mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-06 16:01:36 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f5e4d3949 | |||
| 5f64ffee3f |
@@ -258,14 +258,10 @@ if(WITH_ROCKSDB)
|
||||
set(ROCKSDB_LIBRARIES "IFCOPENSHELL_RocksDB")
|
||||
target_compile_definitions(IFCOPENSHELL_RocksDB INTERFACE IFOPSH_WITH_ROCKSDB)
|
||||
set(SWIG_DEFINES ${SWIG_DEFINES} -DIFOPSH_WITH_ROCKSDB)
|
||||
# Shared binaries for `rocksdb` only support limited API (only `c.h`), but we use `db.h` API.
|
||||
# So rocksdb supported only as a static library.
|
||||
# See https://github.com/facebook/rocksdb/issues/981.
|
||||
if(TARGET RocksDB::rocksdb)
|
||||
target_link_libraries(IFCOPENSHELL_RocksDB INTERFACE RocksDB::rocksdb)
|
||||
elseif(TARGET RocksDB::rocksdb-shared)
|
||||
target_link_libraries(IFCOPENSHELL_RocksDB INTERFACE RocksDB::rocksdb-shared)
|
||||
else()
|
||||
message(FATAL_ERROR "RocksDB found but neither RocksDB::rocksdb nor RocksDB::rocksdb-shared target exists")
|
||||
endif()
|
||||
target_link_libraries(IFCOPENSHELL_RocksDB INTERFACE RocksDB::rocksdb)
|
||||
|
||||
if(WITH_ZSTD)
|
||||
# @todo do we actually need the zstd include dir or rather just pass
|
||||
|
||||
@@ -88,15 +88,7 @@ if(NOT HDF5_INCLUDE_DIR OR NOT HDF5_LIBRARY_DIR)
|
||||
mark_as_advanced(HDF5_DIR)
|
||||
if(HDF5_DIR)
|
||||
message(STATUS "HDF5: found config at '${HDF5_DIR}'.")
|
||||
if(TARGET hdf5_cpp-static)
|
||||
set(HDF5_LIBRARIES hdf5_cpp-static)
|
||||
elseif(TARGET hdf5_cpp-shared)
|
||||
set(HDF5_LIBRARIES hdf5_cpp-shared)
|
||||
elseif(TARGET hdf5::hdf5_cpp-shared)
|
||||
set(HDF5_LIBRARIES hdf5::hdf5_cpp-shared)
|
||||
else()
|
||||
find_package(HDF5 REQUIRED COMPONENTS CXX)
|
||||
endif()
|
||||
set(HDF5_LIBRARIES hdf5_cpp-static)
|
||||
else()
|
||||
# If it failed, still try to find as a module.
|
||||
# E.g. on Ubuntu `libhdf5-dev` doesn't provie hdf5-config.cmake.
|
||||
|
||||
+1
-9
@@ -192,11 +192,7 @@ endif
|
||||
# Provides networkx graph analysis for project dependency calculations
|
||||
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download networkx --dest=./wheels
|
||||
# Required by IFCDiff
|
||||
# Pinned <9.1: deepdiff 9.1.0 adds cachebox<6,>=5.2 which only ships macOS x86_64
|
||||
# wheels for macosx_10_12+ and is incompatible with our macos py311 --platform
|
||||
# macosx_10_10_x86_64 target. Revisit once the macos py311 platform tag is bumped
|
||||
# to 10_13 (matching py312/py313).
|
||||
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download "deepdiff<9.1" --dest=./wheels
|
||||
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download deepdiff --dest=./wheels
|
||||
# Required by IFCCSV and ifcopenshell.util.selector
|
||||
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download lark --dest=./wheels
|
||||
# Required by IFC4D
|
||||
@@ -360,10 +356,6 @@ else
|
||||
pytest test/tool/test_$(MODULE).py --maxfail=1
|
||||
endif
|
||||
|
||||
.PHONY: test-modal
|
||||
test-modal:
|
||||
blender --enable-event-simulate --python test/modal/test_modal.py --window-maximized
|
||||
|
||||
# Reregistering test is not added to the standard test suite because during unregister
|
||||
# Blender removes all Bonsai dependencies breaking dev-environment symlinks.
|
||||
.PHONY: test-reregister
|
||||
|
||||
@@ -29,6 +29,18 @@ from bpy_extras.io_utils import ExportHelper, ImportHelper
|
||||
|
||||
from . import handler, operator, parametric_lifecycle, prop, ui
|
||||
|
||||
|
||||
def _parametric_gizmo_preference_classes() -> list[type]:
|
||||
"""Resolves the registry-driven ``GizmoPreferences<X>`` classes for the
|
||||
``classes`` list below. ``import bonsai.tool`` is kept local to surface
|
||||
the load-order constraint: it relies on ``from . import handler, …``
|
||||
above having primed the
|
||||
``tool/ifc.py → bim/ifc.py → bim/handler.py → bonsai.tool`` cycle."""
|
||||
import bonsai.tool as tool
|
||||
|
||||
return tool.Parametric.iter_gizmo_preference_classes(ui)
|
||||
|
||||
|
||||
try:
|
||||
from bonsai.translations import translations_dict
|
||||
except ImportError:
|
||||
@@ -159,7 +171,12 @@ classes = [
|
||||
ui.BIM_UL_tab_visibilities,
|
||||
ui.BIM_UL_panel_visibilities,
|
||||
ui.DocPreferences,
|
||||
# Per-parametric-type ``GizmoPreferences<Name>`` classes — must register
|
||||
# before ``ui.GizmoPreferences`` which holds the matching PointerProperty
|
||||
# fields. Driven by ``tool.Parametric.EDIT_TYPES``.
|
||||
*_parametric_gizmo_preference_classes(),
|
||||
ui.GizmoPreferences,
|
||||
ui.CrossSelectPreferences,
|
||||
# ui.DefaultParameters and ui.BIM_ADDON_preferences are registered separately after modules (see late_classes below)
|
||||
# Tabs panel
|
||||
ui.BIM_PT_tabs,
|
||||
@@ -317,6 +334,10 @@ def register():
|
||||
tool.Blender.ensure_bin_in_path()
|
||||
# RestrictedContext doesn't allow accessing scene attribute, postpone it for a bit.
|
||||
bpy.app.timers.register(tool.Blender.setup_user_data_dir, first_interval=0.1)
|
||||
# Tools are imported (and their bl_keymap baked) before preferences exist, so they
|
||||
# default to the Cross Select keymap. Once prefs are available, apply the saved
|
||||
# preference (no-op unless the user disabled Cross Select).
|
||||
bpy.app.timers.register(tool.Blender.apply_cross_select_preference, first_interval=0.1)
|
||||
|
||||
|
||||
def unregister():
|
||||
|
||||
@@ -41,21 +41,13 @@ from bonsai.bim.decorator_cache import (
|
||||
from bonsai.bim.ifc import IfcStore, get_cache_or_detect_lock
|
||||
from bonsai.bim.module.aggregate.decorator import AggregateDecorator
|
||||
from bonsai.bim.module.georeference.decorator import GeoreferenceDecorator
|
||||
from bonsai.bim.module.model.array import (
|
||||
ArrayPreviewDecorator,
|
||||
ArraySelectionHighlightDecorator,
|
||||
)
|
||||
from bonsai.bim.module.model.data import AuthoringData
|
||||
from bonsai.bim.module.model.decorator import (
|
||||
BendPreviewDecorator,
|
||||
BoundingBoxDecorator,
|
||||
DoorSwingReadonlyDecorator,
|
||||
MEPSegmentExtendPreviewDecorator,
|
||||
SlabDirectionDecorator,
|
||||
WallAxisDecorator,
|
||||
WallFilletPreviewDecorator,
|
||||
)
|
||||
from bonsai.bim.module.model.wall import WallGizmoPreviewDecorator
|
||||
from bonsai.bim.module.model.preview_base import discard_pending_previews
|
||||
from bonsai.bim.module.nest.decorator import NestDecorator
|
||||
|
||||
cwd = os.path.dirname(os.path.realpath(__file__))
|
||||
@@ -123,13 +115,19 @@ def active_object_callback():
|
||||
|
||||
|
||||
def update_bim_tool_props():
|
||||
"""Selection-driven BIM Tool sync: re-target user-intent enums
|
||||
(ifc_class, relating_type_id) AND refresh header values
|
||||
(extrusion_depth, length, x_angle) for the new active object."""
|
||||
ctx = _resolve_bim_tool_context()
|
||||
if ctx is None:
|
||||
"""update BIM Tools props (such as extrusion_depth, length and x_angle) when active object changes"""
|
||||
obj = bpy.context.active_object
|
||||
|
||||
# bunch of checks to see if we're in a valid state
|
||||
if not obj:
|
||||
return
|
||||
mode = bpy.context.mode
|
||||
current_tool = bpy.context.workspace.tools.from_space_view3d_mode(mode)
|
||||
if not current_tool or current_tool.idname not in tool.Blender.get_list_of_tools():
|
||||
return
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element:
|
||||
return
|
||||
obj, current_tool, element = ctx
|
||||
|
||||
props = tool.Model.get_model_props()
|
||||
aprops = tool.Drawing.get_annotation_props()
|
||||
@@ -152,14 +150,7 @@ def update_bim_tool_props():
|
||||
return
|
||||
|
||||
if is_bim_tool:
|
||||
try:
|
||||
props.ifc_class = element_type.is_a()
|
||||
except TypeError:
|
||||
# ifc_class only lists element/space types present in the model, so an
|
||||
# unsupported type (e.g. a raw IfcTypeProduct) or a stale item list mid-
|
||||
# rebuild raises `enum "<class>" not found`. Skip rather than crash the
|
||||
# handler — it re-fires on the next selection and the panel resyncs.
|
||||
pass
|
||||
props.ifc_class = element_type.is_a()
|
||||
|
||||
# Only assign when the target enum is the one that lists this type — otherwise
|
||||
# we hit `enum "<id>" not found in (...)` if the user selects an element of a
|
||||
@@ -179,48 +170,6 @@ def update_bim_tool_props():
|
||||
if is_annotation_tool:
|
||||
return
|
||||
|
||||
_read_headers_into_props(obj, element)
|
||||
|
||||
|
||||
def refresh_bim_tool_headers():
|
||||
"""Push the active IFC entity's current header float values
|
||||
(extrusion_depth, length, x_angle) into ``BIMModelProperties``.
|
||||
Enum-safe: never writes user-intent enum slots, which are owned by
|
||||
the selection callback."""
|
||||
ctx = _resolve_bim_tool_context()
|
||||
if ctx is None:
|
||||
return
|
||||
obj, current_tool, element = ctx
|
||||
if current_tool.idname not in tool.Blender.get_property_header_tools():
|
||||
return
|
||||
_read_headers_into_props(obj, element)
|
||||
|
||||
|
||||
def _resolve_bim_tool_context():
|
||||
"""Return ``(obj, current_tool, element)`` when an active BIM workspace
|
||||
tool sees a resolvable IFC element; ``None`` otherwise. Defensive
|
||||
against stripped operator contexts — a missing ``active_object`` /
|
||||
``mode`` / ``workspace`` short-circuits to ``None`` instead of raising."""
|
||||
obj = tool.Blender.get_active_object()
|
||||
if not obj:
|
||||
return None
|
||||
mode = getattr(bpy.context, "mode", None)
|
||||
workspace = getattr(bpy.context, "workspace", None)
|
||||
if mode is None or workspace is None:
|
||||
return None
|
||||
current_tool = workspace.tools.from_space_view3d_mode(mode)
|
||||
if not current_tool or current_tool.idname not in tool.Blender.get_list_of_tools():
|
||||
return None
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element:
|
||||
return None
|
||||
return obj, current_tool, element
|
||||
|
||||
|
||||
def _read_headers_into_props(obj, element):
|
||||
"""Populate ``BIMModelProperties`` header values from the active
|
||||
object's IFC extrusion. Enum-safe: writes only header floats, never
|
||||
user-intent enum slots, so it is safe to call on the post-commit hook."""
|
||||
representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
|
||||
if not representation:
|
||||
return
|
||||
@@ -238,7 +187,6 @@ def _read_headers_into_props(obj, element):
|
||||
if not AuthoringData.is_loaded:
|
||||
AuthoringData.load()
|
||||
|
||||
props = tool.Model.get_model_props()
|
||||
if AuthoringData.data["active_material_usage"] == "LAYER2":
|
||||
x_angle = get_x_angle(extrusion)
|
||||
axis = tool.Model.get_wall_axis(obj)["reference"]
|
||||
@@ -437,8 +385,8 @@ def subscribe_to_viewport_shading_changes():
|
||||
|
||||
def _apply_save_file_invariants(scene: bpy.types.Scene) -> None:
|
||||
"""Invariants enforced on every load_post: msgbus subscription, IFC owner
|
||||
settings, scene-bound caches, load-transient parametric state, and the
|
||||
multi-instance lock probe."""
|
||||
settings, scene-bound caches, draft-flag healing, multi-instance lock probe,
|
||||
and previews discarded so saved preview state never resurfaces on reopen."""
|
||||
global global_subscription_owner
|
||||
active_object_key = bpy.types.LayerObjects, "active"
|
||||
bpy.msgbus.subscribe_rna(
|
||||
@@ -449,7 +397,8 @@ def _apply_save_file_invariants(scene: bpy.types.Scene) -> None:
|
||||
ifcopenshell.api.owner.settings.get_application = get_application
|
||||
AuthoringData.type_thumbnails = {}
|
||||
|
||||
tool.Parametric.on_load_post(scene)
|
||||
tool.Parametric.heal_stale_edit_flags()
|
||||
discard_pending_previews(scene)
|
||||
|
||||
if tool.Ifc.get() and bpy.data.is_saved:
|
||||
props = tool.Blender.get_bim_props()
|
||||
@@ -513,13 +462,6 @@ def _install_viewport_overlays() -> None:
|
||||
NestDecorator.uninstall()
|
||||
WallAxisDecorator.uninstall()
|
||||
SlabDirectionDecorator.uninstall()
|
||||
WallFilletPreviewDecorator.uninstall()
|
||||
BendPreviewDecorator.uninstall()
|
||||
MEPSegmentExtendPreviewDecorator.uninstall()
|
||||
WallGizmoPreviewDecorator.uninstall()
|
||||
DoorSwingReadonlyDecorator.uninstall()
|
||||
ArrayPreviewDecorator.uninstall()
|
||||
ArraySelectionHighlightDecorator.uninstall()
|
||||
uninstall_decorator_cache_handlers()
|
||||
try:
|
||||
if georeference_props.should_visualise:
|
||||
@@ -534,30 +476,6 @@ def _install_viewport_overlays() -> None:
|
||||
SlabDirectionDecorator.install(bpy.context)
|
||||
if model_props.show_bounding_box:
|
||||
BoundingBoxDecorator.install(bpy.context)
|
||||
# Always-installed: draw() self-polls on Scene.BIMPreviewProperties.
|
||||
# wall_fillet.is_active, so installation has no cost when no preview
|
||||
# is open. No corresponding addon-preference toggle.
|
||||
WallFilletPreviewDecorator.install(bpy.context)
|
||||
# Always-installed siblings of WallFilletPreviewDecorator: each
|
||||
# self-polls on its own scene.BIMPreviewProperties subgroup or on
|
||||
# selection + hover gizmo state — zero cost when nothing is active.
|
||||
BendPreviewDecorator.install(bpy.context)
|
||||
MEPSegmentExtendPreviewDecorator.install(bpy.context)
|
||||
# Always-installed: draw_lines() self-polls on selection + hover state
|
||||
# for join / extend-to-wall / cursor-extend / cursor-split previews.
|
||||
# Free when no preview-eligible state is active.
|
||||
WallGizmoPreviewDecorator.install(bpy.context)
|
||||
# Always-installed: draw() self-polls on active object + IfcDoor +
|
||||
# parametric pset, so the cost is one bpy/IFC lookup per redraw when
|
||||
# nothing eligible is selected.
|
||||
DoorSwingReadonlyDecorator.install(bpy.context)
|
||||
# Always-installed: draw() self-polls on the active object's array
|
||||
# family membership, so installation has no cost when no array
|
||||
# element is selected.
|
||||
ArraySelectionHighlightDecorator.install(bpy.context)
|
||||
# Always-installed: draw() self-polls on props.is_editing — only
|
||||
# paints during an active array edit lifecycle.
|
||||
ArrayPreviewDecorator.install(bpy.context)
|
||||
finally:
|
||||
install_decorator_cache_handlers()
|
||||
|
||||
|
||||
@@ -552,7 +552,7 @@ class IfcStore:
|
||||
BrickStore.end_transaction()
|
||||
IfcStore.end_transaction(operator)
|
||||
bonsai.bim.handler.refresh_ui_data()
|
||||
tool.Parametric.refresh_post_commit(operator)
|
||||
tool.Parametric.refresh_post_commit()
|
||||
|
||||
if method == "MODAL":
|
||||
cls.modal_in_progress = False
|
||||
@@ -566,19 +566,6 @@ class IfcStore:
|
||||
result = getattr(operator, "_modal")(context, event)
|
||||
except:
|
||||
bonsai.last_error = traceback.format_exc()
|
||||
# An operator that mutated IFC then raised leaves the IFC graph captured
|
||||
# by the transaction but the Blender side stale. Blender does not push an
|
||||
# undo step for a raised operator (mirror of the CANCELLED-modal gap
|
||||
# handled below), so we push one here so Ctrl+Z actually rewinds the
|
||||
# partial mutation, then surface the recovery path to the user.
|
||||
ifc_file = tool.Ifc.get()
|
||||
if ifc_file and ifc_file.transaction and ifc_file.transaction.operations:
|
||||
bpy.ops.ed.undo_push(message=f"Recover {operator.bl_idname}")
|
||||
operator.report(
|
||||
{"WARNING"},
|
||||
"Operation partially completed (IFC changed, Blender state may be stale). "
|
||||
"Press Ctrl+Z to restore the previous state.",
|
||||
)
|
||||
# Try to ensure undo will work since Blender undo does work in case of errors.
|
||||
# As error come unexpectedly, it's important that user might have a chance to save the file
|
||||
# before they got the error and not to lose the work they've done.
|
||||
|
||||
@@ -1219,8 +1219,8 @@ class IfcImporter:
|
||||
if element not in elements_to_import:
|
||||
continue
|
||||
for i in range(len(data)):
|
||||
tool.Array.set_children_lock_state(element, i, True)
|
||||
tool.Array.constrain_children_to_parent(element)
|
||||
tool.Blender.Modifier.Array.set_children_lock_state(element, i, True)
|
||||
tool.Blender.Modifier.Array.constrain_children_to_parent(element)
|
||||
|
||||
def update_linked_aggregates(self):
|
||||
# TODO Remove this after a while. See commit 17d6b8a
|
||||
|
||||
@@ -345,17 +345,9 @@ class CadArcFrom3Points(bpy.types.Operator):
|
||||
class CadOffset(bpy.types.Operator):
|
||||
bl_idname = "bim.cad_offset"
|
||||
bl_label = "CAD Offset"
|
||||
bl_description = (
|
||||
"Offset selected mesh geometry at provided distance, based on the current viewport angle. "
|
||||
"Creates a copy by default, or moves the existing edges if Copy is disabled."
|
||||
)
|
||||
bl_description = "Copy selected mesh geometry at provided offset. Mesh copied based on the current viewport angle."
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
distance: bpy.props.FloatProperty(name="Distance", default=0.1, subtype="DISTANCE")
|
||||
copy: bpy.props.BoolProperty(
|
||||
name="Copy",
|
||||
description="Create a new offset copy of the geometry. If disabled, move the existing edges to the offset location",
|
||||
default=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
@@ -413,11 +405,6 @@ class CadOffset(bpy.types.Operator):
|
||||
rotation = Matrix.Rotation(pi / 2, 2, "Z")
|
||||
rotation_i = Matrix.Rotation(-pi / 2, 2, "Z")
|
||||
|
||||
# When not copying, the offset positions are gathered here and applied to
|
||||
# the existing verts only after all loops are processed, so that the
|
||||
# original coordinates are still available while computing offsets.
|
||||
moved_verts = []
|
||||
|
||||
# Create loops from edges
|
||||
loop_edges = set(edges)
|
||||
loops = []
|
||||
@@ -530,15 +517,12 @@ class CadOffset(bpy.types.Operator):
|
||||
offset_length = self.distance / sqrt((1 + normals[0].dot(normals[1])) / 2)
|
||||
offset = mw.inverted().to_quaternion() @ (wp.to_quaternion() @ (new_normal * offset_length).to_3d())
|
||||
new_vert = v1.co + offset
|
||||
new_verts.append(bm.verts.new(new_vert))
|
||||
else:
|
||||
normal = (normals[0] * self.distance).to_3d()
|
||||
offset = mw.inverted().to_quaternion() @ (wp.to_quaternion() @ normal)
|
||||
new_vert = v1.co + offset
|
||||
|
||||
if self.copy:
|
||||
new_verts.append(bm.verts.new(new_vert))
|
||||
else:
|
||||
moved_verts.append((v1, new_vert))
|
||||
|
||||
processed_verts.add(v1.index)
|
||||
|
||||
@@ -547,14 +531,9 @@ class CadOffset(bpy.types.Operator):
|
||||
|
||||
v1 = v2
|
||||
|
||||
if self.copy:
|
||||
[bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)]
|
||||
if is_closed:
|
||||
bm.edges.new((new_verts[len(new_verts) - 1], new_verts[0]))
|
||||
|
||||
# Move the existing edges to the offset location.
|
||||
for vert, new_co in moved_verts:
|
||||
vert.co = new_co
|
||||
[bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)]
|
||||
if is_closed:
|
||||
bm.edges.new((new_verts[len(new_verts) - 1], new_verts[0]))
|
||||
|
||||
bm.verts.index_update()
|
||||
bm.edges.index_update()
|
||||
|
||||
@@ -27,11 +27,6 @@ class BIMCadProperties(PropertyGroup):
|
||||
resolution: bpy.props.IntProperty(name="Arc Resolution", min=1, default=1)
|
||||
radius: bpy.props.FloatProperty(name="Radius", default=0.1, subtype="DISTANCE")
|
||||
distance: bpy.props.FloatProperty(name="Distance", default=0.1, subtype="DISTANCE")
|
||||
copy: bpy.props.BoolProperty(
|
||||
name="Copy",
|
||||
description="Create a new offset copy of the geometry. If disabled, move the existing edges to the offset location",
|
||||
default=True,
|
||||
)
|
||||
x: bpy.props.FloatProperty(name="X", default=0.2, subtype="DISTANCE")
|
||||
y: bpy.props.FloatProperty(name="Y", default=0.1, subtype="DISTANCE")
|
||||
gable_roof_edge_angle: bpy.props.FloatProperty(
|
||||
@@ -42,7 +37,6 @@ class BIMCadProperties(PropertyGroup):
|
||||
resolution: int
|
||||
radius: float
|
||||
distance: float
|
||||
copy: bool
|
||||
x: float
|
||||
y: float
|
||||
gable_roof_edge_angle: float
|
||||
|
||||
@@ -256,8 +256,6 @@ class CadHotkey(bpy.types.Operator):
|
||||
elif self.hotkey == "S_O":
|
||||
row = self.layout.row()
|
||||
row.prop(props, "distance")
|
||||
row = self.layout.row()
|
||||
row.prop(props, "copy")
|
||||
|
||||
elif self.hotkey == "S_R":
|
||||
if tool.Geometry.is_profile_object_active():
|
||||
@@ -293,7 +291,7 @@ class CadHotkey(bpy.types.Operator):
|
||||
bpy.ops.bim.cad_fillet(resolution=self.props.resolution, radius=self.props.radius)
|
||||
|
||||
def hotkey_S_O(self):
|
||||
bpy.ops.bim.cad_offset(distance=self.props.distance, copy=self.props.copy)
|
||||
bpy.ops.bim.cad_offset(distance=self.props.distance)
|
||||
|
||||
def hotkey_S_Q(self):
|
||||
obj = bpy.context.active_object
|
||||
|
||||
@@ -138,26 +138,15 @@ classes = (
|
||||
gizmos.GizmoArrow2D,
|
||||
gizmos.GizmoCone,
|
||||
gizmos.GizmoDimension,
|
||||
gizmos.GizmoLockOpen,
|
||||
gizmos.GizmoLockClosed,
|
||||
gizmos.GizmoLock,
|
||||
gizmos.GizmoArc,
|
||||
gizmos.GizmoLinkToggle,
|
||||
gizmos.GizmoFillet,
|
||||
gizmos.GizmoWallCornerIcon,
|
||||
gizmos.GizmoWallTeeIcon,
|
||||
gizmos.GizmoPen,
|
||||
gizmos.GizmoValidate,
|
||||
gizmos.GizmoCancel,
|
||||
gizmos.GizmoPlus,
|
||||
gizmos.GizmoMinus,
|
||||
gizmos.GizmoTrash,
|
||||
gizmos.GizmoArrayParent,
|
||||
gizmos.GizmoArrayAll,
|
||||
gizmos.GizmoArrayLayerIndicator,
|
||||
gizmos.GizmoCountLabel,
|
||||
gizmos.GizmoMerge,
|
||||
gizmos.GizmoSplit,
|
||||
gizmos.GizmoUnjoin,
|
||||
gizmos.GizmoExtend,
|
||||
gizmos.GizmoExtendVertical,
|
||||
gizmos.GizmoOffsetExterior,
|
||||
@@ -165,7 +154,6 @@ classes = (
|
||||
gizmos.GizmoOffsetInterior,
|
||||
gizmos.GizmoAddOpening,
|
||||
gizmos.GizmoCycle,
|
||||
gizmos.GizmoMenu,
|
||||
# Drawing-specific gizmos
|
||||
gizmos.UglyDotGizmo,
|
||||
gizmos.ExtrusionGuidesGizmo,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -44,12 +44,8 @@ class ViewportData:
|
||||
|
||||
@classmethod
|
||||
def load(cls):
|
||||
# Populate data BEFORE flipping is_loaded so a raising ``mode()``
|
||||
# call doesn't leave the class half-loaded (flag set, dict empty).
|
||||
# Subsequent items-callback invocations skip load() on a True flag
|
||||
# and would hit ``cls.data["mode"]`` → KeyError.
|
||||
cls.data = {"mode": cls.mode()}
|
||||
cls.is_loaded = True
|
||||
cls.data = {"mode": cls.mode()}
|
||||
|
||||
@classmethod
|
||||
def mode(cls) -> tool.Blender.BLENDER_ENUM_ITEMS:
|
||||
@@ -80,9 +76,9 @@ class ViewportData:
|
||||
modes.append(edit_mode)
|
||||
elif element.is_a("IfcGridAxis"):
|
||||
modes.append(edit_mode)
|
||||
elif tool.Parametric.is_roof(element):
|
||||
elif tool.Blender.Modifier.is_roof(element):
|
||||
modes.append(edit_mode)
|
||||
elif tool.Parametric.is_railing(element):
|
||||
elif tool.Blender.Modifier.is_railing(element):
|
||||
modes.append(edit_mode)
|
||||
elif item_mode not in modes:
|
||||
modes.append(item_mode)
|
||||
|
||||
@@ -60,7 +60,6 @@ import bonsai.core.root
|
||||
import bonsai.core.spatial
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
from bonsai.bim.module.model import preview_base
|
||||
from bonsai.bim.module.model.decorator import ProfileDecorator
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -546,13 +545,6 @@ class UpdateRepresentation(bpy.types.Operator, tool.Ifc.Operator):
|
||||
objs = [bpy.data.objects[obj_name]] if obj_name else context.selected_objects
|
||||
self.file = tool.Ifc.get()
|
||||
|
||||
# Tessellated face sets (IfcTriangulatedFaceSet/IfcPolygonalFaceSet) were
|
||||
# introduced in IFC4 and do not exist in IFC2X3. Catch this early so we
|
||||
# don't silently fall back to a faceted brep after stripping materials.
|
||||
if self.ifc_representation_class == "IfcTessellatedFaceSet" and self.file.schema == "IFC2X3":
|
||||
self.report({"ERROR"}, "Tessellated face sets are not supported in IFC2X3.")
|
||||
return {"CANCELLED"}
|
||||
|
||||
for obj in objs:
|
||||
# TODO: write unit tests to see how this bulk operation handles
|
||||
# contradictory ifc_representation_class values and when
|
||||
@@ -1034,10 +1026,10 @@ class OverrideDelete(bpy.types.Operator):
|
||||
|
||||
for array_parent in array_parents:
|
||||
array_parent_obj = tool.Ifc.get_object(array_parent)
|
||||
data = [(i, data) for i, data in enumerate(tool.Array.get_modifiers_data(array_parent))]
|
||||
data = [(i, data) for i, data in enumerate(tool.Blender.Modifier.Array.get_modifiers_data(array_parent))]
|
||||
# NOTE: there is a way to remove arrays more precisely but it's more complex
|
||||
for i, modifier_data in reversed(data):
|
||||
children = set(tool.Array.get_children_objects(modifier_data))
|
||||
children = set(tool.Blender.Modifier.Array.get_children_objects(modifier_data))
|
||||
if children.issubset(selected_objects):
|
||||
with context.temp_override(active_object=array_parent_obj):
|
||||
bpy.ops.bim.remove_array(item=i)
|
||||
@@ -1296,7 +1288,9 @@ class OverrideDuplicateMove(bpy.types.Operator):
|
||||
all_objects_to_select.add(part_obj)
|
||||
|
||||
# Non-IFC duplicates aren't tracked in old_to_new but are left selected by duplicate_ifc_objects
|
||||
all_objects_to_select.update(obj for obj in context.selected_objects if not tool.Ifc.get_entity(obj))
|
||||
all_objects_to_select.update(
|
||||
obj for obj in context.selected_objects if not tool.Ifc.get_entity(obj)
|
||||
)
|
||||
|
||||
# Deselect everything first
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
@@ -2234,8 +2228,6 @@ class OverrideEscape(bpy.types.Operator):
|
||||
bpy.ops.bim.hide_all_openings()
|
||||
elif tool.Aggregate.get_aggregate_props().in_aggregate_mode:
|
||||
bpy.ops.bim.disable_aggregate_mode()
|
||||
elif preview_base.try_cancel_active_preview(context):
|
||||
pass
|
||||
elif active_object := context.active_object:
|
||||
if tool.Blender.Modifier.try_canceling_editing_modifier_parameters_or_path(active_object):
|
||||
pass
|
||||
@@ -2502,9 +2494,9 @@ class OverrideModeSetObject(bpy.types.Operator, tool.Ifc.Operator):
|
||||
profile = tool.Ifc.get().by_id(profile_id)
|
||||
if tool.Ifc.get_object(profile): # We are editing an arbitrary profile
|
||||
bpy.ops.bim.edit_arbitrary_profile()
|
||||
elif tool.Parametric.is_railing(element):
|
||||
elif tool.Blender.Modifier.is_railing(element):
|
||||
bpy.ops.bim.finish_editing_railing_path()
|
||||
elif tool.Parametric.is_roof(element):
|
||||
elif tool.Blender.Modifier.is_roof(element):
|
||||
bpy.ops.bim.finish_editing_roof_path()
|
||||
elif tool.Model.get_usage_type(element) == "PROFILE":
|
||||
bpy.ops.bim.edit_extrusion_axis()
|
||||
@@ -3173,7 +3165,7 @@ class EnableEditingRepresentationItems(bpy.types.Operator, tool.Ifc.Operator):
|
||||
product_reps = element.RepresentationMaps
|
||||
item_aspect = {}
|
||||
for product_rep in product_reps:
|
||||
for aspect in getattr(product_rep, "HasShapeAspects", ()):
|
||||
for aspect in product_rep.HasShapeAspects:
|
||||
for aspect_rep in aspect.ShapeRepresentations:
|
||||
if aspect_rep.ContextOfItems != representation.ContextOfItems:
|
||||
continue
|
||||
|
||||
@@ -31,9 +31,7 @@ from . import (
|
||||
external,
|
||||
grid,
|
||||
handler,
|
||||
host_add_opening_gizmo,
|
||||
mep,
|
||||
mep_bend_preview,
|
||||
opening,
|
||||
product,
|
||||
profile,
|
||||
@@ -52,28 +50,17 @@ from . import (
|
||||
|
||||
classes = (
|
||||
array.AddArray,
|
||||
array.CancelEditingArray,
|
||||
array.DisableEditingArray,
|
||||
array.EditArray,
|
||||
array.EnableEditingArray,
|
||||
array.FinishEditingArray,
|
||||
array.ApplyArray,
|
||||
array.RegenerateArray,
|
||||
array.RemoveArray,
|
||||
array.SelectAllArrayObjects,
|
||||
array.SelectArrayParent,
|
||||
array.ArrayParentGizmoClick,
|
||||
array.EditArrayFromChild,
|
||||
array.Input3DCursorXArray,
|
||||
array.Input3DCursorYArray,
|
||||
array.Input3DCursorZArray,
|
||||
array.EnableEditingParametric,
|
||||
array.AddArrayFromFeatureEdit,
|
||||
array.ArrayGizmoClick,
|
||||
array.ToggleArrayMethod,
|
||||
array.RemoveArrayLayerFromEdit,
|
||||
array.InputArrayCount,
|
||||
array.AdjustArrayCount,
|
||||
array.GizmoArrayEdition,
|
||||
array.GizmoArrayChild,
|
||||
product.AddDefaultType,
|
||||
product.AddEmptyType,
|
||||
product.AddOccurrence,
|
||||
@@ -84,8 +71,8 @@ classes = (
|
||||
product.MirrorElements,
|
||||
product.SetActiveType,
|
||||
workspace.Hotkey,
|
||||
workspace.CrossSelect,
|
||||
workspace.BIM_MT_add_representation_item,
|
||||
wall.AddPerpendicularWall,
|
||||
wall.AddWallsFromSlab,
|
||||
wall.AlignWall,
|
||||
wall.CancelEditingWall,
|
||||
@@ -97,22 +84,15 @@ classes = (
|
||||
wall.EnableEditingWall,
|
||||
wall.ExtendWallHeightToCursor,
|
||||
wall.ExtendWallsToUnderside,
|
||||
wall.RegenerateWallToUnderside,
|
||||
wall.ExtendWallsToWall,
|
||||
wall.ExtendWallsToPolylinePoint,
|
||||
wall.ExtendWallToCursor,
|
||||
wall.FinishEditingWall,
|
||||
wall.FlipWall,
|
||||
host_add_opening_gizmo.GizmoHostAddOpening,
|
||||
host_add_opening_gizmo.GizmoHostToggleOpenings,
|
||||
wall.GizmoWallAddOpening,
|
||||
wall.GizmoWallEdition,
|
||||
wall.GizmoWallExtendVertically,
|
||||
wall.GizmoWallFilletPreview,
|
||||
wall.GizmoWallFilletReedit,
|
||||
wall.GizmoWallFilletToggleOpenings,
|
||||
wall.GizmoWallJoinIntersection,
|
||||
wall.GizmoWallLinkToggle,
|
||||
wall.GizmoWallUnjoinSingle,
|
||||
wall.JoinWallsIntersection,
|
||||
wall.MergeWall,
|
||||
wall.OffsetWalls,
|
||||
@@ -120,13 +100,8 @@ classes = (
|
||||
wall.RotateWall90,
|
||||
wall.SplitWall,
|
||||
wall.SplitWallAtCursor,
|
||||
wall.UnjoinWallPathConnection,
|
||||
wall.ToggleWallOpenings,
|
||||
wall.UnjoinWalls,
|
||||
wall.EnableWallFilletPreview,
|
||||
wall.FinishWallFilletPreview,
|
||||
wall.CancelWallFilletPreview,
|
||||
wall.EnableWallFilletPreviewFromCorner,
|
||||
wall.CreateWallFillet,
|
||||
opening.AddBoolean,
|
||||
opening.CloneOpening,
|
||||
opening.EditOpenings,
|
||||
@@ -138,7 +113,6 @@ classes = (
|
||||
opening.RemoveBoolean,
|
||||
opening.SelectBoolean,
|
||||
opening.ShowOpenings,
|
||||
opening.ToggleHostOpenings,
|
||||
opening.UpdateOpeningsFocus,
|
||||
profile.ChangeCardinalPoint,
|
||||
profile.ChangeProfileDepth,
|
||||
@@ -186,14 +160,8 @@ classes = (
|
||||
prop.BIMRailingProperties,
|
||||
prop.BIMRoofProperties,
|
||||
prop.BIMWallProperties,
|
||||
prop.BIMPipeSegmentProperties,
|
||||
prop.BIMDuctSegmentProperties,
|
||||
prop.BIMPolylineProperties,
|
||||
prop.BIMExternalParametricGeometryProperties,
|
||||
prop.BIMBendPreviewProperties,
|
||||
prop.BIMWallFilletPreviewProperties,
|
||||
prop.BIMPreviewProperties,
|
||||
prop.BIMParametricEditDialogPrefs,
|
||||
ui.BIM_PT_array,
|
||||
ui.BIM_PT_stair,
|
||||
ui.BIM_PT_wall,
|
||||
@@ -217,8 +185,7 @@ classes = (
|
||||
stair.ToggleStairProperty,
|
||||
stair.AdjustStairTreads,
|
||||
stair.SetStairTreads,
|
||||
stair.InputStairTreads,
|
||||
stair.PickStairType,
|
||||
stair.CycleStairType,
|
||||
stair.GizmoStairEdition,
|
||||
sverchok_modifier.CreateNewSverchokGraph,
|
||||
sverchok_modifier.UpdateDataFromSverchok,
|
||||
@@ -231,7 +198,7 @@ classes = (
|
||||
window.FinishEditingWindow,
|
||||
window.EnableEditingWindow,
|
||||
window.RemoveWindow,
|
||||
window.PickWindowType,
|
||||
window.CycleWindowType,
|
||||
window.GizmoWindowEdition,
|
||||
door.BIM_OT_add_door,
|
||||
door.AddDoor,
|
||||
@@ -240,7 +207,7 @@ classes = (
|
||||
door.EnableEditingDoor,
|
||||
door.RemoveDoor,
|
||||
door.ToggleDoorSwing,
|
||||
door.PickDoorType,
|
||||
door.CycleDoorType,
|
||||
door.GizmoDoorEdition,
|
||||
railing.BIM_OT_add_railing,
|
||||
railing.CopyRailingParameters,
|
||||
@@ -257,41 +224,16 @@ classes = (
|
||||
roof.AddRoof,
|
||||
roof.CancelEditingRoof,
|
||||
roof.CopyRoofParameters,
|
||||
roof.CycleRoofGenerationMethod,
|
||||
roof.FinishEditingRoof,
|
||||
roof.EnableEditingRoof,
|
||||
roof.CancelEditingRoofPath,
|
||||
roof.FinishEditingRoofPath,
|
||||
roof.EnableEditingRoofPath,
|
||||
roof.GizmoRoofEdition,
|
||||
roof.RemoveRoof,
|
||||
roof.SetGableRoofEdgeAngle,
|
||||
mep.MEPAddObstruction,
|
||||
mep.MEPAddTransition,
|
||||
mep.MEPAddBend,
|
||||
mep.MEPUnjoinAtPort,
|
||||
mep.MEPRemoveTerminalFitting,
|
||||
mep.MEPUnjoinPair,
|
||||
mep.SelectMEPPathMembers,
|
||||
mep.MEPJoinSegments,
|
||||
mep_bend_preview.EnableBendPreview,
|
||||
mep_bend_preview.FinishBendPreview,
|
||||
mep_bend_preview.CancelBendPreview,
|
||||
mep_bend_preview.EnableBendPreviewFromBend,
|
||||
mep_bend_preview.GizmoBendPreview,
|
||||
mep.EnableEditingPipeSegment,
|
||||
mep.FinishEditingPipeSegment,
|
||||
mep.CancelEditingPipeSegment,
|
||||
mep.EnableEditingDuctSegment,
|
||||
mep.FinishEditingDuctSegment,
|
||||
mep.CancelEditingDuctSegment,
|
||||
mep.ExtendPipeSegmentToCursor,
|
||||
mep.ExtendDuctSegmentToCursor,
|
||||
mep.SplitPipeSegmentAtCursor,
|
||||
mep.SplitDuctSegmentAtCursor,
|
||||
mep.GizmoPipeSegmentEdition,
|
||||
mep.GizmoDuctSegmentEdition,
|
||||
mep.GizmoMEPActions,
|
||||
external.ApplyExternalParametricGeometry,
|
||||
)
|
||||
|
||||
@@ -350,10 +292,6 @@ def register():
|
||||
bpy.types.Object.BIMExternalParametricGeometryProperties = bpy.props.PointerProperty(
|
||||
type=prop.BIMExternalParametricGeometryProperties
|
||||
)
|
||||
bpy.types.Scene.BIMPreviewProperties = bpy.props.PointerProperty(type=prop.BIMPreviewProperties)
|
||||
bpy.types.WindowManager.BIMParametricEditDialogPrefs = bpy.props.PointerProperty(
|
||||
type=prop.BIMParametricEditDialogPrefs
|
||||
)
|
||||
|
||||
bpy.types.VIEW3D_MT_add.prepend(ui.add_menu)
|
||||
bpy.app.handlers.load_post.append(handler.load_post)
|
||||
@@ -378,8 +316,6 @@ def unregister():
|
||||
del bpy.types.Object.BIMSverchokProperties
|
||||
tool.Parametric.unregister_object_properties()
|
||||
del bpy.types.Object.BIMExternalParametricGeometryProperties
|
||||
del bpy.types.Scene.BIMPreviewProperties
|
||||
del bpy.types.WindowManager.BIMParametricEditDialogPrefs
|
||||
|
||||
bpy.app.handlers.load_post.remove(handler.load_post)
|
||||
bpy.types.VIEW3D_MT_add.remove(ui.add_menu)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,14 +15,12 @@
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was modified with the assistance of an AI coding tool.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from math import cos, pi, radians, sin, tan
|
||||
from typing import Any, Literal, NamedTuple
|
||||
from typing import Any, Literal
|
||||
|
||||
import blf
|
||||
import bmesh
|
||||
@@ -43,11 +41,6 @@ from mathutils import Matrix, Quaternion, Vector
|
||||
|
||||
import bonsai.core.geometry
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.drawing.gizmos import (
|
||||
ARC_SEGMENTS,
|
||||
DOOR_SWING_ANGLE_MAX,
|
||||
DOOR_SWING_ANGLE_MIN,
|
||||
)
|
||||
from bonsai.bim.module.drawing.helper import format_distance
|
||||
|
||||
|
||||
@@ -95,9 +88,15 @@ class ProfileDecorator:
|
||||
batch.draw(shader)
|
||||
|
||||
def draw_faces(self, bm, vertices_coords):
|
||||
"""Submit a non-mutating beauty-triangulated TRIS batch over ``bm``'s faces."""
|
||||
"""mutates original bm (triangulates it)
|
||||
so the triangulation edges will be shown too
|
||||
"""
|
||||
traingulated_bm = bm
|
||||
bmesh.ops.triangulate(traingulated_bm, faces=traingulated_bm.faces)
|
||||
|
||||
face_indices = [[v.index for v in f.verts] for f in traingulated_bm.faces]
|
||||
faces_color = transparent_color(self.addon_prefs.decorator_color_special)
|
||||
tool.Blender.draw_bmesh_face_tris(bm, vertices_coords, faces_color, self.draw_batch)
|
||||
self.draw_batch("TRIS", vertices_coords, faces_color, face_indices)
|
||||
|
||||
def __call__(self, context, get_custom_bmesh=None, draw_faces=False, exit_edit_mode_callback=None):
|
||||
self.addon_prefs = tool.Blender.get_addon_preferences()
|
||||
@@ -109,7 +108,7 @@ class ProfileDecorator:
|
||||
|
||||
obj = context.active_object
|
||||
|
||||
if obj is None or obj.mode != "EDIT":
|
||||
if obj.mode != "EDIT":
|
||||
if exit_edit_mode_callback:
|
||||
ProfileDecorator.uninstall()
|
||||
exit_edit_mode_callback()
|
||||
@@ -2030,502 +2029,3 @@ class BoundingBoxDecorator:
|
||||
else:
|
||||
co1.y += y_overlap / 2 + min_spacing
|
||||
co2.y -= y_overlap / 2 + min_spacing
|
||||
|
||||
|
||||
def _fill_quads_alpha(
|
||||
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],
|
||||
alpha: float,
|
||||
) -> None:
|
||||
"""Render ``quads`` (each a 4-tuple of world-space corner verts in CCW
|
||||
order) as one TRIS batch with two triangles per quad."""
|
||||
if not quads:
|
||||
return
|
||||
verts: list[tuple[float, float, float]] = []
|
||||
indices: list[tuple[int, int, int]] = []
|
||||
for quad in quads:
|
||||
if len(quad) != 4:
|
||||
continue
|
||||
base = len(verts)
|
||||
verts.extend(tuple(v) for v in quad)
|
||||
indices.append((base, base + 1, base + 2))
|
||||
indices.append((base, base + 2, base + 3))
|
||||
if not tool.Blender.validate_shader_batch_data(verts, indices):
|
||||
return
|
||||
region = getattr(context, "region", None)
|
||||
if region is None:
|
||||
return
|
||||
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
|
||||
shader.bind()
|
||||
shader.uniform_float("color", (*color_rgb, alpha))
|
||||
batch = batch_for_shader(shader, "TRIS", {"pos": verts}, indices=indices)
|
||||
gpu.state.blend_set("ALPHA")
|
||||
batch.draw(shader)
|
||||
gpu.state.blend_set("NONE")
|
||||
|
||||
|
||||
def compute_mep_join_location():
|
||||
"""Midpoint between the closest endpoint pair of two selected MEP
|
||||
segments — the world location where a connecting fitting (bend /
|
||||
transition) would land. Returns ``None`` when prerequisites aren't met
|
||||
(wrong cardinality, mixed non-MEP)."""
|
||||
selected = list(tool.Blender.get_selected_objects())
|
||||
if len(selected) != 2:
|
||||
return None
|
||||
for obj in selected:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if element is None or not tool.System.is_mep_element(element):
|
||||
return None
|
||||
a_start, a_end = tool.Model.get_flow_segment_axis(selected[0])
|
||||
b_start, b_end = tool.Model.get_flow_segment_axis(selected[1])
|
||||
pairs = [(a_start, b_start), (a_start, b_end), (a_end, b_start), (a_end, b_end)]
|
||||
closest = min(pairs, key=lambda p: (p[0] - p[1]).length)
|
||||
return (closest[0] + closest[1]) * 0.5
|
||||
|
||||
|
||||
class MEPSegmentExtendPreviewDecorator(tool.Blender.ViewportDecorator):
|
||||
"""Preview line for the MEP segment extend-to-cursor gizmo. Renders one
|
||||
line from the segment's current end to the cursor's projection on the
|
||||
segment's local Z axis when the extend icon is hovered. Self-gates every
|
||||
draw on the viewport gizmo toggle and the per-feature ``extend`` pref."""
|
||||
|
||||
draw_method = "draw_line"
|
||||
|
||||
LINE_WIDTH = 1.5
|
||||
LINE_ALPHA = 0.8
|
||||
|
||||
def draw_line(self, context: bpy.types.Context) -> None:
|
||||
if not tool.Blender.are_viewport_gizmos_enabled():
|
||||
return
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
|
||||
active = context.active_object
|
||||
if active is None:
|
||||
return
|
||||
selected = list(tool.Blender.get_selected_objects())
|
||||
if active not in selected or len(selected) != 1:
|
||||
return
|
||||
|
||||
element = tool.Ifc.get_entity(active)
|
||||
if element is None:
|
||||
return
|
||||
|
||||
from bonsai.bim.module.model.mep import (
|
||||
GizmoDuctSegmentEdition,
|
||||
GizmoPipeSegmentEdition,
|
||||
)
|
||||
|
||||
if tool.Parametric.is_pipe_segment(element):
|
||||
gizmo_prefs = getattr(prefs.gizmos, "pipe_segment", None)
|
||||
gizmo_cls = GizmoPipeSegmentEdition
|
||||
elif tool.Parametric.is_duct_segment(element):
|
||||
gizmo_prefs = getattr(prefs.gizmos, "duct_segment", None)
|
||||
gizmo_cls = GizmoDuctSegmentEdition
|
||||
else:
|
||||
return
|
||||
if gizmo_prefs is None or not getattr(gizmo_prefs, "enabled", True):
|
||||
return
|
||||
if not self._cursor_icon_hovered(gizmo_cls, "extend_gizmo", context):
|
||||
return
|
||||
|
||||
current_length = max(c[2] for c in active.bound_box) if active.bound_box else 0.0
|
||||
line = self._compute_extend_preview_line(active.matrix_world, context.scene.cursor.location, current_length)
|
||||
if line is None:
|
||||
return
|
||||
start_world, end_world = line
|
||||
color = tuple(prefs.decorator_color_selected[:3])
|
||||
draw_polyline_segments(
|
||||
context,
|
||||
[(tuple(start_world), tuple(end_world))],
|
||||
color,
|
||||
self.LINE_ALPHA,
|
||||
self.LINE_WIDTH,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _compute_extend_preview_line(
|
||||
matrix_world: Matrix,
|
||||
cursor_world: Vector,
|
||||
current_length: float,
|
||||
) -> tuple[Vector, Vector] | None:
|
||||
"""Returns ``(current_end_world, target_end_world)`` or ``None`` when
|
||||
no extend would happen (degenerate segment, or cursor on the existing
|
||||
end). Target follows the cursor's raw local-Z projection unbounded —
|
||||
the line stays visible past the segment origin (negative local Z)
|
||||
because the user expects to see where they're pointing even when the
|
||||
operator would floor it."""
|
||||
if current_length <= 0:
|
||||
return None
|
||||
cursor_local = matrix_world.inverted() @ cursor_world
|
||||
if abs(cursor_local.z - current_length) < 1e-6:
|
||||
return None
|
||||
current_end_world = matrix_world @ Vector((0.0, 0.0, current_length))
|
||||
target_end_world = matrix_world @ Vector((0.0, 0.0, cursor_local.z))
|
||||
return current_end_world, target_end_world
|
||||
|
||||
|
||||
class BendPreviewDecorator(tool.Blender.ViewportDecorator):
|
||||
"""GPU preview lines for the bend-creation flow.
|
||||
|
||||
Polls on ``scene.BIMPreviewProperties.bend.is_active`` and renders the
|
||||
centerline + leg projections returned by ``mep.compute_bend_preview_polylines``.
|
||||
The two leg lines (segment → tangent point) show how each segment will
|
||||
be shortened; the arc polyline approximates the bend curve. On invalid
|
||||
geometry, draws the two rejected axes in warning colour instead so the
|
||||
user sees why the bend cannot be placed.
|
||||
|
||||
Installed once per Blender session from ``bim/handler.py:load_post``.
|
||||
Cheap to leave running because the first thing ``draw`` does is check
|
||||
``is_active`` and return when False.
|
||||
"""
|
||||
|
||||
LINE_WIDTH_LEG = 1.5
|
||||
LINE_WIDTH_ARC = 2.5
|
||||
LINE_ALPHA = 0.7
|
||||
|
||||
def draw(self, context: bpy.types.Context) -> None:
|
||||
scene = context.scene
|
||||
preview = getattr(scene, "BIMPreviewProperties", None)
|
||||
props = preview.bend if preview is not None else None
|
||||
if props is None or not props.is_active:
|
||||
return
|
||||
ifc_file = tool.Ifc.get()
|
||||
if ifc_file is None:
|
||||
return
|
||||
try:
|
||||
start_element = ifc_file.by_id(props.start_segment_id)
|
||||
end_element = ifc_file.by_id(props.end_segment_id)
|
||||
except Exception:
|
||||
return
|
||||
start_obj = tool.Ifc.get_object(start_element) if start_element else None
|
||||
end_obj = tool.Ifc.get_object(end_element) if end_element else None
|
||||
if start_obj is None or end_obj is None:
|
||||
return
|
||||
|
||||
# Late import: decorator.py loads at addon enable but mep.py imports
|
||||
# this module for the extend preview, so a module-level import would
|
||||
# cycle.
|
||||
from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines
|
||||
|
||||
preview = cached_compute_bend_preview_polylines(
|
||||
start_obj, end_obj, props.start_length, props.end_length, props.radius
|
||||
)
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
|
||||
if not preview["valid"]:
|
||||
warning_color = tuple(prefs.decorator_color_error[:3])
|
||||
axes = preview.get("invalid_axes") or []
|
||||
if axes:
|
||||
segments = [(tuple(a), tuple(b)) for a, b in axes]
|
||||
draw_polyline_segments(context, segments, warning_color, self.LINE_ALPHA, self.LINE_WIDTH_ARC)
|
||||
return
|
||||
|
||||
leg_color = tuple(prefs.decorations_colour[:3])
|
||||
arc_color = tuple(prefs.decorator_color_selected[:3])
|
||||
|
||||
leg_a_far, leg_a_end = preview["leg_a"]
|
||||
leg_b_far, leg_b_end = preview["leg_b"]
|
||||
draw_polyline_segments(
|
||||
context,
|
||||
[(tuple(leg_a_far), tuple(leg_a_end)), (tuple(leg_b_far), tuple(leg_b_end))],
|
||||
leg_color,
|
||||
self.LINE_ALPHA,
|
||||
self.LINE_WIDTH_LEG,
|
||||
)
|
||||
|
||||
arc = preview["arc"]
|
||||
if len(arc) >= 2:
|
||||
arc_segments = [(tuple(arc[i]), tuple(arc[i + 1])) for i in range(len(arc) - 1)]
|
||||
draw_polyline_segments(context, arc_segments, arc_color, self.LINE_ALPHA, self.LINE_WIDTH_ARC)
|
||||
|
||||
|
||||
class WallFilletPreviewDecorator(tool.Blender.ViewportDecorator):
|
||||
"""GPU preview lines for the wall-fillet flow.
|
||||
|
||||
Polls on ``scene.BIMPreviewProperties.wall_fillet.is_active`` and renders
|
||||
the leg projections + arc + radial construction lines returned by
|
||||
``tool.Wall.compute_wall_fillet_geometry``. The two leg lines show how
|
||||
each wall will be shortened to its tangent point; the arc approximates
|
||||
the rounded corner; the two construction lines (arc center to each
|
||||
tangent point) visually pin the radius.
|
||||
|
||||
Installed once per Blender session from ``bim/handler.py:load_post``
|
||||
and uninstalled in ``bim/module/model/__init__.py:unregister``."""
|
||||
|
||||
LINE_WIDTH_LEG = 1.5
|
||||
LINE_WIDTH_ARC = 2.5
|
||||
LINE_WIDTH_CONSTRUCTION = 1.0
|
||||
LINE_ALPHA = 0.7
|
||||
CONSTRUCTION_ALPHA = 0.4
|
||||
|
||||
def draw(self, context: bpy.types.Context) -> None:
|
||||
scene = context.scene
|
||||
preview_props = getattr(scene, "BIMPreviewProperties", None)
|
||||
props = preview_props.wall_fillet if preview_props is not None else None
|
||||
if props is None or not props.is_active:
|
||||
return
|
||||
ifc_file = tool.Ifc.get()
|
||||
if ifc_file is None:
|
||||
return
|
||||
try:
|
||||
wall_a = ifc_file.by_id(props.wall_a_id)
|
||||
wall_b = ifc_file.by_id(props.wall_b_id)
|
||||
except Exception:
|
||||
return
|
||||
wall_a_obj = tool.Ifc.get_object(wall_a) if wall_a else None
|
||||
wall_b_obj = tool.Ifc.get_object(wall_b) if wall_b else None
|
||||
if wall_a_obj is None or wall_b_obj is None:
|
||||
return
|
||||
|
||||
geom = tool.Wall.compute_wall_fillet_geometry(wall_a_obj, wall_b_obj, props.radius)
|
||||
if geom is None:
|
||||
return
|
||||
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
warning_color = tuple(prefs.decorator_color_error[:3])
|
||||
|
||||
if not geom["valid"]:
|
||||
# Degenerate geometry paints red: invalid_radius shows legs+arc
|
||||
# past the wall ends; invalid_axes shows the parallel/collinear
|
||||
# axes.
|
||||
if geom.get("invalid_radius"):
|
||||
tangent_a = geom.get("tangent_a")
|
||||
tangent_b = geom.get("tangent_b")
|
||||
ref_a = tool.Wall.get_world_reference_line(wall_a_obj)
|
||||
ref_b = tool.Wall.get_world_reference_line(wall_b_obj)
|
||||
if tangent_a is not None and tangent_b is not None and ref_a is not None and ref_b is not None:
|
||||
far_a = self._far_endpoint(ref_a, geom["intersection"])
|
||||
far_b = self._far_endpoint(ref_b, geom["intersection"])
|
||||
legs = [
|
||||
(tuple(far_a), tuple(tangent_a)),
|
||||
(tuple(far_b), tuple(tangent_b)),
|
||||
]
|
||||
draw_polyline_segments(context, legs, warning_color, self.LINE_ALPHA, self.LINE_WIDTH_LEG)
|
||||
arc = geom.get("arc") or []
|
||||
if len(arc) >= 2:
|
||||
arc_segments = [(tuple(arc[i]), tuple(arc[i + 1])) for i in range(len(arc) - 1)]
|
||||
draw_polyline_segments(context, arc_segments, warning_color, self.LINE_ALPHA, self.LINE_WIDTH_ARC)
|
||||
elif geom.get("invalid_axes"):
|
||||
axes = geom["invalid_axes"]
|
||||
segments = [(tuple(a), tuple(b)) for a, b in axes]
|
||||
draw_polyline_segments(context, segments, warning_color, self.LINE_ALPHA, self.LINE_WIDTH_ARC)
|
||||
return
|
||||
|
||||
leg_color = tuple(prefs.decorations_colour[:3])
|
||||
arc_color = tuple(prefs.decorator_color_selected[:3])
|
||||
|
||||
# Resolved against the IFC reference line, not mesh bounds, so trimmed
|
||||
# walls and openings don't shift the leg endpoints.
|
||||
ref_a = tool.Wall.get_world_reference_line(wall_a_obj)
|
||||
ref_b = tool.Wall.get_world_reference_line(wall_b_obj)
|
||||
if ref_a is not None and ref_b is not None and geom["intersection"] is not None:
|
||||
far_a = self._far_endpoint(ref_a, geom["intersection"])
|
||||
far_b = self._far_endpoint(ref_b, geom["intersection"])
|
||||
legs = [
|
||||
(tuple(far_a), tuple(geom["tangent_a"])),
|
||||
(tuple(far_b), tuple(geom["tangent_b"])),
|
||||
]
|
||||
draw_polyline_segments(context, legs, leg_color, self.LINE_ALPHA, self.LINE_WIDTH_LEG)
|
||||
|
||||
arc = geom["arc"]
|
||||
if len(arc) >= 2:
|
||||
arc_segments = [(tuple(arc[i]), tuple(arc[i + 1])) for i in range(len(arc) - 1)]
|
||||
draw_polyline_segments(context, arc_segments, arc_color, self.LINE_ALPHA, self.LINE_WIDTH_ARC)
|
||||
|
||||
# Dim construction lines from arc_center to each tangent point so
|
||||
# the radius reads as concrete during drag.
|
||||
arc_center = geom.get("arc_center")
|
||||
if arc_center is not None:
|
||||
construction = [
|
||||
(tuple(arc_center), tuple(geom["tangent_a"])),
|
||||
(tuple(arc_center), tuple(geom["tangent_b"])),
|
||||
]
|
||||
draw_polyline_segments(
|
||||
context, construction, arc_color, self.CONSTRUCTION_ALPHA, self.LINE_WIDTH_CONSTRUCTION
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _far_endpoint(reference_line, intersection):
|
||||
"""Endpoint of ``reference_line`` furthest from ``intersection``."""
|
||||
p1, p2 = reference_line
|
||||
d1 = (p1.x - intersection[0]) ** 2 + (p1.y - intersection[1]) ** 2 + (p1.z - intersection[2]) ** 2
|
||||
d2 = (p2.x - intersection[0]) ** 2 + (p2.y - intersection[1]) ** 2 + (p2.z - intersection[2]) ** 2
|
||||
return p2 if d2 >= d1 else p1
|
||||
|
||||
|
||||
class _DoorSwingArc(NamedTuple):
|
||||
"""Parameters for one swing-arc draw call in door-local space."""
|
||||
|
||||
hinge_x: float
|
||||
hinge_y: float
|
||||
panel_width: float
|
||||
x_mirror: bool
|
||||
|
||||
|
||||
def _visible_arcs(door_type: str, overall_width: float, lining_offset: float) -> list[_DoorSwingArc]:
|
||||
"""Arc specs for the parametric door swing visualisation, agnostic of
|
||||
edit-mode state so the readonly preview and the editor view stay aligned.
|
||||
|
||||
Empty only for sliding-door types; unknown ``door_type`` values fall
|
||||
through to a single left-hinged arc."""
|
||||
if "SLIDING" in door_type:
|
||||
return []
|
||||
is_double = "DOUBLE_DOOR" in door_type
|
||||
is_right_single = door_type.endswith("RIGHT") and not is_double
|
||||
arcs = [
|
||||
_DoorSwingArc(
|
||||
hinge_x=overall_width if is_right_single else 0.0,
|
||||
hinge_y=lining_offset,
|
||||
panel_width=overall_width / 2 if is_double else overall_width,
|
||||
x_mirror=is_right_single,
|
||||
)
|
||||
]
|
||||
if is_double:
|
||||
arcs.append(
|
||||
_DoorSwingArc(
|
||||
hinge_x=overall_width,
|
||||
hinge_y=lining_offset,
|
||||
panel_width=overall_width / 2,
|
||||
x_mirror=True,
|
||||
)
|
||||
)
|
||||
return arcs
|
||||
|
||||
|
||||
# Unit quarter-arc samples shared with the edit-mode swing gizmo so the
|
||||
# readonly arc traces the same curve. Re-scaled per draw via the per-arc
|
||||
# transform.
|
||||
_DOOR_SWING_ARC_ANGLE_MIN_RAD = math.radians(DOOR_SWING_ANGLE_MIN)
|
||||
_DOOR_SWING_ARC_ANGLE_RANGE_RAD = math.radians(DOOR_SWING_ANGLE_MAX) - _DOOR_SWING_ARC_ANGLE_MIN_RAD
|
||||
_DOOR_SWING_ARC_UNIT_POINTS: tuple[Vector, ...] = tuple(
|
||||
Vector(
|
||||
(
|
||||
math.cos(_DOOR_SWING_ARC_ANGLE_MIN_RAD + _DOOR_SWING_ARC_ANGLE_RANGE_RAD * (_i / ARC_SEGMENTS)),
|
||||
math.sin(_DOOR_SWING_ARC_ANGLE_MIN_RAD + _DOOR_SWING_ARC_ANGLE_RANGE_RAD * (_i / ARC_SEGMENTS)),
|
||||
0.0,
|
||||
)
|
||||
)
|
||||
for _i in range(ARC_SEGMENTS + 1)
|
||||
)
|
||||
|
||||
|
||||
class DoorSwingReadonlyDecorator(tool.Blender.ViewportDecorator):
|
||||
"""Always-on swing-arc preview for the active Bonsai-parametric IfcDoor
|
||||
when it is not currently in parametric edit mode. Matches the visual
|
||||
contract of the parametric door's swing-arc gizmos so the hinge side
|
||||
and opening direction can be read without entering edit mode.
|
||||
|
||||
Silent-skip cases (no draw, no error):
|
||||
|
||||
- active object missing / not selected / not an IfcDoor;
|
||||
- door is mid-edit (the swing gizmo is already painting the arc);
|
||||
- door has no ``BBIM_Door`` pset (legacy import, never edited in Bonsai)."""
|
||||
|
||||
LINE_WIDTH = 1.5
|
||||
LINE_ALPHA = 0.8
|
||||
|
||||
def draw(self, context: bpy.types.Context) -> None:
|
||||
obj = context.active_object
|
||||
if obj is None or not obj.select_get():
|
||||
return
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if element is None or not element.is_a("IfcDoor"):
|
||||
return
|
||||
props = getattr(obj, "BIMDoorProperties", None)
|
||||
if props is not None and props.is_editing:
|
||||
return
|
||||
pset = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Door")
|
||||
if not pset:
|
||||
return
|
||||
data = pset.get("data_dict")
|
||||
if not data:
|
||||
return
|
||||
door_type = data.get("door_type", "")
|
||||
overall_width_project = data.get("overall_width", 0.0)
|
||||
lining_offset_project = (data.get("lining_properties") or {}).get("lining_offset", 0.0)
|
||||
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
overall_width = overall_width_project * si_conversion
|
||||
lining_offset = lining_offset_project * si_conversion
|
||||
specs = _visible_arcs(door_type, overall_width, lining_offset)
|
||||
if not specs:
|
||||
return
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
main_color = tuple(prefs.decorator_color_special[:3])
|
||||
mw = obj.matrix_world
|
||||
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = []
|
||||
for spec in specs:
|
||||
x_flip = Matrix.Scale(-1, 4, (1, 0, 0)) if spec.x_mirror else Matrix.Identity(4)
|
||||
transform = (
|
||||
Matrix.Translation(Vector((spec.hinge_x, spec.hinge_y, 0.0)))
|
||||
@ Matrix.Scale(spec.panel_width, 4)
|
||||
@ x_flip
|
||||
)
|
||||
world_main = mw @ transform
|
||||
pts = [world_main @ p for p in _DOOR_SWING_ARC_UNIT_POINTS]
|
||||
for i in range(len(pts) - 1):
|
||||
segments.append((tuple(pts[i]), tuple(pts[i + 1])))
|
||||
draw_polyline_segments(context, segments, main_color, self.LINE_ALPHA, self.LINE_WIDTH)
|
||||
|
||||
|
||||
_BBOX_EDGES = (
|
||||
(0, 1), (1, 2), (2, 3), (3, 0),
|
||||
(4, 5), (5, 6), (6, 7), (7, 4),
|
||||
(0, 4), (1, 5), (2, 6), (3, 7),
|
||||
) # fmt: skip
|
||||
|
||||
|
||||
def bbox_world_edges(
|
||||
obj: bpy.types.Object,
|
||||
) -> list[tuple[tuple[float, float, float], tuple[float, float, float]]]:
|
||||
"""Return world-space (start, end) tuples for the 12 edges of ``obj``'s
|
||||
bounding box. Empty list if the object has no bound_box (e.g. Empties)."""
|
||||
if not obj.bound_box:
|
||||
return []
|
||||
mw = obj.matrix_world
|
||||
corners = [mw @ Vector(c) for c in obj.bound_box]
|
||||
return [(tuple(corners[a]), tuple(corners[b])) for a, b in _BBOX_EDGES]
|
||||
|
||||
|
||||
def draw_polyline_segments(
|
||||
context: bpy.types.Context,
|
||||
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]],
|
||||
color_rgb: tuple[float, float, float],
|
||||
alpha: float,
|
||||
line_width: float,
|
||||
) -> None:
|
||||
"""Render ``segments`` as one anti-aliased LINES batch in world space."""
|
||||
if not segments:
|
||||
return
|
||||
verts: list[tuple[float, float, float]] = []
|
||||
indices: list[tuple[int, int]] = []
|
||||
for start, end in segments:
|
||||
base = len(verts)
|
||||
verts.append(start)
|
||||
verts.append(end)
|
||||
indices.append((base, base + 1))
|
||||
if not tool.Blender.validate_shader_batch_data(verts, indices):
|
||||
return
|
||||
region = getattr(context, "region", None)
|
||||
if region is None:
|
||||
return
|
||||
shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR")
|
||||
shader.bind()
|
||||
shader.uniform_float("viewportSize", (region.width, region.height))
|
||||
shader.uniform_float("lineWidth", line_width)
|
||||
shader.uniform_float("color", (*color_rgb, alpha))
|
||||
batch = batch_for_shader(shader, "LINES", {"pos": verts}, indices=indices)
|
||||
gpu.state.blend_set("ALPHA")
|
||||
batch.draw(shader)
|
||||
gpu.state.blend_set("NONE")
|
||||
|
||||
|
||||
_BBOX_HIGHLIGHT_LINE_WIDTH = 1.8
|
||||
_BBOX_HIGHLIGHT_LINE_ALPHA = 0.8
|
||||
|
||||
@@ -37,9 +37,8 @@ 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.model.wall_offset_gizmos import WALL_OFFSET_GIZMO_CONFIGS
|
||||
from bonsai.bim.module.model.window import create_bm_box, create_bm_window
|
||||
from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin, PickTypeMixin
|
||||
from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bonsai.bim.module.model.prop import BIMDoorProperties
|
||||
@@ -581,7 +580,7 @@ class _DoorEditMixin(FeatureModifierEditMixin):
|
||||
|
||||
@classmethod
|
||||
def _is_element_type(cls, element):
|
||||
return tool.Parametric.is_door(element)
|
||||
return tool.Blender.Modifier.is_door(element)
|
||||
|
||||
@classmethod
|
||||
def _get_props(cls, obj: bpy.types.Object):
|
||||
@@ -630,7 +629,7 @@ class RemoveDoor(bpy.types.Operator, tool.Ifc.Operator):
|
||||
def remove_door_on_object(self, obj: bpy.types.Object) -> None:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
assert element
|
||||
if not tool.Parametric.is_door(element):
|
||||
if not tool.Blender.Modifier.is_door(element):
|
||||
return
|
||||
props = tool.Model.get_door_props(obj)
|
||||
props.is_editing = False
|
||||
@@ -645,8 +644,12 @@ class RemoveDoor(bpy.types.Operator, tool.Ifc.Operator):
|
||||
|
||||
|
||||
class ToggleDoorSwing(bpy.types.Operator, tool.Ifc.Operator):
|
||||
"""Toggle door swing direction and optionally flip door geometry.
|
||||
|
||||
Shift+Click (when flip_geometry=True): Flip geometry only without changing door direction"""
|
||||
|
||||
bl_idname = "bim.toggle_door_swing"
|
||||
bl_label = "Change Door Swing"
|
||||
bl_label = "Toggle Door Swing"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
flip_geometry: bpy.props.BoolProperty(name="Flip Geometry", default=False)
|
||||
@@ -657,15 +660,6 @@ class ToggleDoorSwing(bpy.types.Operator, tool.Ifc.Operator):
|
||||
name="Skip Direction Change", default=False, options={"HIDDEN", "SKIP_SAVE"}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def description(cls, context: bpy.types.Context, properties: bpy.types.OperatorProperties) -> str:
|
||||
if properties.flip_geometry:
|
||||
return (
|
||||
"Swing the door from the opposite side of the wall. "
|
||||
"Shift+click: mirror the door without changing which side it opens to"
|
||||
)
|
||||
return "Move the door hinge to the opposite side"
|
||||
|
||||
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]:
|
||||
self.skip_direction_change = event.shift
|
||||
return self.execute(context)
|
||||
@@ -692,7 +686,7 @@ class ToggleDoorSwing(bpy.types.Operator, tool.Ifc.Operator):
|
||||
if not element:
|
||||
return {"CANCELLED"}
|
||||
|
||||
is_door = tool.Parametric.is_door(element)
|
||||
is_door = tool.Blender.Modifier.is_door(element)
|
||||
|
||||
if self.flip_geometry:
|
||||
tool.Geometry.flip_object(obj, self.flip_local_axes)
|
||||
@@ -706,20 +700,20 @@ class ToggleDoorSwing(bpy.types.Operator, tool.Ifc.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class PickDoorType(bpy.types.Operator, tool.Ifc.Operator, PickTypeMixin):
|
||||
"""Pick a door type from a popup menu."""
|
||||
class CycleDoorType(bpy.types.Operator, tool.Ifc.Operator, gizmo.CycleTypeMixin):
|
||||
"""Cycle through available door types. Shift+click to cycle in reverse."""
|
||||
|
||||
bl_idname = "bim.pick_door_type"
|
||||
bl_label = "Pick Door Type"
|
||||
bl_idname = "bim.cycle_door_type"
|
||||
bl_label = "Cycle Door Type"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
element_checker = tool.Parametric.is_door
|
||||
props_getter = tool.Model.get_door_props
|
||||
element_checker = "is_door"
|
||||
props_getter = "get_door_props"
|
||||
type_literal = tool.Model.DoorType
|
||||
type_attr = "door_type"
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
return self._pick_type(context)
|
||||
return self._cycle_type(context)
|
||||
|
||||
|
||||
class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
@@ -732,7 +726,7 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
enable_editing_operator = "bim.enable_editing_door"
|
||||
finish_editing_operator = "bim.finish_editing_door"
|
||||
cancel_editing_operator = "bim.cancel_editing_door"
|
||||
pick_type_operator = "bim.pick_door_type"
|
||||
cycle_type_operator = "bim.cycle_door_type"
|
||||
|
||||
# Declarative dimension gizmo configuration with visibility and position
|
||||
# matrix_position lambdas replace the get_dimension_matrix_* methods
|
||||
@@ -839,44 +833,14 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
p.get_transom_window_center_z(),
|
||||
),
|
||||
),
|
||||
*WALL_OFFSET_GIZMO_CONFIGS,
|
||||
]
|
||||
|
||||
# Big quarter-arc hit shapes cover much of the door face — without a
|
||||
# negative select_bias they would steal clicks from the small dimension
|
||||
# and edit gizmos drawn on top of them.
|
||||
SWING_ARC_SELECT_BIAS = -1000.0
|
||||
swing_arc_operator = "bim.toggle_door_swing"
|
||||
|
||||
swing_arc_props = [
|
||||
gizmo.SwingArcConfig(
|
||||
name="primary",
|
||||
visibility_condition=lambda p: p.is_editing and "SLIDING" not in p.door_type,
|
||||
hinge_x=lambda p: (
|
||||
p.overall_width if p.door_type.endswith("RIGHT") and "DOUBLE_DOOR" not in p.door_type else 0.0
|
||||
),
|
||||
hinge_y=lambda p: p.lining_offset,
|
||||
panel_width=lambda p: p.overall_width / 2 if "DOUBLE_DOOR" in p.door_type else p.overall_width,
|
||||
x_mirror=lambda p: p.door_type.endswith("RIGHT") and "DOUBLE_DOOR" not in p.door_type,
|
||||
),
|
||||
gizmo.SwingArcConfig(
|
||||
name="secondary",
|
||||
visibility_condition=lambda p: p.is_editing
|
||||
and "DOUBLE_DOOR" in p.door_type
|
||||
and "SLIDING" not in p.door_type,
|
||||
hinge_x=lambda p: p.overall_width,
|
||||
hinge_y=lambda p: p.lining_offset,
|
||||
panel_width=lambda p: p.overall_width / 2,
|
||||
x_mirror=lambda _p: True,
|
||||
),
|
||||
]
|
||||
|
||||
props_getter = tool.Model.get_door_props
|
||||
props_getter = "get_door_props"
|
||||
gizmo_pref_name = "door"
|
||||
|
||||
@classmethod
|
||||
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
return tool.Parametric.is_door(element)
|
||||
return tool.Blender.Modifier.is_door(element)
|
||||
|
||||
def get_icon_y_extent(self, props: "BIMDoorProperties") -> tuple[float, float]:
|
||||
"""Get Y extents for door icon positioning.
|
||||
@@ -894,20 +858,24 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
return (furthest_y, furthest_y)
|
||||
|
||||
def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None:
|
||||
"""Create one (main, flip) swing-arc pair per ``swing_arc_props`` entry.
|
||||
|
||||
Stored as ``self.gizmo_swing_arc_<name>`` and ``self.gizmo_swing_arc_<name>_flip``
|
||||
and pinned to ``SWING_ARC_SELECT_BIAS`` so other door gizmos win selection."""
|
||||
"""Create door-specific swing arc gizmos."""
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
main_color = prefs.decorator_color_special[:3]
|
||||
flip_color = prefs.decorator_color_background[:3]
|
||||
for cfg in self.swing_arc_props:
|
||||
main = self.create_arc_gizmo(main_color, self.swing_arc_operator, flip_geometry=False)
|
||||
flip = self.create_arc_gizmo(flip_color, self.swing_arc_operator, flip_geometry=True)
|
||||
for gz in (main, flip):
|
||||
gz.select_bias = self.SWING_ARC_SELECT_BIAS
|
||||
setattr(self, f"gizmo_swing_arc_{cfg.name}", main)
|
||||
setattr(self, f"gizmo_swing_arc_{cfg.name}_flip", flip)
|
||||
inactive_color = prefs.decorator_color_background[:3]
|
||||
special_color = prefs.decorator_color_special[:3]
|
||||
|
||||
self.gizmo_door_type = self.create_arc_gizmo(
|
||||
special_color,
|
||||
"bim.toggle_door_swing",
|
||||
prop_path="BIMDoorProperties.door_type",
|
||||
flip_geometry=False,
|
||||
)
|
||||
self.gizmo_flip_arc = self.create_arc_gizmo(
|
||||
inactive_color,
|
||||
"bim.toggle_door_swing",
|
||||
prop_path="BIMDoorProperties.door_type",
|
||||
flip_geometry=True,
|
||||
flip_local_axes="XY",
|
||||
)
|
||||
|
||||
def _refresh_element_specific(
|
||||
self, context: bpy.types.Context, mw: Matrix, props: "BIMDoorProperties" # noqa: ARG002
|
||||
@@ -926,23 +894,29 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
self._update_view_dependent_dimensions(context, mw, props)
|
||||
|
||||
def update_swing_gizmos(self, mw: Matrix, props: "BIMDoorProperties") -> None:
|
||||
"""Position each declared swing-arc pair per its config + props state."""
|
||||
mirror_y = Matrix.Scale(-1, 4, (0, 1, 0))
|
||||
for cfg in self.swing_arc_props:
|
||||
main = getattr(self, f"gizmo_swing_arc_{cfg.name}")
|
||||
flip = getattr(self, f"gizmo_swing_arc_{cfg.name}_flip")
|
||||
show = cfg.visibility_condition(props)
|
||||
main_visible = self.update_gizmo_visibility(main, show)
|
||||
flip_visible = self.update_gizmo_visibility(flip, show)
|
||||
if not (main_visible or flip_visible):
|
||||
continue
|
||||
x_flip = Matrix.Scale(-1, 4, (1, 0, 0)) if cfg.x_mirror(props) else Matrix.Identity(4)
|
||||
transform = (
|
||||
Matrix.Translation(V_(cfg.hinge_x(props), cfg.hinge_y(props), 0))
|
||||
@ Matrix.Scale(cfg.panel_width(props), 4)
|
||||
@ x_flip
|
||||
)
|
||||
if main_visible:
|
||||
main.matrix_basis = mw @ transform
|
||||
if flip_visible:
|
||||
flip.matrix_basis = mw @ transform @ mirror_y
|
||||
"""Update swing gizmo position and color based on editing state."""
|
||||
prefs = self.get_addon_prefs()
|
||||
door_gizmo_prefs = prefs.gizmos.door
|
||||
|
||||
door_type_visible = self.update_gizmo_visibility(
|
||||
self.gizmo_door_type, props.is_editing, door_gizmo_prefs.swing_arc
|
||||
)
|
||||
flip_arc_visible = self.update_gizmo_visibility(
|
||||
self.gizmo_flip_arc, props.is_editing, door_gizmo_prefs.flip_arc
|
||||
)
|
||||
|
||||
if not door_type_visible and not flip_arc_visible:
|
||||
return
|
||||
|
||||
swing_x_offset = props.overall_width if "RIGHT" in props.door_type else 0.0
|
||||
base_swing_transform = Matrix.Translation(V_(swing_x_offset, props.lining_offset, 0)) @ Matrix.Scale(
|
||||
props.overall_width, 4
|
||||
)
|
||||
|
||||
if door_type_visible:
|
||||
self.gizmo_door_type.matrix_basis = mw @ base_swing_transform
|
||||
self.gizmo_door_type.color = prefs.decorations_colour[:3]
|
||||
|
||||
if flip_arc_visible:
|
||||
mirror_y = Matrix.Scale(-1, 4, (0, 1, 0))
|
||||
self.gizmo_flip_arc.matrix_basis = mw @ base_swing_transform @ mirror_y
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Generic single-click "Add Opening" gizmo for hosts (walls, slabs, roofs).
|
||||
|
||||
One GizmoGroup serves every IFC host type that exposes ``HasOpenings``:
|
||||
parametric LAYER2 walls, any ``IfcSlab``, and any ``IfcRoof``. The poll
|
||||
guards host-host pairings so this gizmo never overlaps with the existing
|
||||
wall-join / extend-vertically gizmos. The positioner dispatches on element
|
||||
type — walls use axis-projection + camera-facing-Y math (which requires the
|
||||
parametric layer-set); slabs and roofs use a world-Z face bias driven by
|
||||
the void object's elevation against the host's bounding box."""
|
||||
|
||||
import bpy
|
||||
from mathutils import Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.drawing import gizmos as gizmo
|
||||
from bonsai.bim.module.model.wall import (
|
||||
_get_wall_geom_cached,
|
||||
_wall_camera_facing_icon_y,
|
||||
_wall_gizmo_poll_gate,
|
||||
_WallGeomCachedBillboardingMixin,
|
||||
)
|
||||
|
||||
|
||||
def is_supported_host(element) -> bool:
|
||||
"""Total predicate (None → False). Walls accept either a parametric
|
||||
LAYER2 wall OR a fillet-corner wall (both expose a usable axis +
|
||||
layer-set for the anchor math); slabs and roofs only need the bound
|
||||
box so any IfcSlab / IfcRoof qualifies regardless of parametric
|
||||
modifier state."""
|
||||
if element is None:
|
||||
return False
|
||||
return tool.Parametric.is_path_connectable_wall(element) or element.is_a("IfcSlab") or element.is_a("IfcRoof")
|
||||
|
||||
|
||||
def _resolve_active_host(context: bpy.types.Context, n_selected: int):
|
||||
"""Shared poll prologue: gizmo gate + selection cardinality + active-in-
|
||||
selected + IFC entity lookup + supported-host predicate. Returns the
|
||||
active element on success, ``None`` on any failure — callers chain their
|
||||
feature-specific checks past the early-return."""
|
||||
if not _wall_gizmo_poll_gate(context):
|
||||
return None
|
||||
selected = tool.Blender.get_selected_objects()
|
||||
if len(selected) != n_selected:
|
||||
return None
|
||||
active = context.active_object
|
||||
if active is None or active not in selected:
|
||||
return None
|
||||
element = tool.Ifc.get_entity(active)
|
||||
if not element or not is_supported_host(element):
|
||||
return None
|
||||
return element
|
||||
|
||||
|
||||
class GizmoHostAddOpening(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin):
|
||||
"""Activates when a host element (wall / slab / roof) is the active object
|
||||
and exactly one other selected object is *not* itself a host.
|
||||
|
||||
Renders a single ``VIEW3D_GT_add_opening`` icon at the void object's
|
||||
projected location on the host. A click dispatches ``bim.add_opening``,
|
||||
which handles any element exposing the ``HasOpenings`` inverse.
|
||||
|
||||
Per-frame positioning keeps the icon facing the camera as the viewport
|
||||
orbits."""
|
||||
|
||||
bl_idname = "OBJECT_GGT_bim_host_add_opening"
|
||||
bl_label = "Host Add Opening Gizmo"
|
||||
bl_space_type = "VIEW_3D"
|
||||
bl_region_type = "WINDOW"
|
||||
bl_options = {"3D", "PERSISTENT"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: bpy.types.Context) -> bool:
|
||||
element = _resolve_active_host(context, n_selected=2)
|
||||
if element is None:
|
||||
return False
|
||||
# The operator itself filters on HasOpenings, but checking here keeps
|
||||
# the icon from appearing on host classes that can't accept openings
|
||||
# in the active IFC schema.
|
||||
if not hasattr(element, "HasOpenings"):
|
||||
return False
|
||||
active = context.active_object
|
||||
other = next(o for o in tool.Blender.get_selected_objects() if o is not active)
|
||||
# Host + host pairings are claimed by host-specific gizmos (wall-join,
|
||||
# extend-vertical, …) — suppress here so the add-opening icon never
|
||||
# stacks on top of them.
|
||||
if is_supported_host(tool.Ifc.get_entity(other)):
|
||||
return False
|
||||
return True
|
||||
|
||||
def setup(self, context: bpy.types.Context) -> None:
|
||||
default_color, highlight_color = self.get_decoration_colors()
|
||||
self.add_opening_icon = self.setup_icon_gizmo(
|
||||
"VIEW3D_GT_add_opening", default_color, highlight_color, "bim.add_opening"
|
||||
)
|
||||
|
||||
def position_gizmos(self, context: bpy.types.Context) -> None:
|
||||
host_obj = context.active_object
|
||||
if not host_obj:
|
||||
return
|
||||
selected = tool.Blender.get_selected_objects()
|
||||
other = next((o for o in selected if o is not host_obj), None)
|
||||
if not other:
|
||||
return
|
||||
element = tool.Ifc.get_entity(host_obj)
|
||||
if not element:
|
||||
return
|
||||
|
||||
if tool.Parametric.is_path_connectable_wall(element):
|
||||
world_pos = wall_anchor(context, self, host_obj, other)
|
||||
else:
|
||||
world_pos = layer3_anchor(host_obj, other)
|
||||
if world_pos is None:
|
||||
return
|
||||
self.add_opening_icon.matrix_basis = gizmo.billboarded_at(world_pos, gizmo.get_billboard_rotation(context))
|
||||
|
||||
|
||||
def wall_anchor(
|
||||
context: bpy.types.Context, group: bpy.types.GizmoGroup, wall_obj: bpy.types.Object, other: bpy.types.Object
|
||||
) -> Vector | None:
|
||||
"""World-space anchor for the add-opening icon on a wall host: void origin
|
||||
projected onto the wall reference-line X (clamped to wall extents), lifted to
|
||||
the camera-facing wall-local Y."""
|
||||
geom = _get_wall_geom_cached(group, wall_obj)
|
||||
if not geom:
|
||||
return None
|
||||
mw = wall_obj.matrix_world
|
||||
wall_local = mw.inverted() @ other.matrix_world.translation
|
||||
local_x = max(geom["anchor_x"], min(wall_local.x, geom["anchor_x"] + geom["length"]))
|
||||
icon_y = _wall_camera_facing_icon_y(context, mw, geom)
|
||||
base_world = mw @ Vector((local_x, icon_y, 0.0))
|
||||
top_world = mw @ Vector((local_x, icon_y, geom["height"] + gizmo.BaseParametricGizmoGroup.ICON_Z_OFFSET))
|
||||
return gizmo.BaseParametricGizmoGroup.pick_visible_anchor(context, base_world, top_world)
|
||||
|
||||
|
||||
def layer3_anchor(host_obj: bpy.types.Object, other: bpy.types.Object) -> Vector:
|
||||
"""World-space anchor for the add-opening icon on a LAYER3 host (slab / roof):
|
||||
void's world XY, lifted just above the host's top face. Predictable height
|
||||
regardless of where the void sits vertically — clicking the icon places the
|
||||
opening at the void's XY, and the operator handles the actual cut depth."""
|
||||
bbox = tool.Blender.get_object_world_bounding_box(host_obj)
|
||||
anchor_xy = other.matrix_world.translation.xy
|
||||
top_z = bbox["max_z"] + gizmo.BaseParametricGizmoGroup.ICON_Z_OFFSET
|
||||
return Vector((anchor_xy.x, anchor_xy.y, top_z))
|
||||
|
||||
|
||||
def host_toggle_anchor(host_obj: bpy.types.Object) -> Vector:
|
||||
"""Object origin XY, lifted just above the topmost mesh vertex. Tracks
|
||||
the parametric origin (useful reference even when the mesh extends
|
||||
asymmetrically) and the visible top face (stays clear of sloped or
|
||||
stepped bodies)."""
|
||||
origin = host_obj.matrix_world.translation
|
||||
top_z = tool.Blender.get_object_world_bounding_box(host_obj)["max_z"] + gizmo.BaseParametricGizmoGroup.ICON_Z_OFFSET
|
||||
return Vector((origin.x, origin.y, top_z))
|
||||
|
||||
|
||||
class GizmoHostToggleOpenings(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin):
|
||||
"""Fallback toggle-openings icon for hosts that lack their own
|
||||
parametric-edit toolbar — slabs today, plus any foreign-authored
|
||||
IfcRoof that carries no BBIM_Roof pset (so ``GizmoRoofEdition`` doesn't
|
||||
poll for it). Walls and parametric roofs already render an idle-row
|
||||
toggle next to the pen and are excluded from this poll.
|
||||
|
||||
When slab parametric-edit lands the slab branch will pen-row-handle
|
||||
its own toggle; updating the exclusion predicate here is the only
|
||||
migration step needed."""
|
||||
|
||||
bl_idname = "OBJECT_GGT_bim_host_toggle_openings"
|
||||
bl_label = "Host Toggle Openings Gizmo"
|
||||
bl_space_type = "VIEW_3D"
|
||||
bl_region_type = "WINDOW"
|
||||
bl_options = {"3D", "PERSISTENT"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: bpy.types.Context) -> bool:
|
||||
element = _resolve_active_host(context, n_selected=1)
|
||||
if element is None:
|
||||
return False
|
||||
if not tool.Geometry.has_openings(element):
|
||||
return False
|
||||
# Skip when a per-feature parametric-edit gizmo already surfaces
|
||||
# an idle-row toggle for this element — walls and parametric roofs
|
||||
# both render their own toggle in the pen row.
|
||||
if tool.Parametric.is_path_connectable_wall(element):
|
||||
return False
|
||||
if tool.Parametric.is_roof(element):
|
||||
return False
|
||||
return True
|
||||
|
||||
def setup(self, context: bpy.types.Context) -> None:
|
||||
default_color, highlight_color = self.get_decoration_colors()
|
||||
self.toggle_openings_icon = self.setup_icon_gizmo(
|
||||
"VIEW3D_GT_add_opening", default_color, highlight_color, "bim.toggle_host_openings"
|
||||
)
|
||||
|
||||
def position_gizmos(self, context: bpy.types.Context) -> None:
|
||||
host_obj = context.active_object
|
||||
if not host_obj:
|
||||
return
|
||||
self.toggle_openings_icon.matrix_basis = gizmo.billboarded_at(
|
||||
host_toggle_anchor(host_obj), gizmo.get_billboard_rotation(context)
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,444 +0,0 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Bend-preview lifecycle for MEP segment joins.
|
||||
|
||||
Holds the four lifecycle operators (Enable / Finish / Cancel /
|
||||
EnableFromBend) and the ``GizmoBendPreview`` group that surfaces the
|
||||
tunable dimensions and validate/cancel icons during preview. Draft state
|
||||
lives at ``Scene.BIMPreviewProperties.bend`` per CLAUDE.md §2.9 (Scene
|
||||
for cross-element previews).
|
||||
|
||||
The geometry math (``compute_bend_preview_polylines``,
|
||||
``_bend_profile_cross_section``, ``_sweep_profile_along_polyline``)
|
||||
stays in ``mep.py`` because the commit operator ``MEPAddBend`` reuses
|
||||
it; this module imports the polyline helper for per-frame gizmo
|
||||
positioning. The GPU lines themselves are drawn by
|
||||
``decorator.BendPreviewDecorator``, kept in ``decorator.py`` with its
|
||||
sibling decorators."""
|
||||
|
||||
from typing import ClassVar
|
||||
|
||||
import bpy
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.util.unit
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.drawing import gizmos as gizmo
|
||||
from bonsai.bim.module.model import preview_base
|
||||
from bonsai.bim.module.model.mep import (
|
||||
_is_bend_fitting,
|
||||
_n_mep_selected,
|
||||
cached_compute_bend_preview_polylines,
|
||||
segments_are_parallel,
|
||||
validate_bend_preconditions,
|
||||
)
|
||||
|
||||
|
||||
class EnableBendPreview(bpy.types.Operator):
|
||||
"""Enter bend-preview mode for two selected MEP segments. Populates
|
||||
scene.BIMPreviewProperties.bend with segment IFC ids and default
|
||||
start_length / end_length / radius; no IFC mutation until finish."""
|
||||
|
||||
bl_idname = "bim.enable_bend_preview"
|
||||
bl_label = "Enter Bend Preview"
|
||||
bl_description = "Begin tuning bend parameters before committing the bend"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
if not _n_mep_selected(2):
|
||||
cls.poll_message_set("Select exactly 2 MEP segments to bend.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def execute(self, context):
|
||||
selected = tool.Blender.get_selected_objects()
|
||||
active = context.active_object
|
||||
if active is None or active not in selected:
|
||||
self.report({"ERROR"}, "Active object must be one of the selected MEP segments.")
|
||||
return {"CANCELLED"}
|
||||
other = next((o for o in selected if o is not active), None)
|
||||
if other is None:
|
||||
self.report({"ERROR"}, "Two MEP segments must be selected.")
|
||||
return {"CANCELLED"}
|
||||
active_element = tool.Ifc.get_entity(active)
|
||||
other_element = tool.Ifc.get_entity(other)
|
||||
if active_element is None or other_element is None:
|
||||
self.report({"ERROR"}, "Both selected objects must be IFC elements.")
|
||||
return {"CANCELLED"}
|
||||
if segments_are_parallel(active, other):
|
||||
self.report({"ERROR"}, "Bend preview is for non-parallel segments only.")
|
||||
return {"CANCELLED"}
|
||||
|
||||
# Pre-check the same preconditions MEPAddBend enforces so the user
|
||||
# sees the rejection here rather than after tuning a doomed preview.
|
||||
precondition_error = validate_bend_preconditions(active_element, other_element)
|
||||
if precondition_error is not None:
|
||||
self.report({"ERROR"}, precondition_error)
|
||||
return {"CANCELLED"}
|
||||
|
||||
preview_base.sync_uncommitted_moves([active, other])
|
||||
|
||||
props = preview_base.get_preview_props(context, "bend")
|
||||
# Auto-cancel any prior preview so re-clicking join on a different
|
||||
# pair doesn't silently commit the previous tuning.
|
||||
if props is not None and props.is_active:
|
||||
bpy.ops.bim.cancel_bend_preview()
|
||||
|
||||
props.start_segment_id = active_element.id()
|
||||
props.end_segment_id = other_element.id()
|
||||
props.start_length = 0.1
|
||||
props.end_length = 0.1
|
||||
props.radius = 0.2
|
||||
props.is_active = True
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class FinishBendPreview(bpy.types.Operator):
|
||||
"""Commit the previewed bend with the tuned parameters and exit preview.
|
||||
|
||||
Preview state survives a failed commit so the user can re-tune without
|
||||
re-selecting."""
|
||||
|
||||
bl_idname = "bim.finish_bend_preview"
|
||||
bl_label = "Apply Bend"
|
||||
bl_description = "Commit the bend with the previewed parameters"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
return preview_base.commit_preview(
|
||||
self,
|
||||
context,
|
||||
"bend",
|
||||
"mep_add_bend",
|
||||
("start_segment_id", "end_segment_id", "start_length", "end_length", "radius", "editing_bend_id"),
|
||||
)
|
||||
|
||||
|
||||
class CancelBendPreview(bpy.types.Operator):
|
||||
"""Exit bend preview without committing."""
|
||||
|
||||
bl_idname = "bim.cancel_bend_preview"
|
||||
bl_label = "Cancel Bend"
|
||||
bl_description = "Discard the previewed bend"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
if context.screen is None:
|
||||
return {"CANCELLED"}
|
||||
props = preview_base.get_preview_props(context, "bend")
|
||||
if props is None or not props.is_active:
|
||||
return {"CANCELLED"}
|
||||
preview_base.clear_preview_state(props)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class EnableBendPreviewFromBend(bpy.types.Operator):
|
||||
"""Re-open the bend preview on an existing bend fitting.
|
||||
|
||||
Resolves the two connected segments via the bend's ports +
|
||||
``IfcRelConnectsPorts``, reads parametric values back from the bend's
|
||||
``BBIM_Fitting`` pset, and flags the preview so committing replaces
|
||||
the existing bend in place."""
|
||||
|
||||
bl_idname = "bim.enable_bend_preview_from_bend"
|
||||
bl_label = "Edit Bend"
|
||||
bl_description = "Re-open the bend preview to retune an existing bend"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
active = context.active_object
|
||||
if active is None:
|
||||
cls.poll_message_set("No active object.")
|
||||
return False
|
||||
element = tool.Ifc.get_entity(active)
|
||||
if element is None or not _is_bend_fitting(element):
|
||||
cls.poll_message_set("Active object must be a bend fitting.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def execute(self, context):
|
||||
active = context.active_object
|
||||
bend_element = tool.Ifc.get_entity(active)
|
||||
if bend_element is None or not _is_bend_fitting(bend_element):
|
||||
self.report({"ERROR"}, "Active object is not a bend fitting.")
|
||||
return {"CANCELLED"}
|
||||
|
||||
connected_segments: list = []
|
||||
for port in tool.System.get_ports(bend_element):
|
||||
connected_port = tool.System.get_connected_port(port)
|
||||
if connected_port is None:
|
||||
continue
|
||||
related = tool.System.get_port_relating_element(connected_port)
|
||||
if related is not None and related.is_a("IfcFlowSegment") and related not in connected_segments:
|
||||
connected_segments.append(related)
|
||||
|
||||
if len(connected_segments) != 2:
|
||||
self.report(
|
||||
{"ERROR"},
|
||||
f"Bend has {len(connected_segments)} connected segments; need exactly 2 to re-edit.",
|
||||
)
|
||||
return {"CANCELLED"}
|
||||
|
||||
# Read parametric values from the bend type's BBIM_Fitting pset. The
|
||||
# type carries the canonical parameters; querying the occurrence
|
||||
# would force a get_type round-trip and miss user-edited types.
|
||||
bend_type = ifcopenshell.util.element.get_type(bend_element)
|
||||
if bend_type is None:
|
||||
self.report({"ERROR"}, "Bend fitting has no type to read parameters from.")
|
||||
return {"CANCELLED"}
|
||||
bend_type_obj = tool.Ifc.get_object(bend_type)
|
||||
if bend_type_obj is None:
|
||||
self.report({"ERROR"}, "Bend type has no Blender object — cannot read pset.")
|
||||
return {"CANCELLED"}
|
||||
bbim = tool.Model.get_modeling_bbim_pset_data(bend_type_obj, "BBIM_Fitting")
|
||||
if bbim is None:
|
||||
self.report({"ERROR"}, "Bend fitting has no BBIM_Fitting pset — not a parametric bend.")
|
||||
return {"CANCELLED"}
|
||||
data = bbim.get("data_dict", {})
|
||||
|
||||
props = preview_base.get_preview_props(context, "bend")
|
||||
if props is not None and props.is_active:
|
||||
bpy.ops.bim.cancel_bend_preview()
|
||||
|
||||
# Segment order is load-bearing: the bend's lateral sign and z-axis
|
||||
# flip are derived from which segment is "start" vs "end". Re-edit
|
||||
# must reuse the same pairing as the original create so the recreate
|
||||
# lands at the same orientation.
|
||||
start_segment, end_segment = connected_segments
|
||||
props.start_segment_id = start_segment.id()
|
||||
props.end_segment_id = end_segment.id()
|
||||
# Pset values are in IFC native units; scene units come from si_conversion.
|
||||
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
props.start_length = float(data.get("start_length", 0.1)) * si_conversion
|
||||
props.end_length = float(data.get("end_length", 0.1)) * si_conversion
|
||||
props.radius = float(data.get("radius", 0.2)) * si_conversion
|
||||
props.editing_bend_id = bend_element.id()
|
||||
props.is_active = True
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
def _bend_preview_segments(context):
|
||||
"""Resolve the two segment objects from the scene-level preview props.
|
||||
|
||||
Re-resolves by IFC id each frame so undo / file reload during preview
|
||||
never dangles a stale bpy reference."""
|
||||
props = context.scene.BIMPreviewProperties.bend
|
||||
ifc_file = tool.Ifc.get()
|
||||
if ifc_file is None or not props.is_active:
|
||||
return None, None
|
||||
try:
|
||||
start_element = ifc_file.by_id(props.start_segment_id)
|
||||
end_element = ifc_file.by_id(props.end_segment_id)
|
||||
except Exception:
|
||||
return None, None
|
||||
start_obj = tool.Ifc.get_object(start_element) if start_element else None
|
||||
end_obj = tool.Ifc.get_object(end_element) if end_element else None
|
||||
return start_obj, end_obj
|
||||
|
||||
|
||||
def _gizmo_x_matrix(location: Vector, x_direction: Vector) -> Matrix:
|
||||
"""Build a 4x4 matrix placing a gizmo at ``location`` with its local +X
|
||||
axis aligned to ``x_direction`` in world space. ``BIM_GT_gizmo_dimension``
|
||||
draws + drags along local +X by convention."""
|
||||
x = x_direction.normalized()
|
||||
seed = Vector((0, 0, 1)) if abs(x.z) < 0.9 else Vector((1, 0, 0))
|
||||
y = (seed - x * seed.dot(x)).normalized()
|
||||
z = x.cross(y)
|
||||
mat = Matrix.Identity(4)
|
||||
mat[0][:3] = (x.x, y.x, z.x)
|
||||
mat[1][:3] = (x.y, y.y, z.y)
|
||||
mat[2][:3] = (x.z, y.z, z.z)
|
||||
mat.translation = location
|
||||
return mat
|
||||
|
||||
|
||||
class GizmoBendPreview(bpy.types.GizmoGroup):
|
||||
"""Interactive gizmo group for the bend preview flow.
|
||||
|
||||
Three dimension widgets drag start_length / end_length / radius; two
|
||||
icon gizmos commit or cancel. When the geometry is degenerate the
|
||||
dimensions and validate hide but cancel stays visible so the user
|
||||
always has an exit."""
|
||||
|
||||
bl_idname = "OBJECT_GGT_bim_bend_preview"
|
||||
bl_label = "Bend Preview Gizmos"
|
||||
bl_space_type = "VIEW_3D"
|
||||
bl_region_type = "WINDOW"
|
||||
bl_options = {"3D", "PERSISTENT"}
|
||||
|
||||
ICON_SCALE: ClassVar[float] = 0.375
|
||||
ICON_SPACING_X: ClassVar[float] = 0.4
|
||||
ICON_Z_OFFSET: ClassVar[float] = 1.5
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
preview = getattr(context.scene, "BIMPreviewProperties", None)
|
||||
props = preview.bend if preview is not None else None
|
||||
if props is None or not props.is_active:
|
||||
return False
|
||||
if not tool.Blender.are_viewport_gizmos_enabled():
|
||||
return False
|
||||
ifc_file = tool.Ifc.get()
|
||||
if ifc_file is None:
|
||||
return False
|
||||
try:
|
||||
ifc_file.by_id(props.start_segment_id)
|
||||
ifc_file.by_id(props.end_segment_id)
|
||||
except (RuntimeError, KeyError):
|
||||
return False
|
||||
return True
|
||||
|
||||
def setup(self, context):
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
default_color = tuple(prefs.decorations_colour[:3])
|
||||
highlight_color = tuple(prefs.decorator_color_selected[:3])
|
||||
|
||||
_props = preview_base.make_props_callback("bend")
|
||||
|
||||
def setup_dimension(attr: str, prop_name: str, invert_delta: bool = False) -> bpy.types.Gizmo:
|
||||
gz = self.gizmos.new("BIM_GT_gizmo_dimension")
|
||||
gz.move_get_cb = preview_base.make_dim_getter(_props, attr)
|
||||
gz.move_set_cb = preview_base.make_dim_setter(_props, attr)
|
||||
gz.axis = Vector((1, 0, 0))
|
||||
gz.invert_delta = invert_delta
|
||||
gz.delta_scale = 1.0
|
||||
gz.prop_name = prop_name
|
||||
gz.gizmo_group = self
|
||||
gz.color = default_color
|
||||
gz.color_highlight = highlight_color
|
||||
gz.alpha = 1.0
|
||||
gz.use_draw_modal = True
|
||||
gz.use_draw_scale = False
|
||||
gz.text_offset_sign = 1
|
||||
gz.text_alignment = gizmo.TextAlignment.CENTER
|
||||
gz.show_start_arrow = False
|
||||
gz.show_end_arrow = True
|
||||
gz.show_extension_lines = False
|
||||
gz.text_formatter = None
|
||||
return gz
|
||||
|
||||
self.start_dim = setup_dimension("start_length", "Start Length")
|
||||
self.end_dim = setup_dimension("end_length", "End Length")
|
||||
self.radius_dim = setup_dimension("radius", "Radius")
|
||||
|
||||
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
|
||||
|
||||
self.validate_icon = self.gizmos.new("VIEW3D_GT_validate")
|
||||
self.validate_icon.use_draw_scale = False
|
||||
self.validate_icon.color = BaseParametricGizmoGroup.COLOR_GREEN
|
||||
self.validate_icon.color_highlight = highlight_color
|
||||
self.validate_icon.target_set_operator("bim.finish_bend_preview")
|
||||
|
||||
self.cancel_icon = self.gizmos.new("VIEW3D_GT_cancel")
|
||||
self.cancel_icon.use_draw_scale = False
|
||||
self.cancel_icon.color = BaseParametricGizmoGroup.COLOR_RED
|
||||
self.cancel_icon.color_highlight = highlight_color
|
||||
self.cancel_icon.target_set_operator("bim.cancel_bend_preview")
|
||||
|
||||
def refresh(self, context):
|
||||
self._position_gizmos(context)
|
||||
|
||||
def draw_prepare(self, context):
|
||||
self._position_gizmos(context)
|
||||
|
||||
def _position_gizmos(self, context):
|
||||
"""Place gizmos at the bend intersection using the current scene
|
||||
props. Cancel stays visible on degenerate geometry so the user
|
||||
always has an exit; the other widgets hide when there's no defined
|
||||
tangent / arc to anchor them on."""
|
||||
start_obj, end_obj = _bend_preview_segments(context)
|
||||
if start_obj is None or end_obj is None:
|
||||
for gz in (self.start_dim, self.end_dim, self.radius_dim, self.validate_icon, self.cancel_icon):
|
||||
gz.hide = True
|
||||
return
|
||||
|
||||
props = context.scene.BIMPreviewProperties.bend
|
||||
preview = cached_compute_bend_preview_polylines(
|
||||
start_obj, end_obj, props.start_length, props.end_length, props.radius
|
||||
)
|
||||
if not preview["valid"]:
|
||||
for gz in (self.start_dim, self.end_dim, self.radius_dim, self.validate_icon):
|
||||
gz.hide = True
|
||||
self.cancel_icon.hide = False
|
||||
axes = preview.get("invalid_axes") or []
|
||||
if axes:
|
||||
intersection_point = axes[0][1]
|
||||
billboard_rot = gizmo.get_billboard_rotation(context)
|
||||
anchor = intersection_point + Vector((0, 0, self.ICON_Z_OFFSET))
|
||||
self.cancel_icon.matrix_basis = gizmo.billboarded_at(anchor, billboard_rot, scale=self.ICON_SCALE)
|
||||
return
|
||||
|
||||
for gz in (self.start_dim, self.end_dim, self.radius_dim, self.validate_icon, self.cancel_icon):
|
||||
gz.hide = False
|
||||
|
||||
leg_a_far, leg_a_end = preview["leg_a"]
|
||||
leg_b_far, leg_b_end = preview["leg_b"]
|
||||
toward_bend_a = (
|
||||
(leg_a_end - leg_a_far).normalized() if (leg_a_end - leg_a_far).length > 1e-6 else Vector((0, 0, 1))
|
||||
)
|
||||
toward_bend_b = (
|
||||
(leg_b_end - leg_b_far).normalized() if (leg_b_end - leg_b_far).length > 1e-6 else Vector((0, 0, 1))
|
||||
)
|
||||
leg_a_tangent = leg_a_end + toward_bend_a * props.start_length
|
||||
leg_b_tangent = leg_b_end + toward_bend_b * props.end_length
|
||||
|
||||
# axis is set in world space every frame so the drag projection
|
||||
# matches the visual regardless of either segment's matrix_world.
|
||||
self.start_dim.matrix_basis = _gizmo_x_matrix(leg_a_tangent, -toward_bend_a)
|
||||
self.start_dim.axis = -toward_bend_a
|
||||
self.start_dim.set_dimension_length(props.start_length)
|
||||
self.end_dim.matrix_basis = _gizmo_x_matrix(leg_b_tangent, -toward_bend_b)
|
||||
self.end_dim.axis = -toward_bend_b
|
||||
self.end_dim.set_dimension_length(props.end_length)
|
||||
|
||||
arc = preview["arc"]
|
||||
if len(arc) >= 3:
|
||||
mid = len(arc) // 2
|
||||
chord_mid = (arc[0] + arc[-1]) * 0.5
|
||||
toward_mid = arc[mid] - chord_mid
|
||||
if toward_mid.length > 1e-6:
|
||||
toward_mid = toward_mid.normalized()
|
||||
half_chord = (arc[-1] - arc[0]).length * 0.5
|
||||
center_dist = max(0.0, props.radius * props.radius - half_chord * half_chord) ** 0.5
|
||||
arc_center = chord_mid - toward_mid * center_dist
|
||||
radial_out = arc[mid] - arc_center
|
||||
if radial_out.length > 1e-6:
|
||||
radial_out.normalize()
|
||||
inward = -radial_out
|
||||
self.radius_dim.matrix_basis = _gizmo_x_matrix(arc[mid], inward)
|
||||
self.radius_dim.axis = inward
|
||||
self.radius_dim.set_dimension_length(props.radius)
|
||||
else:
|
||||
self.radius_dim.hide = True
|
||||
else:
|
||||
self.radius_dim.hide = True
|
||||
else:
|
||||
self.radius_dim.hide = True
|
||||
|
||||
billboard_rot = gizmo.get_billboard_rotation(context)
|
||||
anchor_base = arc[len(arc) // 2] if arc else (leg_a_end + leg_b_end) * 0.5
|
||||
anchor = anchor_base + Vector((0, 0, self.ICON_Z_OFFSET))
|
||||
offset_x = billboard_rot @ Vector((self.ICON_SPACING_X, 0.0, 0.0))
|
||||
self.validate_icon.matrix_basis = gizmo.billboarded_at(anchor, billboard_rot, scale=self.ICON_SCALE)
|
||||
self.cancel_icon.matrix_basis = gizmo.billboarded_at(anchor + offset_x, billboard_rot, scale=self.ICON_SCALE)
|
||||
@@ -15,8 +15,6 @@
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was modified with the assistance of an AI coding tool.
|
||||
|
||||
from collections.abc import Sequence
|
||||
from math import radians
|
||||
@@ -209,21 +207,6 @@ def _get_cached_world_draw_data(
|
||||
# handle each call), so they stay drawable across frames.
|
||||
_batch_cache: dict[tuple[int, str], tuple[int, "gpu.types.GPUBatch"]] = {}
|
||||
|
||||
# CAD hidden-line convention for the occluded back-pass: world-space dashes so
|
||||
# density stays coherent across zoom. Dash + gap = period; dash_width controls
|
||||
# the "on" portion.
|
||||
_DASH_PERIOD_METERS: float = 0.20
|
||||
_DASH_WIDTH_METERS: float = 0.10
|
||||
# Solid front pass is rendered wider than the dashed back pass so its halo
|
||||
# overpowers the dashed center on visible edges even when the WIRE-display
|
||||
# overlay biases the depth buffer at outline pixels.
|
||||
_DASH_LINE_WIDTH: float = 1.5
|
||||
_SOLID_LINE_WIDTH: float = 2.5
|
||||
# Per-iteration default line width used by every non-occlusion draw call in
|
||||
# this decorator's ``__call__``. Restored after each occlusion pair so the
|
||||
# next draw isn't silently inheriting the wider solid-pass override.
|
||||
_DEFAULT_LINE_WIDTH: float = 2.0
|
||||
|
||||
|
||||
def _get_cached_batch_or_none(cache_key: tuple[int, str]) -> "gpu.types.GPUBatch | None":
|
||||
uid = cache_key[0]
|
||||
@@ -246,15 +229,9 @@ class FilledOpeningGenerator:
|
||||
filling_obj: bpy.types.Object,
|
||||
voided_obj: bpy.types.Object,
|
||||
target: Optional[Vector] = None,
|
||||
preserve_placement: bool = False,
|
||||
) -> Union[None, str]:
|
||||
"""
|
||||
:param target: Target opening position. If ommited, cursor position is used.
|
||||
:param preserve_placement: If True, keep ``filling_obj.matrix_world`` as-is
|
||||
and skip the snap-to-wall-axis / rl1-rl2 Z-default logic. The opening
|
||||
is still created at the filling's current world position. Useful
|
||||
when the caller (e.g. the SHIFT-add-opening gizmo flow) has
|
||||
already positioned the filling intentionally.
|
||||
:return: None if there was no errors, otherwise returns a string with error message.
|
||||
"""
|
||||
props = tool.Model.get_model_props()
|
||||
@@ -276,7 +253,7 @@ class FilledOpeningGenerator:
|
||||
should_set_z_level = False
|
||||
|
||||
# Sometimes, the voided_obj may be an aggregate, which won't have any representation.
|
||||
if not preserve_placement and voided_obj.data:
|
||||
if voided_obj.data:
|
||||
raycast = voided_obj.closest_point_on_mesh(voided_obj.matrix_world.inverted() @ target, distance=0.01)
|
||||
if not raycast[0]:
|
||||
target = filling_obj.matrix_world.translation.copy()
|
||||
@@ -760,29 +737,6 @@ class AddBoolean(Operator, tool.Ifc.Operator):
|
||||
tool.Root.reload_item_decorator()
|
||||
|
||||
|
||||
class ToggleHostOpenings(Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.toggle_host_openings"
|
||||
bl_label = "Toggle Openings"
|
||||
bl_description = "Show or hide opening fills (doors and windows) in the viewport\n\nHotkey: Alt+O"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
if not tool.Model.has_selected_ifc_objects():
|
||||
cls.poll_message_set("No IFC objects selected.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
# Opening visibility is independent of host geometry — don't commit any
|
||||
# active parametric edit; the user can keep editing the host.
|
||||
if tool.Model.get_model_props().openings:
|
||||
bpy.ops.bim.edit_openings(apply_all=True)
|
||||
else:
|
||||
bpy.ops.bim.show_openings()
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class ShowOpenings(Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.show_openings"
|
||||
bl_label = "Show Openings"
|
||||
@@ -1206,55 +1160,22 @@ class DecorationsHandler:
|
||||
shader.uniform_float("color", color)
|
||||
batch.draw(shader)
|
||||
|
||||
def _draw_lines_with_occlusion(self, verts, color, edges_indices, cache_key=None):
|
||||
# Two-pass CAD hidden-line convention. Both passes use POLYLINE_UNIFORM_COLOR.
|
||||
#
|
||||
# The solid front pass is rendered WIDER than the dashed back pass so it
|
||||
# produces a halo around the line center, beyond the depth-bias zone that
|
||||
# Blender's overlay engine writes when an opening is set to WIRE display.
|
||||
# Without the width difference, the wire bias makes the center-pixel
|
||||
# ``LESS_EQUAL`` comparison fail (line ends up slightly behind the biased
|
||||
# wire depth) so the solid pass would lose to the dashed back pass even
|
||||
# on visible edges. The halo gives the solid pass enough screen-space to
|
||||
# overpower the dashed pattern visually.
|
||||
#
|
||||
# Dashed renders first at the standard width so the solid overlay's wider
|
||||
# halo cleanly hides it on visible edges; on occluded edges the solid
|
||||
# ``LESS_EQUAL`` pass fails against the wall depth and the dashed remains.
|
||||
front_batch = self._get_or_build_batch(self.line_shader, "LINES", verts, edges_indices, cache_key=cache_key)
|
||||
if front_batch is None:
|
||||
def _draw_lines_with_occlusion(self, verts, color, edges_indices, occluded_alpha: float = 0.25, cache_key=None):
|
||||
# One batch, two draws: front pass at full color, occluded pass at
|
||||
# `occluded_alpha`. Save/restore depth_test matches the pattern in
|
||||
# bim/module/structural/decorator.py so callers' state survives.
|
||||
batch = self._get_or_build_batch(self.line_shader, "LINES", verts, edges_indices, cache_key=cache_key)
|
||||
if batch is None:
|
||||
return
|
||||
|
||||
dashed_cache_key = (cache_key[0], cache_key[1] + "_dashed") if cache_key is not None else None
|
||||
dash_batch = None
|
||||
if dashed_cache_key is not None:
|
||||
dash_batch = _get_cached_batch_or_none(dashed_cache_key)
|
||||
if dash_batch is None:
|
||||
dash_verts, dash_edges = tool.Blender.build_dashed_line_segments(
|
||||
verts, edges_indices, _DASH_PERIOD_METERS, _DASH_WIDTH_METERS
|
||||
)
|
||||
dash_batch = self._get_or_build_batch(self.line_shader, "LINES", dash_verts, dash_edges)
|
||||
if dash_batch is not None and dashed_cache_key is not None:
|
||||
_store_batch_in_cache(dashed_cache_key, dash_batch)
|
||||
|
||||
original_depth_test = gpu.state.depth_test_get()
|
||||
front_color = list(color)
|
||||
front_color[3] = 1.0
|
||||
self.line_shader.uniform_float("color", front_color)
|
||||
|
||||
if dash_batch is not None:
|
||||
self.line_shader.uniform_float("lineWidth", _DASH_LINE_WIDTH)
|
||||
gpu.state.depth_test_set("ALWAYS")
|
||||
dash_batch.draw(self.line_shader)
|
||||
|
||||
self.line_shader.uniform_float("lineWidth", _SOLID_LINE_WIDTH)
|
||||
gpu.state.depth_test_set("LESS_EQUAL")
|
||||
front_batch.draw(self.line_shader)
|
||||
|
||||
# Restore the per-iteration default set at the top of __call__ so
|
||||
# subsequent draws (the HalfSpaceSolid arrow, future call-sites) are
|
||||
# not silently affected by the front-pass width override.
|
||||
self.line_shader.uniform_float("lineWidth", _DEFAULT_LINE_WIDTH)
|
||||
self.line_shader.uniform_float("color", color)
|
||||
batch.draw(self.line_shader)
|
||||
gpu.state.depth_test_set("GREATER")
|
||||
dimmed = list(color)
|
||||
dimmed[3] = occluded_alpha
|
||||
self.line_shader.uniform_float("color", dimmed)
|
||||
batch.draw(self.line_shader)
|
||||
gpu.state.depth_test_set(original_depth_test)
|
||||
|
||||
def __call__(self, context):
|
||||
@@ -1290,7 +1211,7 @@ class DecorationsHandler:
|
||||
self.line_shader.bind() # required to be able to change uniforms of the shader
|
||||
# POLYLINE_UNIFORM_COLOR specific uniforms
|
||||
self.line_shader.uniform_float("viewportSize", (context.region.width, context.region.height))
|
||||
self.line_shader.uniform_float("lineWidth", _DEFAULT_LINE_WIDTH)
|
||||
self.line_shader.uniform_float("lineWidth", 2.0)
|
||||
|
||||
# general shader
|
||||
self.shader = gpu.shader.from_builtin("UNIFORM_COLOR")
|
||||
@@ -1328,7 +1249,9 @@ class DecorationsHandler:
|
||||
self.draw_batch("LINES", verts, selected_elements_color, selected_edges)
|
||||
self.draw_batch("POINTS", unselected_vertices, unselected_elements_color)
|
||||
self.draw_batch("POINTS", selected_vertices, selected_elements_color)
|
||||
tool.Blender.draw_bmesh_face_tris(bm, verts, transparent_color(special_elements_color), self.draw_batch)
|
||||
obj.data.calc_loop_triangles()
|
||||
tris = [tuple(t.vertices) for t in obj.data.loop_triangles]
|
||||
self.draw_batch("TRIS", verts, transparent_color(special_elements_color), tris)
|
||||
else:
|
||||
line_verts, verts, edges_indices, tris = _get_cached_world_draw_data(obj)
|
||||
color = selected_elements_color if obj in context.selected_objects else special_elements_color
|
||||
|
||||
@@ -75,7 +75,6 @@ class PolylineOperator:
|
||||
self.is_typing = False
|
||||
self.snap_angle = None
|
||||
self.snapping_points = []
|
||||
self.unit_scale = 1.0
|
||||
self.instructions = {
|
||||
"Cycle Input": {"icons": True, "keys": ["EVENT_TAB"]},
|
||||
"Distance Input": {"icons": True, "keys": ["EVENT_D"]},
|
||||
|
||||
@@ -60,12 +60,8 @@ def get_preview_props(context: bpy.types.Context, attr: str):
|
||||
Returns ``None`` if the umbrella isn't attached yet — true briefly
|
||||
during addon register and during plug-out, so polls / draw callbacks
|
||||
must defend against ``None`` rather than assuming the prop is always
|
||||
available. Also tolerates contexts without a ``scene`` attribute
|
||||
(test mocks built from ``SimpleNamespace``)."""
|
||||
scene = getattr(context, "scene", None)
|
||||
if scene is None:
|
||||
return None
|
||||
preview = getattr(scene, "BIMPreviewProperties", None)
|
||||
available."""
|
||||
preview = getattr(context.scene, "BIMPreviewProperties", None)
|
||||
return getattr(preview, attr, None) if preview is not None else None
|
||||
|
||||
|
||||
@@ -78,17 +74,6 @@ def is_preview_active(context: bpy.types.Context, attr: str) -> bool:
|
||||
return bool(props is not None and props.is_active)
|
||||
|
||||
|
||||
def any_preview_active(context: bpy.types.Context) -> bool:
|
||||
"""``True`` if any registered preview is currently open. Sister gizmo
|
||||
polls call this to hide themselves uniformly during ANY preview, so a
|
||||
new preview registered in ``PREVIEW_CANCEL_OPS`` automatically gates
|
||||
every parametric gizmo without each one growing a specific check."""
|
||||
for attr, _op_name in PREVIEW_CANCEL_OPS:
|
||||
if is_preview_active(context, attr):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# --- Lazy closure factories --------------------------------------------------
|
||||
#
|
||||
# Used by preview gizmo groups when wiring ``BIM_GT_gizmo_dimension``'s
|
||||
@@ -163,59 +148,6 @@ def sync_uncommitted_moves(objects: list) -> None:
|
||||
tool.Geometry.commit_placement_if_moved(obj, apply_scale=False)
|
||||
|
||||
|
||||
def clear_preview_state(props: bpy.types.PropertyGroup) -> None:
|
||||
"""Reset a preview PropertyGroup to its idle state on commit / cancel.
|
||||
|
||||
Sets ``is_active`` to False and zeros every ``IntProperty`` whose name
|
||||
ends in ``_id`` (the entity-reference convention every preview follows).
|
||||
Other fields are left at their last value — defaults are re-applied on
|
||||
the next enable, so leaving them alone avoids a redundant write."""
|
||||
props.is_active = False
|
||||
for name, rna in props.bl_rna.properties.items():
|
||||
if name.endswith("_id") and rna.type == "INT":
|
||||
setattr(props, name, 0)
|
||||
|
||||
|
||||
# --- Standard Finish flow ----------------------------------------------------
|
||||
|
||||
|
||||
def commit_preview(
|
||||
operator: bpy.types.Operator,
|
||||
context: bpy.types.Context,
|
||||
attr: str,
|
||||
target_op_name: str,
|
||||
kwarg_names: tuple[str, ...],
|
||||
) -> set[str]:
|
||||
"""Standard Finish-Preview dispatch: validate context + active preview,
|
||||
read kwargs off the draft, call ``bpy.ops.bim.<target_op_name>(**kwargs)``,
|
||||
and clear the preview on success.
|
||||
|
||||
The dispatched operator's own ``self.report({"ERROR"})`` paths are promoted
|
||||
by ``bpy.ops`` to ``RuntimeError`` — catching it here surfaces the message
|
||||
to the user via ``operator.report`` rather than leaving Blender's operator
|
||||
state half-broken (which silently disables downstream gizmo polls).
|
||||
|
||||
Returns the dispatched operator's result set verbatim so callers can
|
||||
pass it straight back from their own ``execute``."""
|
||||
if context.screen is None:
|
||||
return {"CANCELLED"}
|
||||
props = get_preview_props(context, attr)
|
||||
if props is None or not props.is_active:
|
||||
return {"CANCELLED"}
|
||||
if tool.Ifc.get() is None:
|
||||
operator.report({"ERROR"}, "No IFC file loaded.")
|
||||
return {"CANCELLED"}
|
||||
kwargs = {name: getattr(props, name) for name in kwarg_names}
|
||||
try:
|
||||
result = getattr(bpy.ops.bim, target_op_name)(**kwargs)
|
||||
except RuntimeError as exc:
|
||||
operator.report({"ERROR"}, str(exc))
|
||||
return {"CANCELLED"}
|
||||
if "FINISHED" in result:
|
||||
clear_preview_state(props)
|
||||
return result
|
||||
|
||||
|
||||
# --- Esc dispatch ------------------------------------------------------------
|
||||
|
||||
PREVIEW_CANCEL_OPS: tuple[tuple[str, str], ...] = (
|
||||
|
||||
@@ -1157,8 +1157,7 @@ class DrawPolylineProfile(bpy.types.Operator, PolylineOperator, tool.Ifc.Operato
|
||||
DumbProfileJoiner().join_V(profile2["obj"], profile1["obj"])
|
||||
if connect_IfcFlowSegments:
|
||||
bpy.ops.bim.mep_connect_elements(
|
||||
obj1_guid=tool.Ifc.get_entity(profile1["obj"]).GlobalId,
|
||||
obj2_guid=tool.Ifc.get_entity(profile2["obj"]).GlobalId,
|
||||
obj1_name=profile1["obj"].name, obj2_name=profile2["obj"].name
|
||||
)
|
||||
|
||||
def modal(self, context, event):
|
||||
|
||||
@@ -103,12 +103,8 @@ def update_type_page(self: "BIMModelProperties", context: bpy.types.Context) ->
|
||||
|
||||
|
||||
def update_relating_array_from_object(self: "BIMArrayProperties", context: bpy.types.Context) -> None:
|
||||
# Skip the cleanup-time clear: Finish/Cancel sets relating_array_object back to None,
|
||||
# which has no source to hydrate from. Only the user-driven pick (None → some array)
|
||||
# should auto-enter edit on the picked source's layer 0.
|
||||
if self.relating_array_object is None:
|
||||
return
|
||||
bpy.ops.bim.enable_editing_array(item=0)
|
||||
bpy.ops.bim.enable_editing_array(item=self.is_editing)
|
||||
return
|
||||
|
||||
|
||||
def is_object_array_applicable(self: "BIMArrayProperties", obj: bpy.types.Object) -> bool:
|
||||
@@ -242,20 +238,6 @@ def update_roof(self: "BIMRoofProperties", context: bpy.types.Context) -> None:
|
||||
_get_updater("roof", "update_roof_modifier_bmesh")(obj)
|
||||
|
||||
|
||||
def update_pipe_segment(self: "BIMPipeSegmentProperties", context: bpy.types.Context) -> None:
|
||||
"""Regenerate pipe-segment preview mesh from props during edit. Does NOT touch IFC."""
|
||||
obj = context.active_object
|
||||
if obj and self.is_editing:
|
||||
_get_updater("mep", "regenerate_pipe_segment_mesh_from_props")(obj)
|
||||
|
||||
|
||||
def update_duct_segment(self: "BIMDuctSegmentProperties", context: bpy.types.Context) -> None:
|
||||
"""Regenerate duct-segment preview mesh from props during edit. Does NOT touch IFC."""
|
||||
obj = context.active_object
|
||||
if obj and self.is_editing:
|
||||
_get_updater("mep", "regenerate_duct_segment_mesh_from_props")(obj)
|
||||
|
||||
|
||||
class BIMModelProperties(PropertyGroup):
|
||||
ifc_class: bpy.props.EnumProperty(items=get_ifc_class, name="Construction Class", update=update_ifc_class)
|
||||
relating_type_id: bpy.props.EnumProperty(
|
||||
@@ -415,13 +397,8 @@ class BIMModelProperties(PropertyGroup):
|
||||
|
||||
|
||||
class BIMArrayProperties(PropertyGroup):
|
||||
is_editing: bpy.props.BoolProperty(
|
||||
default=False,
|
||||
description="True while an array layer is in parametric edit mode. The specific layer is in editing_item_index.",
|
||||
)
|
||||
editing_item_index: bpy.props.IntProperty(
|
||||
default=-1,
|
||||
description="Index of the array layer currently being edited; -1 when not in edit mode.",
|
||||
is_editing: bpy.props.IntProperty(
|
||||
default=-1, description="Currently edited array index. -1 if not in array editing mode."
|
||||
)
|
||||
count: bpy.props.IntProperty(name="Count", default=0, min=0)
|
||||
x: bpy.props.FloatProperty(name="X", default=0, subtype="DISTANCE")
|
||||
@@ -437,15 +414,6 @@ class BIMArrayProperties(PropertyGroup):
|
||||
name="Method",
|
||||
default="OFFSET",
|
||||
)
|
||||
per_child_opening: bpy.props.BoolProperty(
|
||||
name="Per-Child Opening",
|
||||
description=(
|
||||
"When the array parent fills a wall (or any voidable host), give each array child its own opening + "
|
||||
"filling pair so the host is cut once per child. Disable to leave the host uncut by the children — "
|
||||
"only the parent's original opening remains"
|
||||
),
|
||||
default=True,
|
||||
)
|
||||
relating_array_object: bpy.props.PointerProperty(
|
||||
type=bpy.types.Object,
|
||||
name="Copy Array Properties",
|
||||
@@ -454,15 +422,13 @@ class BIMArrayProperties(PropertyGroup):
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
is_editing: bool
|
||||
editing_item_index: int
|
||||
is_editing: int
|
||||
count: int
|
||||
x: float
|
||||
y: float
|
||||
z: float
|
||||
use_local_space: bool
|
||||
method: Literal["OFFSET", "DISTRIBUTE"]
|
||||
per_child_opening: bool
|
||||
sync_children: bool
|
||||
relating_array_object: Union[bpy.types.Object, None]
|
||||
|
||||
@@ -1936,236 +1902,3 @@ class BIMExternalParametricGeometryProperties(bpy.types.PropertyGroup):
|
||||
geometry_source: Literal["GEONODES", "IFCSVERCHOK"]
|
||||
geo_nodes: Union[bpy.types.GeometryNodeTree, None]
|
||||
sverchok_nodes: Union[sverchok.node_tree.SverchCustomTree, None]
|
||||
|
||||
|
||||
class BIMPipeSegmentProperties(PropertyGroup):
|
||||
"""Transient draft state for parametric pipe-segment gizmo editing."""
|
||||
|
||||
is_editing: bpy.props.BoolProperty(
|
||||
default=False,
|
||||
description="True while pipe-segment parametric edit mode is active.",
|
||||
)
|
||||
mesh_dirty: bpy.props.BoolProperty(
|
||||
default=False,
|
||||
options={"HIDDEN", "SKIP_SAVE"},
|
||||
description=(
|
||||
"True while the visible mesh is the preview shape; cleared once the "
|
||||
"real IFC-derived geometry is restored (on commit or cancel)."
|
||||
),
|
||||
)
|
||||
length: bpy.props.FloatProperty(
|
||||
name="Length",
|
||||
default=1.0,
|
||||
min=0.01,
|
||||
subtype="DISTANCE",
|
||||
update=update_pipe_segment,
|
||||
description="Pipe-segment extrusion length (preview value; committed on finish).",
|
||||
)
|
||||
snap_length: bpy.props.FloatProperty(
|
||||
description="Snapshot of length at edit-enable; commit skips no-op writes.",
|
||||
)
|
||||
snap_object_scale_z: bpy.props.FloatProperty(
|
||||
default=1.0,
|
||||
description=(
|
||||
"Snapshot of obj.scale.z at edit-enable. Cancel / no-op-finish restore "
|
||||
"this exact value so a user's non-identity pre-edit scale isn't silently "
|
||||
"zeroed by the scale-based preview."
|
||||
),
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
is_editing: bool
|
||||
mesh_dirty: bool
|
||||
length: float
|
||||
snap_length: float
|
||||
snap_object_scale_z: float
|
||||
|
||||
|
||||
class BIMDuctSegmentProperties(PropertyGroup):
|
||||
"""Transient draft state for parametric duct-segment gizmo editing."""
|
||||
|
||||
is_editing: bpy.props.BoolProperty(
|
||||
default=False,
|
||||
description="True while duct-segment parametric edit mode is active.",
|
||||
)
|
||||
mesh_dirty: bpy.props.BoolProperty(
|
||||
default=False,
|
||||
options={"HIDDEN", "SKIP_SAVE"},
|
||||
description=(
|
||||
"True while the visible mesh is the preview shape; cleared once the "
|
||||
"real IFC-derived geometry is restored (on commit or cancel)."
|
||||
),
|
||||
)
|
||||
length: bpy.props.FloatProperty(
|
||||
name="Length",
|
||||
default=1.0,
|
||||
min=0.01,
|
||||
subtype="DISTANCE",
|
||||
update=update_duct_segment,
|
||||
description="Duct-segment extrusion length (preview value; committed on finish).",
|
||||
)
|
||||
snap_length: bpy.props.FloatProperty(
|
||||
description="Snapshot of length at edit-enable; commit skips no-op writes.",
|
||||
)
|
||||
snap_object_scale_z: bpy.props.FloatProperty(
|
||||
default=1.0,
|
||||
description=(
|
||||
"Snapshot of obj.scale.z at edit-enable. Cancel / no-op-finish restore "
|
||||
"this exact value so a user's non-identity pre-edit scale isn't silently "
|
||||
"zeroed by the scale-based preview."
|
||||
),
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
is_editing: bool
|
||||
mesh_dirty: bool
|
||||
length: float
|
||||
snap_length: float
|
||||
snap_object_scale_z: float
|
||||
|
||||
|
||||
class BIMBendPreviewProperties(PropertyGroup):
|
||||
"""Scene-level pending state for the bend-creation preview flow.
|
||||
|
||||
Scene-level (not per-object) because the bend involves two segments by
|
||||
IFC id — neither alone owns the draft."""
|
||||
|
||||
is_active: bpy.props.BoolProperty(
|
||||
default=False,
|
||||
options={"SKIP_SAVE"},
|
||||
description="True while the bend-creation preview flow is active.",
|
||||
)
|
||||
start_segment_id: bpy.props.IntProperty(
|
||||
default=0,
|
||||
options={"SKIP_SAVE"},
|
||||
description="IFC element id of the start (active) segment.",
|
||||
)
|
||||
end_segment_id: bpy.props.IntProperty(
|
||||
default=0,
|
||||
options={"SKIP_SAVE"},
|
||||
description="IFC element id of the end (other selected) segment.",
|
||||
)
|
||||
start_length: bpy.props.FloatProperty(
|
||||
name="Start Length",
|
||||
default=0.1,
|
||||
min=0.001,
|
||||
subtype="DISTANCE",
|
||||
description="Length of the bend fitting's tangent leg on the start (active) segment side",
|
||||
)
|
||||
end_length: bpy.props.FloatProperty(
|
||||
name="End Length",
|
||||
default=0.1,
|
||||
min=0.001,
|
||||
subtype="DISTANCE",
|
||||
description="Length of the bend fitting's tangent leg on the end (other) segment side",
|
||||
)
|
||||
radius: bpy.props.FloatProperty(
|
||||
name="Radius",
|
||||
default=0.2,
|
||||
min=0.001,
|
||||
subtype="DISTANCE",
|
||||
description="Inner radius of the bend curve",
|
||||
)
|
||||
editing_bend_id: bpy.props.IntProperty(
|
||||
default=0,
|
||||
options={"SKIP_SAVE"},
|
||||
description=(
|
||||
"IFC element id of an existing bend fitting being re-edited "
|
||||
"(non-zero only on the pen-icon re-edit flow). The create "
|
||||
"operator deletes this bend + its port connections before "
|
||||
"recreating with the new parameters."
|
||||
),
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
is_active: bool
|
||||
start_segment_id: int
|
||||
end_segment_id: int
|
||||
start_length: float
|
||||
end_length: float
|
||||
radius: float
|
||||
editing_bend_id: int
|
||||
|
||||
|
||||
class BIMWallFilletPreviewProperties(PropertyGroup):
|
||||
"""Scene-level pending state for the wall-fillet preview flow.
|
||||
|
||||
Scene-level because the fillet spans two walls and commits a third
|
||||
(corner) wall between them. ``SKIP_SAVE`` fields throughout."""
|
||||
|
||||
is_active: bpy.props.BoolProperty(
|
||||
default=False,
|
||||
options={"SKIP_SAVE"},
|
||||
description="True while the wall-fillet preview flow is active.",
|
||||
)
|
||||
wall_a_id: bpy.props.IntProperty(
|
||||
default=0,
|
||||
options={"SKIP_SAVE"},
|
||||
description=(
|
||||
"IFC element id of the active wall — the corner wall inherits its "
|
||||
"material layer set, height, x_angle, and type."
|
||||
),
|
||||
)
|
||||
wall_b_id: bpy.props.IntProperty(
|
||||
default=0,
|
||||
options={"SKIP_SAVE"},
|
||||
description="IFC element id of the other selected wall.",
|
||||
)
|
||||
radius: bpy.props.FloatProperty(
|
||||
name="Radius",
|
||||
default=0.5,
|
||||
soft_min=-10.0,
|
||||
soft_max=10.0,
|
||||
subtype="DISTANCE",
|
||||
unit="LENGTH",
|
||||
options={"SKIP_SAVE"},
|
||||
description="Radius of the circular arc connecting the two walls.",
|
||||
)
|
||||
editing_corner_id: bpy.props.IntProperty(
|
||||
default=0,
|
||||
options={"SKIP_SAVE"},
|
||||
description=(
|
||||
"IFC element id of an existing fillet corner being re-edited "
|
||||
"(non-zero only on the pen-icon re-edit flow). The create "
|
||||
"operator deletes this corner + its connections before recreating "
|
||||
"with the new radius."
|
||||
),
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
is_active: bool
|
||||
wall_a_id: int
|
||||
wall_b_id: int
|
||||
radius: float
|
||||
editing_corner_id: int
|
||||
|
||||
|
||||
class BIMPreviewProperties(PropertyGroup):
|
||||
"""Umbrella for parametric-edit preview drafts attached to ``Scene``."""
|
||||
|
||||
bend: bpy.props.PointerProperty(type=BIMBendPreviewProperties)
|
||||
wall_fillet: bpy.props.PointerProperty(type=BIMWallFilletPreviewProperties)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
bend: BIMBendPreviewProperties
|
||||
wall_fillet: BIMWallFilletPreviewProperties
|
||||
|
||||
|
||||
class BIMParametricEditDialogPrefs(PropertyGroup):
|
||||
"""Session-scoped flag for the parametric-edit pen-icon dispatcher.
|
||||
|
||||
Attached to ``WindowManager`` so the state lives for one Blender session
|
||||
and resets on restart — the right scope for "don't show this again for
|
||||
this session" toggles."""
|
||||
|
||||
suppress_shared_rep_warning: bpy.props.BoolProperty(
|
||||
name="Suppress shared-representation warning",
|
||||
description=(
|
||||
"When true, the pen-icon dispatcher skips the shared-geometry "
|
||||
"confirmation dialog. Resets on Blender restart."
|
||||
),
|
||||
default=False,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
suppress_shared_rep_warning: bool
|
||||
|
||||
@@ -415,7 +415,7 @@ class _RailingEditMixin(PathPreservingEditMixin):
|
||||
|
||||
@classmethod
|
||||
def _is_element_type(cls, element):
|
||||
return tool.Parametric.is_railing(element)
|
||||
return tool.Blender.Modifier.is_railing(element)
|
||||
|
||||
@classmethod
|
||||
def _get_props(cls, obj: bpy.types.Object):
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import json
|
||||
from math import atan2, cos, degrees, pi, radians, tan
|
||||
from typing import Any, ClassVar, Literal, Union
|
||||
from math import cos, pi, radians, tan
|
||||
from typing import Any, Literal, Union
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
@@ -32,11 +32,9 @@ 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, 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
|
||||
from bonsai.bim.parametric_lifecycle import PathPreservingEditMixin
|
||||
|
||||
# reference:
|
||||
# https://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcRoof.htm
|
||||
@@ -213,13 +211,7 @@ def generate_hipped_roof_bmesh(
|
||||
|
||||
new_verts = [bm.verts.new(v) for v in verts]
|
||||
new_edges = [bm.edges.new([new_verts[vi] for vi in edge]) for edge in edges]
|
||||
# Skip degenerate faces. ``bpypolyskel.polygonize`` can emit a face whose
|
||||
# vertex list contains the same index twice on certain footprint /
|
||||
# slope combinations (the straight-skeleton collapses two ridge events
|
||||
# onto the same vertex). ``bm.faces.new`` rejects those with
|
||||
# ``found the same (BMVert) used multiple times``; dropping them keeps
|
||||
# the rest of the roof intact instead of aborting the whole rebuild.
|
||||
new_faces = [bm.faces.new([new_verts[vi] for vi in face]) for face in faces if len(set(face)) == len(face)]
|
||||
new_faces = [bm.faces.new([new_verts[vi] for vi in face]) for face in faces]
|
||||
|
||||
if mode == "HEIGHT": # Calculate the angle we ended up with.
|
||||
new_faces[0].normal_update()
|
||||
@@ -405,11 +397,6 @@ def generate_hipped_roof_bmesh(
|
||||
if is_internal:
|
||||
faces_to_delete.add(face)
|
||||
bmesh.ops.delete(bm, geom=list(faces_to_delete), context="FACES")
|
||||
# Final pass: ``remove_doubles`` + internal-face deletion above can leave
|
||||
# the bottom slab faces flipped at low slopes, where the kernel's
|
||||
# "outward" inference becomes ambiguous on near-flat geometry. Recompute
|
||||
# once more on the final topology so the eave plane points down.
|
||||
bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:])
|
||||
return bm
|
||||
|
||||
|
||||
@@ -631,7 +618,7 @@ class _RoofEditMixin(PathPreservingEditMixin):
|
||||
|
||||
@classmethod
|
||||
def _is_element_type(cls, element):
|
||||
return tool.Parametric.is_roof(element)
|
||||
return tool.Blender.Modifier.is_roof(element)
|
||||
|
||||
@classmethod
|
||||
def _get_props(cls, obj: bpy.types.Object):
|
||||
@@ -649,142 +636,32 @@ class _RoofEditMixin(PathPreservingEditMixin):
|
||||
def _update_modifier_bmesh(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
|
||||
update_roof_modifier_bmesh(obj)
|
||||
|
||||
@classmethod
|
||||
def _restore_viewport_after_cancel(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
|
||||
"""Rebuild the roof bmesh from the just-restored draft props so the
|
||||
viewport reverts to the pre-edit geometry. Same helper the modal
|
||||
edits use, just driven by the cancelled props instead of in-flight
|
||||
drag values."""
|
||||
update_roof_modifier_bmesh(obj)
|
||||
|
||||
|
||||
EnableEditingRoof, FinishEditingRoof, CancelEditingRoof = tool.Parametric.build_edit_lifecycle(
|
||||
"roof",
|
||||
_RoofEditMixin,
|
||||
labels=(
|
||||
("Enable Editing Roof", ""),
|
||||
("Finish Editing Roof", ""),
|
||||
("Cancel Editing Roof", ""),
|
||||
),
|
||||
module_name=__name__,
|
||||
)
|
||||
|
||||
|
||||
# Fixed horizontal run for the slope gizmo: the draggable value is the
|
||||
# vertical rise at this distance from the anchor, in the rise/run convention.
|
||||
_ROOF_SLOPE_REFERENCE_RUN = 1.0
|
||||
# One degree shy of vertical; avoids tan() blow-up when the user drags the
|
||||
# rise handle past the gizmo's anchor.
|
||||
_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."""
|
||||
|
||||
bl_idname = "bim.cycle_roof_generation_method"
|
||||
bl_label = "Cycle Roof Generation Method"
|
||||
class EnableEditingRoof(_RoofEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.enable_editing_roof"
|
||||
bl_label = "Enable Editing Roof"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
element_checker = tool.Parametric.is_roof
|
||||
props_getter = tool.Model.get_roof_props
|
||||
type_literal = tool.Model.RoofGenerationMethod
|
||||
type_attr = "generation_method"
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
return self._cycle_type(context)
|
||||
def _execute(self, context):
|
||||
return self._enable_targets(context)
|
||||
|
||||
|
||||
class GizmoRoofEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
bl_idname = "OBJECT_GGT_bim_roof_edition"
|
||||
bl_label = "Roof Editing Gizmo"
|
||||
bl_space_type = "VIEW_3D"
|
||||
bl_region_type = "WINDOW"
|
||||
bl_options = {"3D", "PERSISTENT"}
|
||||
class CancelEditingRoof(_RoofEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.cancel_editing_roof"
|
||||
bl_label = "Cancel Editing Roof"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
enable_editing_operator = "bim.enable_editing_roof"
|
||||
finish_editing_operator = "bim.finish_editing_roof"
|
||||
cancel_editing_operator = "bim.cancel_editing_roof"
|
||||
cycle_type_operator = "bim.cycle_roof_generation_method"
|
||||
def _execute(self, context):
|
||||
return self._cancel_targets(context)
|
||||
|
||||
# Positions for all three dimensions are set per-frame by the position
|
||||
# override below; no static ``matrix_position`` is needed.
|
||||
dimension_gizmo_props = [
|
||||
DimensionGizmoConfig(
|
||||
attr_name="height",
|
||||
axis=(0, 0, 1),
|
||||
min_value=0.01,
|
||||
visibility_condition=lambda p: p.generation_method == "HEIGHT",
|
||||
),
|
||||
DimensionGizmoConfig(
|
||||
attr_name="angle",
|
||||
axis=(0, 0, 1),
|
||||
prop_name="Slope",
|
||||
min_value=0.0,
|
||||
visibility_condition=lambda p: p.generation_method == "ANGLE",
|
||||
compute_value=lambda p: tan(p.angle) * _ROOF_SLOPE_REFERENCE_RUN,
|
||||
apply_value=lambda p, rise: setattr(
|
||||
p, "angle", min(_ROOF_MAX_SLOPE_ANGLE, max(0.0, atan2(rise, _ROOF_SLOPE_REFERENCE_RUN)))
|
||||
),
|
||||
text_formatter=lambda p, rise: (f"{tool.Unit.format_distance(rise)} ({degrees(p.angle):.1f}°)"),
|
||||
),
|
||||
DimensionGizmoConfig(
|
||||
attr_name="roof_thickness",
|
||||
axis=(0, 0, -1),
|
||||
min_value=0.001,
|
||||
# The line shows the perpendicular slab thickness (matching the
|
||||
# pset value and the drag delta); the true vertical span is
|
||||
# ``roof_thickness / cos(angle)``, longer than what is drawn.
|
||||
),
|
||||
]
|
||||
|
||||
props_getter = tool.Model.get_roof_props
|
||||
gizmo_pref_name = "roof"
|
||||
class FinishEditingRoof(_RoofEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.finish_editing_roof"
|
||||
bl_label = "Finish Editing Roof"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
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)
|
||||
|
||||
def _update_dimension_gizmo_positions(self, context: bpy.types.Context, mw, props) -> None: # noqa: ARG002
|
||||
"""Anchor every dimension gizmo at the object origin. Each gizmo's
|
||||
declared axis (height/slope along +Z, thickness along -Z) separates
|
||||
them in 3D so they don't visually collide despite sharing a
|
||||
position; the height + slope gizmos themselves are mutually
|
||||
exclusive via ``visibility_condition`` on ``generation_method``."""
|
||||
origin = Vector((0.0, 0.0, 0.0))
|
||||
self.set_dimension_gizmo_position("height", mw, origin, (0, 0, 1))
|
||||
self.set_dimension_gizmo_position("angle", mw, origin, (0, 0, 1))
|
||||
self.set_dimension_gizmo_position("roof_thickness", mw, origin, (0, 0, -1))
|
||||
|
||||
def get_element_height(self, props) -> float: # noqa: ARG002
|
||||
"""Object-local Z of the mesh's topmost vertex, so the pen / validate /
|
||||
cancel / cycle row anchors visibly above sloped or stepped roof
|
||||
bodies rather than at the parametric ``props.height`` which may not
|
||||
match the rendered apex on ANGLE-generation roofs."""
|
||||
obj = bpy.context.active_object
|
||||
if obj is None or not getattr(obj, "bound_box", None):
|
||||
return 1.0
|
||||
return max(c[2] for c in obj.bound_box)
|
||||
def _execute(self, context):
|
||||
return self._finish_targets(context)
|
||||
|
||||
|
||||
class EnableEditingRoofPath(bpy.types.Operator, tool.Ifc.Operator):
|
||||
|
||||
@@ -31,13 +31,7 @@ from mathutils import Matrix, 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 (
|
||||
COLOR_GREEN,
|
||||
COLOR_RED,
|
||||
DimensionGizmoConfig,
|
||||
IconSlot,
|
||||
)
|
||||
from bonsai.bim.parametric_lifecycle import IntegerInputDialogMixin, PickTypeMixin
|
||||
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
|
||||
from bonsai.tool.numeric_input import (
|
||||
IntegerInputState,
|
||||
run_integer_input_modal,
|
||||
@@ -45,7 +39,7 @@ from bonsai.tool.numeric_input import (
|
||||
)
|
||||
|
||||
V_ = tool.Blender.V_
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bmesh.types import BMVert
|
||||
from bpy.props import IntProperty
|
||||
@@ -384,20 +378,6 @@ class AdjustStairTreads(bpy.types.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class InputStairTreads(IntegerInputDialogMixin, bpy.types.Operator):
|
||||
"""Popup-dialog entry point for typing a new ``number_of_treads`` value.
|
||||
Bound to the world-space ``xN`` count label in the stair edit row."""
|
||||
|
||||
bl_idname = "bim.input_stair_treads"
|
||||
bl_label = "Set Number of Treads"
|
||||
bl_description = "Type the number of treads for this stair"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
number_of_treads: IntProperty(name="Number of Treads", default=1, min=1)
|
||||
attr_name = "number_of_treads"
|
||||
props_getter = staticmethod(tool.Model.get_stair_props)
|
||||
|
||||
|
||||
class SetStairTreads(bpy.types.Operator):
|
||||
"""Set the number of treads to a specific value."""
|
||||
|
||||
@@ -443,20 +423,20 @@ class SetStairTreads(bpy.types.Operator):
|
||||
return f"Number of Treads: {input_str}_{validity} | Enter to confirm, Esc to cancel"
|
||||
|
||||
|
||||
class PickStairType(bpy.types.Operator, PickTypeMixin):
|
||||
"""Pick a stair type from a popup menu."""
|
||||
class CycleStairType(bpy.types.Operator, gizmo.CycleTypeMixin):
|
||||
"""Cycle through stair types. Shift+click to cycle in reverse."""
|
||||
|
||||
bl_idname = "bim.pick_stair_type"
|
||||
bl_label = "Pick Stair Type"
|
||||
bl_idname = "bim.cycle_stair_type"
|
||||
bl_label = "Cycle Stair Type"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
props_getter = tool.Model.get_stair_props
|
||||
props_getter = "get_stair_props"
|
||||
type_literal = tool.Model.StairType
|
||||
type_attr = "stair_type"
|
||||
skip_element_check = True
|
||||
|
||||
def execute(self, context: bpy.types.Context) -> set[str]:
|
||||
return self._pick_type(context)
|
||||
return self._cycle_type(context)
|
||||
|
||||
|
||||
# Tread run accessors - callbacks that delegate to BIMStairProperties methods
|
||||
@@ -482,47 +462,20 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
bl_region_type = "WINDOW"
|
||||
bl_options = {"3D", "PERSISTENT"}
|
||||
|
||||
# === Stair-Specific Icon Layout ===
|
||||
# Row order: [Validate] [Cancel] [Cycle] [TreadLock] [xN] [Plus] [Minus]
|
||||
# The base class assigns X positions from ``feature_slots`` tuple order —
|
||||
# adding an icon is a one-line append, no hardcoded X constant.
|
||||
# === Stair-Specific Icon Layout (meters) ===
|
||||
# Additional icons for stair editing, positioned after standard icons:
|
||||
# [Validate] [Cancel] [Cycle] [TreadLock] [Plus] [Minus]
|
||||
ICON_TREAD_LOCK_X = 1.24 # X position for tread lock toggle icon
|
||||
ICON_PLUS_X = 1.61 # X position for add tread (+) icon
|
||||
ICON_MINUS_X = 1.98 # X position for remove tread (-) icon
|
||||
ICON_PLUS_MINUS_SCALE = 0.24 # Scale for plus/minus icons (slightly larger)
|
||||
ICON_CYCLE_SCALE = 0.3 # Scale for cycle type icon
|
||||
ICON_COUNT_LABEL_SCALE = 0.36 # Scale for the xN tread-count label
|
||||
ICON_Z_OFFSET = 0.5 # Z offset above geometry for editing icons
|
||||
|
||||
feature_slots: ClassVar[tuple[IconSlot, ...]] = (
|
||||
IconSlot(
|
||||
name="tread_lock",
|
||||
gizmo_idname="VIEW3D_GT_lock",
|
||||
variants=("open", "closed"),
|
||||
operator="bim.toggle_stair_property",
|
||||
color=(1.0, 1.0, 1.0),
|
||||
operator_props=(("property_name", "custom_tread_lock"),),
|
||||
),
|
||||
IconSlot(name="tread_count_label", placeholder=True),
|
||||
IconSlot(
|
||||
name="plus",
|
||||
gizmo_idname="VIEW3D_GT_plus",
|
||||
operator="bim.adjust_stair_treads",
|
||||
scale=ICON_PLUS_MINUS_SCALE,
|
||||
color=COLOR_GREEN,
|
||||
operator_props=(("increment", 1),),
|
||||
),
|
||||
IconSlot(
|
||||
name="minus",
|
||||
gizmo_idname="VIEW3D_GT_minus",
|
||||
operator="bim.adjust_stair_treads",
|
||||
scale=ICON_PLUS_MINUS_SCALE,
|
||||
color=COLOR_RED,
|
||||
operator_props=(("increment", -1),),
|
||||
),
|
||||
)
|
||||
|
||||
enable_editing_operator = "bim.enable_editing_stair"
|
||||
finish_editing_operator = "bim.finish_editing_stair"
|
||||
cancel_editing_operator = "bim.cancel_editing_stair"
|
||||
pick_type_operator = "bim.pick_stair_type"
|
||||
cycle_type_operator = "bim.cycle_stair_type"
|
||||
|
||||
def get_icon_y_extent(self, props: "BIMStairProperties") -> tuple[float, float]:
|
||||
"""Get Y extents for stair icon positioning.
|
||||
@@ -627,36 +580,35 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
]
|
||||
|
||||
# Metadata-driven dispatch for props and preferences
|
||||
props_getter = tool.Model.get_stair_props
|
||||
props_getter = "get_stair_props"
|
||||
gizmo_pref_name = "stair"
|
||||
|
||||
@classmethod
|
||||
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
return tool.Parametric.is_stair(element)
|
||||
return tool.Blender.Modifier.is_stair(element)
|
||||
|
||||
def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None:
|
||||
"""Create the total-length lock as an open/closed pair plus the
|
||||
``xN`` tread-count label. Lock click toggles
|
||||
``props.total_length_lock``; the per-frame update hook picks which
|
||||
member is visible. Anchored to the stair's far X end (not the edit
|
||||
row) so it's positioned by ``_update_lock_gizmo_position`` rather
|
||||
than the toolbar slot system.
|
||||
|
||||
The count label binds to ``bim.input_stair_treads`` (popup dialog)
|
||||
for click-to-type input and sits at the X reserved by the
|
||||
``tread_count_label`` placeholder slot in ``feature_slots``."""
|
||||
self.total_length_lock_open_gizmo, self.total_length_lock_closed_gizmo = self.create_icon_gizmo_lock_pair(
|
||||
"bim.toggle_stair_property",
|
||||
"""Create stair-specific icon gizmos (lock, plus, minus)."""
|
||||
self.lock_gizmo = self.create_icon_gizmo(
|
||||
"VIEW3D_GT_lock",
|
||||
self.COLOR_BLUE,
|
||||
"bim.toggle_stair_property",
|
||||
prop_path="BIMStairProperties.total_length_lock",
|
||||
property_name="total_length_lock",
|
||||
)
|
||||
default_color, highlight_color = self.get_decoration_colors()
|
||||
self.tread_count_label_gizmo = self.gizmos.new("BIM_GT_count_label")
|
||||
self.tread_count_label_gizmo.use_draw_scale = False
|
||||
self.tread_count_label_gizmo.color = default_color
|
||||
self.tread_count_label_gizmo.color_highlight = highlight_color
|
||||
self.tread_count_label_gizmo.alpha = 0.8
|
||||
self.tread_count_label_gizmo.target_set_operator("bim.input_stair_treads")
|
||||
self.tread_lock_gizmo = self.create_icon_gizmo(
|
||||
"VIEW3D_GT_lock",
|
||||
(1.0, 1.0, 1.0),
|
||||
"bim.toggle_stair_property",
|
||||
prop_path="BIMStairProperties.custom_tread_lock",
|
||||
property_name="custom_tread_lock",
|
||||
)
|
||||
self.plus_gizmo = self.create_icon_gizmo(
|
||||
"VIEW3D_GT_plus", self.COLOR_GREEN, "bim.adjust_stair_treads", increment=1
|
||||
)
|
||||
self.minus_gizmo = self.create_icon_gizmo(
|
||||
"VIEW3D_GT_minus", self.COLOR_RED, "bim.adjust_stair_treads", increment=-1
|
||||
)
|
||||
|
||||
def _refresh_element_specific(
|
||||
self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties" # noqa: ARG002
|
||||
@@ -668,43 +620,30 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
self.update_tread_count_gizmos(props)
|
||||
|
||||
def update_lock_gizmo(self, props: "BIMStairProperties") -> None:
|
||||
"""Show the open/closed total-length lock variant matching
|
||||
``props.total_length_lock``. Positioning is handled per-frame by
|
||||
the dimension-positioning hook."""
|
||||
if not hasattr(self, "total_length_lock_open_gizmo"):
|
||||
return
|
||||
if not props.is_editing:
|
||||
self.total_length_lock_open_gizmo.hide = True
|
||||
self.total_length_lock_closed_gizmo.hide = True
|
||||
return
|
||||
self.total_length_lock_open_gizmo.hide = props.total_length_lock
|
||||
self.total_length_lock_closed_gizmo.hide = not props.total_length_lock
|
||||
"""Update lock gizmo color and visibility. Positioning is handled
|
||||
per-frame by the dimension-positioning hook."""
|
||||
gizmo_prefs = self.get_gizmo_prefs()
|
||||
if not self.update_gizmo_visibility(self.lock_gizmo, props.is_editing, gizmo_prefs.lock):
|
||||
return # Hidden, skip color update
|
||||
self.lock_gizmo.color = self.COLOR_RED if props.total_length_lock else self.COLOR_GREEN
|
||||
|
||||
def update_tread_lock_gizmo(self, props: "BIMStairProperties") -> None:
|
||||
"""Show the open/closed lock variant matching ``props.custom_tread_lock``.
|
||||
|
||||
Both pair members share an X position (set by the base's slot
|
||||
positioning); this picks which one is visible per frame so a state
|
||||
flip can't reveal both at once."""
|
||||
if not hasattr(self, "tread_lock_open_gizmo"):
|
||||
"""Update visibility of tread lock gizmo. Positioning is handled in _update_editing_icon_positions."""
|
||||
if not hasattr(self, "tread_lock_gizmo"):
|
||||
return
|
||||
if not props.is_editing:
|
||||
self.tread_lock_open_gizmo.hide = True
|
||||
self.tread_lock_closed_gizmo.hide = True
|
||||
return
|
||||
self.tread_lock_open_gizmo.hide = props.custom_tread_lock
|
||||
self.tread_lock_closed_gizmo.hide = not props.custom_tread_lock
|
||||
gizmo_prefs = self.get_gizmo_prefs()
|
||||
self.update_gizmo_visibility(self.tread_lock_gizmo, props.is_editing, gizmo_prefs.lock)
|
||||
|
||||
def update_tread_count_gizmos(self, props: "BIMStairProperties") -> None:
|
||||
"""Update visibility of the +/- tread count gizmos and the ``xN``
|
||||
label. Positioning is handled in ``_update_editing_icon_positions``."""
|
||||
"""Update visibility of +/- tread count gizmos. Positioning is handled in _update_editing_icon_positions."""
|
||||
if not hasattr(self, "plus_gizmo") or not hasattr(self, "minus_gizmo"):
|
||||
return
|
||||
self.update_gizmo_visibility(self.plus_gizmo, props.is_editing)
|
||||
gizmo_prefs = self.get_gizmo_prefs()
|
||||
self.update_gizmo_visibility(self.plus_gizmo, props.is_editing, gizmo_prefs.plus)
|
||||
# Minus has additional condition: number_of_treads > 1
|
||||
self.update_gizmo_visibility(self.minus_gizmo, props.is_editing and props.number_of_treads > 1)
|
||||
if hasattr(self, "tread_count_label_gizmo"):
|
||||
self.update_gizmo_visibility(self.tread_count_label_gizmo, props.is_editing)
|
||||
self.update_gizmo_visibility(
|
||||
self.minus_gizmo, props.is_editing and props.number_of_treads > 1, gizmo_prefs.minus
|
||||
)
|
||||
|
||||
def _update_dimension_gizmo_positions(
|
||||
self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties" # noqa: ARG002
|
||||
@@ -782,12 +721,10 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
billboard_rot: Matrix,
|
||||
total_run: float,
|
||||
) -> None:
|
||||
"""Update lock gizmo pair position based on Y view direction. Writes
|
||||
the matrix on both members so a state flip can't reveal a stale pose."""
|
||||
"""Update lock gizmo position based on Y view direction."""
|
||||
y_pos = self.get_y_position_for_view(props, viewing_from_negative_y, use_offset=True)
|
||||
self.set_icon_gizmo_pair_position(
|
||||
"total_length_lock_open_gizmo",
|
||||
"total_length_lock_closed_gizmo",
|
||||
self.set_icon_gizmo_position(
|
||||
"lock_gizmo",
|
||||
mw,
|
||||
total_run + self.ICON_Z_OFFSET,
|
||||
y_pos,
|
||||
@@ -799,47 +736,30 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
def _update_editing_icon_positions(
|
||||
self, mw: Matrix, props: "BIMStairProperties", viewing_from_negative_y: bool, billboard_rot: Matrix
|
||||
) -> None:
|
||||
"""Reposition the editing icons at stair's view-dependent Y. The base
|
||||
class's update_editing_gizmos already placed them at the default
|
||||
``get_icon_y_offset`` Y — this overrides with the stair-specific
|
||||
``get_icon_y_for_view`` flip so the icons land on the side the
|
||||
camera is looking from."""
|
||||
"""Update editing icon positions, flipping Y based on viewing angle."""
|
||||
if not props.is_editing:
|
||||
return
|
||||
|
||||
icon_z = props.height + self.ICON_Z_OFFSET
|
||||
y_pos = self.get_icon_y_for_view(props, viewing_from_negative_y)
|
||||
slot_x = self._slot_x_positions()
|
||||
|
||||
self.set_icon_gizmo_position("validate_gizmo", mw, 0, y_pos, icon_z, billboard_rot)
|
||||
self.set_icon_gizmo_position("cancel_gizmo", mw, self.ICON_CANCEL_X, y_pos, icon_z, billboard_rot)
|
||||
self.set_icon_gizmo_position(
|
||||
"cycle_gizmo", mw, self.ICON_CYCLE_X, y_pos, icon_z, billboard_rot, scale=self.ICON_CYCLE_SCALE
|
||||
)
|
||||
self.set_icon_gizmo_pair_position(
|
||||
"tread_lock_open_gizmo",
|
||||
"tread_lock_closed_gizmo",
|
||||
self.set_icon_gizmo_position(
|
||||
"tread_lock_gizmo",
|
||||
mw,
|
||||
slot_x["tread_lock"],
|
||||
self.ICON_TREAD_LOCK_X,
|
||||
y_pos,
|
||||
icon_z - self.EDITING_ICON_SCALE / 2,
|
||||
billboard_rot,
|
||||
scale=self.EDITING_ICON_SCALE,
|
||||
)
|
||||
self.set_icon_gizmo_position(
|
||||
"plus_gizmo", mw, slot_x["plus"], y_pos, icon_z, billboard_rot, scale=self.ICON_PLUS_MINUS_SCALE
|
||||
"plus_gizmo", mw, self.ICON_PLUS_X, y_pos, icon_z, billboard_rot, scale=self.ICON_PLUS_MINUS_SCALE
|
||||
)
|
||||
self.set_icon_gizmo_position(
|
||||
"minus_gizmo", mw, slot_x["minus"], y_pos, icon_z, billboard_rot, scale=self.ICON_PLUS_MINUS_SCALE
|
||||
"minus_gizmo", mw, self.ICON_MINUS_X, y_pos, icon_z, billboard_rot, scale=self.ICON_PLUS_MINUS_SCALE
|
||||
)
|
||||
if hasattr(self, "tread_count_label_gizmo"):
|
||||
self.tread_count_label_gizmo.set_count(int(props.number_of_treads))
|
||||
self.set_icon_gizmo_position(
|
||||
"tread_count_label_gizmo",
|
||||
mw,
|
||||
slot_x["tread_count_label"],
|
||||
y_pos,
|
||||
icon_z,
|
||||
billboard_rot,
|
||||
scale=self.ICON_COUNT_LABEL_SCALE,
|
||||
)
|
||||
|
||||
@@ -238,11 +238,11 @@ class BIM_PT_array(bpy.types.Panel):
|
||||
|
||||
for i, array in enumerate(ArrayData.data["parameters"]["data_dict"]):
|
||||
box = self.layout.box()
|
||||
if props.editing_item_index == i:
|
||||
if props.is_editing == i:
|
||||
row = box.row(align=True)
|
||||
row.prop(props, "count", icon="MOD_ARRAY")
|
||||
row.operator("bim.finish_editing_array", icon="CHECKMARK", text="")
|
||||
row.operator("bim.cancel_editing_array", icon="CANCEL", text="")
|
||||
row.operator("bim.edit_array", icon="CHECKMARK", text="").item = i
|
||||
row.operator("bim.disable_editing_array", icon="CANCEL", text="")
|
||||
row = box.row(align=True)
|
||||
row.prop(props, "method")
|
||||
row = box.row(align=True)
|
||||
@@ -365,7 +365,7 @@ class BIM_PT_wall(bpy.types.Panel):
|
||||
if not obj:
|
||||
return False
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
return bool(element) and tool.Parametric.is_wall(element)
|
||||
return bool(element) and tool.Blender.Modifier.is_wall(element)
|
||||
|
||||
def draw(self, context):
|
||||
obj = context.active_object
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,279 +0,0 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Four wall-offset dimension gizmos (left / right / top / bottom) shared by door and
|
||||
window edit gizmo groups — both fillings sit in a LAYER2 wall and the offset math is
|
||||
identical.
|
||||
|
||||
The compute side returns a *signed* value on the X axis (negative when the filling
|
||||
is 180°-flipped onto the wall's opposite face) so the gizmo framework auto-flips
|
||||
the rendered arrow; the apply side takes ``abs(value)`` because the user-facing
|
||||
offset is always positive. Z-axis values are unsigned in both directions.
|
||||
|
||||
Fillings are assumed to align with the wall's local X axis to within ±90° — the
|
||||
parametric door/window construction path enforces this, and the X-sign math
|
||||
falls back to +1 if ``col[0].x`` lands on the ambiguous zero (filling rotated
|
||||
exactly 90° in the wall plane).
|
||||
|
||||
Every public entry point falls back to a safe no-op when the host-wall chain
|
||||
cannot be resolved: reads return 0.0, writes do nothing, and gizmo anchors
|
||||
return a filling-relative position. This keeps the gizmos non-crashing when a
|
||||
filling momentarily loses its host (e.g. mid-edit, partially-loaded files).
|
||||
|
||||
``_GEOM_CACHE`` is module-scoped and persists across tests — tests must call
|
||||
``clear_caches()`` between cases."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, NamedTuple, Protocol
|
||||
|
||||
from mathutils import Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import bpy
|
||||
|
||||
|
||||
class FillingProps(Protocol):
|
||||
"""Structural subset of door/window props this module touches."""
|
||||
|
||||
id_data: bpy.types.Object
|
||||
overall_width: float
|
||||
overall_height: float
|
||||
|
||||
|
||||
# Wall-local frame axis indices. Y (depth) is unused — fillings sit on the wall's centreline.
|
||||
_AXIS_X = 0
|
||||
_AXIS_Z = 2
|
||||
|
||||
|
||||
class _HostWallGeom(NamedTuple):
|
||||
"""Cached host-wall geometry in SI metres, wall-local frame. ``height`` is
|
||||
the vertical projection (already accounts for slanted extrusions)."""
|
||||
|
||||
wall_obj: bpy.types.Object
|
||||
height: float
|
||||
axis_min_x: float
|
||||
axis_max_x: float
|
||||
|
||||
|
||||
class _AxisExtent(NamedTuple):
|
||||
"""``[low, high]`` interval on one wall-local axis; low = near end.
|
||||
|
||||
``x_sign`` is +1 / -1 for an X-axis filling extent only (carries the
|
||||
180° auto-flip); always 1.0 elsewhere."""
|
||||
|
||||
low: float
|
||||
high: float
|
||||
x_sign: float = 1.0
|
||||
|
||||
|
||||
class _Edge(NamedTuple):
|
||||
"""Wall edge a gizmo measures to. ``is_max_end=True`` picks right/top, else left/bottom."""
|
||||
|
||||
axis_index: int
|
||||
is_max_end: bool
|
||||
|
||||
|
||||
_LEFT = _Edge(axis_index=_AXIS_X, is_max_end=False)
|
||||
_RIGHT = _Edge(axis_index=_AXIS_X, is_max_end=True)
|
||||
_BOTTOM = _Edge(axis_index=_AXIS_Z, is_max_end=False)
|
||||
_TOP = _Edge(axis_index=_AXIS_Z, is_max_end=True)
|
||||
|
||||
|
||||
# Avoids repeating the host-wall chain walk + LAYER2 geometry read per gizmo per frame.
|
||||
_GEOM_CACHE = tool.Parametric.GenerationKeyedCache()
|
||||
|
||||
|
||||
def clear_caches() -> None:
|
||||
_GEOM_CACHE.clear()
|
||||
|
||||
|
||||
def _host_wall_geom(filling_obj: bpy.types.Object) -> _HostWallGeom | None:
|
||||
"""Cached host-wall geometry for a filling, or ``None`` if any link in
|
||||
filling → opening → wall → LAYER2 extrusion → scene-object resolution breaks."""
|
||||
return _GEOM_CACHE.get_or_compute(filling_obj.name, lambda: _compute_host_wall_geom(filling_obj))
|
||||
|
||||
|
||||
def _compute_host_wall_geom(filling_obj: bpy.types.Object) -> _HostWallGeom | None:
|
||||
element = tool.Ifc.get_entity(filling_obj)
|
||||
if not element:
|
||||
return None
|
||||
host_wall = tool.Spatial.get_host_wall(element)
|
||||
if not host_wall:
|
||||
return None
|
||||
wall_obj = tool.Ifc.get_object(host_wall)
|
||||
length_height = tool.Wall.get_length_and_height(host_wall)
|
||||
axis_extent = tool.Wall.get_axis_local_extent(host_wall)
|
||||
# x_angle is None for non-LAYER2 walls — gates entry; the value itself is unused.
|
||||
if not (wall_obj and length_height and axis_extent and tool.Wall.get_x_angle(host_wall) is not None):
|
||||
return None
|
||||
_, height = length_height
|
||||
axis_min_x, axis_max_x = axis_extent
|
||||
return _HostWallGeom(wall_obj=wall_obj, height=height, axis_min_x=axis_min_x, axis_max_x=axis_max_x)
|
||||
|
||||
|
||||
def _filling_axis_extent(props: FillingProps, host_wall_obj: bpy.types.Object, axis_index: int) -> _AxisExtent:
|
||||
"""Filling footprint on the wall's local axis.
|
||||
|
||||
X-axis extent carries the filling's orientation sign (180° flip onto
|
||||
the opposite face) in ``x_sign``."""
|
||||
filling_in_wall = host_wall_obj.matrix_world.inverted() @ props.id_data.matrix_world
|
||||
origin = filling_in_wall.translation[axis_index]
|
||||
if axis_index == _AXIS_X:
|
||||
# col[0].x is the X-component of the filling's local X axis in the wall-local frame:
|
||||
# +1 when filling's +X aligns with wall's +X, -1 after a 180° Z-flip.
|
||||
x_sign = 1.0 if filling_in_wall.col[0].x >= 0.0 else -1.0
|
||||
signed_width = x_sign * props.overall_width
|
||||
return _AxisExtent(origin + min(0.0, signed_width), origin + max(0.0, signed_width), x_sign)
|
||||
return _AxisExtent(origin, origin + props.overall_height)
|
||||
|
||||
|
||||
def _wall_axis_extent(geom: _HostWallGeom, axis_index: int) -> _AxisExtent:
|
||||
"""Wall span on one local axis: X = IFC axis-line endpoints (not mesh bound-box,
|
||||
which drifts on trimmed walls); Z = 0 → wall height."""
|
||||
if axis_index == _AXIS_X:
|
||||
return _AxisExtent(geom.axis_min_x, geom.axis_max_x)
|
||||
return _AxisExtent(0.0, geom.height)
|
||||
|
||||
|
||||
def _offset_from_extents(filling: _AxisExtent, wall: _AxisExtent, is_max_end: bool) -> float:
|
||||
"""Distance from the wall edge to the filling's matching edge on the same axis."""
|
||||
if is_max_end:
|
||||
return wall.high - filling.high
|
||||
return filling.low - wall.low
|
||||
|
||||
|
||||
def _translate_along_wall_axis(
|
||||
props: FillingProps, host_wall_obj: bpy.types.Object, delta: float, axis_index: int
|
||||
) -> None:
|
||||
"""Shift the filling by ``delta`` SI metres along the wall's local axis. Drag
|
||||
operates in the filling's intent frame, not Blender's world frame, so a rotated
|
||||
host wall still tracks correctly."""
|
||||
if delta == 0.0:
|
||||
return
|
||||
direction_world = host_wall_obj.matrix_world.to_3x3().col[axis_index].normalized()
|
||||
props.id_data.matrix_world.translation = props.id_data.matrix_world.translation + direction_world * delta
|
||||
|
||||
|
||||
def _get_offset(props: FillingProps, edge: _Edge) -> float:
|
||||
"""SI distance from the wall edge to the filling's matching edge on the same axis."""
|
||||
geom = _host_wall_geom(props.id_data)
|
||||
if not geom:
|
||||
return 0.0
|
||||
filling = _filling_axis_extent(props, geom.wall_obj, edge.axis_index)
|
||||
wall = _wall_axis_extent(geom, edge.axis_index)
|
||||
return _offset_from_extents(filling, wall, edge.is_max_end)
|
||||
|
||||
|
||||
def _set_offset(props: FillingProps, edge: _Edge, value: float) -> None:
|
||||
"""Translate the filling so its offset to ``edge`` becomes ``max(0, value)`` SI metres.
|
||||
Max-end edges (right/top) translate in the opposite direction of near-end edges."""
|
||||
geom = _host_wall_geom(props.id_data)
|
||||
if not geom:
|
||||
return
|
||||
current = _get_offset(props, edge)
|
||||
target = max(0.0, value)
|
||||
delta = (current - target) if edge.is_max_end else (target - current)
|
||||
_translate_along_wall_axis(props, geom.wall_obj, delta, edge.axis_index)
|
||||
|
||||
|
||||
def has_host_wall(props: FillingProps) -> bool:
|
||||
"""True when the filling resolves to a LAYER2 host wall present in the scene."""
|
||||
return _host_wall_geom(props.id_data) is not None
|
||||
|
||||
|
||||
def _edge_position(props: FillingProps, edge: _Edge) -> Vector:
|
||||
"""Gizmo anchor in filling-local space, at the wall edge, pointing toward the filling."""
|
||||
geom = _host_wall_geom(props.id_data)
|
||||
if not geom:
|
||||
if edge.axis_index == _AXIS_X:
|
||||
return Vector((0.0, 0.0, props.overall_height / 2))
|
||||
return Vector((props.overall_width / 2, 0.0, props.overall_height if edge.is_max_end else 0.0))
|
||||
wall = _wall_axis_extent(geom, edge.axis_index)
|
||||
edge_value = wall.high if edge.is_max_end else wall.low
|
||||
if edge.axis_index == _AXIS_X:
|
||||
wall_edge_world = geom.wall_obj.matrix_world @ Vector((edge_value, 0.0, 0.0))
|
||||
pos = props.id_data.matrix_world.inverted() @ wall_edge_world
|
||||
return Vector((pos.x, 0.0, props.overall_height / 2))
|
||||
# LAYER2 wall matrix_world is upright, so wall-local Z and filling-local Z differ
|
||||
# only by the filling's Z origin in the wall frame.
|
||||
filling_z_in_wall = _filling_axis_extent(props, geom.wall_obj, axis_index=_AXIS_Z).low
|
||||
return Vector((props.overall_width / 2, 0.0, edge_value - filling_z_in_wall))
|
||||
|
||||
|
||||
def _compute_value(props: FillingProps, edge: _Edge) -> float:
|
||||
"""Renderer-side value. X-axis edges return a signed value so the gizmo's
|
||||
auto-flip kicks in for fillings on the wall's opposite face; Z-axis returns unsigned."""
|
||||
geom = _host_wall_geom(props.id_data)
|
||||
if not geom:
|
||||
return 0.0
|
||||
filling = _filling_axis_extent(props, geom.wall_obj, edge.axis_index)
|
||||
wall = _wall_axis_extent(geom, edge.axis_index)
|
||||
return filling.x_sign * _offset_from_extents(filling, wall, edge.is_max_end)
|
||||
|
||||
|
||||
def _apply_value(props: FillingProps, edge: _Edge, value: float) -> None:
|
||||
"""Drag-end commit; X-axis takes ``abs(value)`` since the negative sign in compute
|
||||
is a rendering hint only (user-facing offset is always positive)."""
|
||||
if edge.axis_index == _AXIS_X:
|
||||
_set_offset(props, edge, abs(value))
|
||||
else:
|
||||
_set_offset(props, edge, value)
|
||||
|
||||
|
||||
# attr_name identifies the gizmo within its group; values flow through
|
||||
# compute/apply, not via a registered property.
|
||||
WALL_OFFSET_GIZMO_CONFIGS: list[DimensionGizmoConfig] = [
|
||||
DimensionGizmoConfig(
|
||||
attr_name="host_wall_offset_left",
|
||||
axis=(1, 0, 0),
|
||||
visibility_condition=has_host_wall,
|
||||
compute_value=lambda p: _compute_value(p, _LEFT),
|
||||
apply_value=lambda p, v: _apply_value(p, _LEFT, v),
|
||||
matrix_position=lambda p: _edge_position(p, _LEFT),
|
||||
),
|
||||
DimensionGizmoConfig(
|
||||
attr_name="host_wall_offset_right",
|
||||
axis=(-1, 0, 0),
|
||||
visibility_condition=has_host_wall,
|
||||
compute_value=lambda p: _compute_value(p, _RIGHT),
|
||||
apply_value=lambda p, v: _apply_value(p, _RIGHT, v),
|
||||
matrix_position=lambda p: _edge_position(p, _RIGHT),
|
||||
),
|
||||
DimensionGizmoConfig(
|
||||
attr_name="host_wall_offset_bottom",
|
||||
axis=(0, 0, 1),
|
||||
visibility_condition=has_host_wall,
|
||||
compute_value=lambda p: _compute_value(p, _BOTTOM),
|
||||
apply_value=lambda p, v: _apply_value(p, _BOTTOM, v),
|
||||
matrix_position=lambda p: _edge_position(p, _BOTTOM),
|
||||
),
|
||||
DimensionGizmoConfig(
|
||||
attr_name="host_wall_offset_top",
|
||||
axis=(0, 0, -1),
|
||||
visibility_condition=has_host_wall,
|
||||
compute_value=lambda p: _compute_value(p, _TOP),
|
||||
apply_value=lambda p, v: _apply_value(p, _TOP, v),
|
||||
matrix_position=lambda p: _edge_position(p, _TOP),
|
||||
),
|
||||
]
|
||||
@@ -39,8 +39,7 @@ 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.model.wall_offset_gizmos import WALL_OFFSET_GIZMO_CONFIGS
|
||||
from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin, PickTypeMixin
|
||||
from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bonsai.bim.module.model.prop import BIMWindowProperties
|
||||
@@ -492,7 +491,7 @@ class _WindowEditMixin(FeatureModifierEditMixin):
|
||||
|
||||
@classmethod
|
||||
def _is_element_type(cls, element):
|
||||
return tool.Parametric.is_window(element)
|
||||
return tool.Blender.Modifier.is_window(element)
|
||||
|
||||
@classmethod
|
||||
def _get_props(cls, obj: bpy.types.Object):
|
||||
@@ -552,20 +551,20 @@ class RemoveWindow(bpy.types.Operator, tool.Ifc.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class PickWindowType(bpy.types.Operator, tool.Ifc.Operator, PickTypeMixin):
|
||||
"""Pick a window type from a popup menu."""
|
||||
class CycleWindowType(bpy.types.Operator, tool.Ifc.Operator, gizmo.CycleTypeMixin):
|
||||
"""Cycle through available window types. Shift+click to cycle in reverse."""
|
||||
|
||||
bl_idname = "bim.pick_window_type"
|
||||
bl_label = "Pick Window Type"
|
||||
bl_idname = "bim.cycle_window_type"
|
||||
bl_label = "Cycle Window Type"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
element_checker = tool.Parametric.is_window
|
||||
props_getter = tool.Model.get_window_props
|
||||
element_checker = "is_window"
|
||||
props_getter = "get_window_props"
|
||||
type_literal = tool.Model.WindowType
|
||||
type_attr = "window_type"
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
return self._pick_type(context)
|
||||
return self._cycle_type(context)
|
||||
|
||||
|
||||
# Frame accessor factory - creates callbacks that delegate to BIMWindowProperties methods
|
||||
@@ -603,7 +602,7 @@ class GizmoWindowEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
enable_editing_operator = "bim.enable_editing_window"
|
||||
finish_editing_operator = "bim.finish_editing_window"
|
||||
cancel_editing_operator = "bim.cancel_editing_window"
|
||||
pick_type_operator = "bim.pick_window_type"
|
||||
cycle_type_operator = "bim.cycle_window_type"
|
||||
|
||||
# matrix_position lambdas replace the get_dimension_matrix_* methods
|
||||
dimension_gizmo_props = [
|
||||
@@ -744,15 +743,14 @@ class GizmoWindowEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
),
|
||||
# lining_offset is handled specially in _update_dimension_gizmo_positions due to negative value support
|
||||
DimensionGizmoConfig(attr_name="lining_offset", axis=(0, 1, 0), min_value=-10.0),
|
||||
*WALL_OFFSET_GIZMO_CONFIGS,
|
||||
]
|
||||
|
||||
props_getter = tool.Model.get_window_props
|
||||
props_getter = "get_window_props"
|
||||
gizmo_pref_name = "window"
|
||||
|
||||
@classmethod
|
||||
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
return tool.Parametric.is_window(element)
|
||||
return tool.Blender.Modifier.is_window(element)
|
||||
|
||||
def get_icon_y_extent(self, props: "BIMWindowProperties") -> tuple[float, float]:
|
||||
"""Get Y extents for window icon positioning.
|
||||
|
||||
@@ -17,12 +17,18 @@
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import os
|
||||
import bmesh
|
||||
import gpu
|
||||
import numpy as np
|
||||
from functools import partial
|
||||
from typing import Optional, Union
|
||||
|
||||
import bpy
|
||||
import bpy.utils.previews
|
||||
from bpy.types import Menu, WorkSpaceTool
|
||||
from bpy_extras import view3d_utils
|
||||
from gpu_extras.batch import batch_for_shader
|
||||
from mathutils import Vector
|
||||
|
||||
import bonsai.core.model as core
|
||||
import bonsai.tool as tool
|
||||
@@ -969,9 +975,7 @@ class EditObjectUI:
|
||||
|
||||
if PortData.data["total_ports"] > 0:
|
||||
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
|
||||
add_layout_hotkey_operator(
|
||||
row, "Regen", "S_G", bpy.ops.bim.regenerate_distribution_element.__doc__, ui_context
|
||||
)
|
||||
add_layout_hotkey_operator(row, "Regen", "S_G", bpy.ops.bim.regenerate_distribution_element.__doc__, ui_context)
|
||||
|
||||
@classmethod
|
||||
def draw_void(cls, context, row):
|
||||
@@ -1296,15 +1300,9 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bpy.ops.bim.generate_space()
|
||||
return
|
||||
if self.active_material_usage == "LAYER2":
|
||||
if element and tool.Model.has_underside_connection(element):
|
||||
bpy.ops.bim.regenerate_wall_to_underside()
|
||||
else:
|
||||
bpy.ops.bim.recalculate_wall()
|
||||
bpy.ops.bim.recalculate_wall()
|
||||
elif self.active_material_usage == "LAYER3":
|
||||
bpy.ops.bim.recalculate_slab()
|
||||
wall_objs = tool.Model.get_connected_wall_objs(element)
|
||||
if wall_objs:
|
||||
core.regenerate_wall_to_underside(tool.Ifc, tool.Geometry, tool.Model, wall_objs)
|
||||
elif tool.System.get_ports(element):
|
||||
bpy.ops.bim.regenerate_distribution_element()
|
||||
elif self.active_material_usage == "PROFILE":
|
||||
@@ -1444,7 +1442,10 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bpy.ops.bim.enable_editing_extrusion_axis()
|
||||
|
||||
def hotkey_A_O(self):
|
||||
bpy.ops.bim.toggle_host_openings()
|
||||
if tool.Model.get_model_props().openings:
|
||||
bpy.ops.bim.edit_openings(apply_all=True)
|
||||
else:
|
||||
bpy.ops.bim.show_openings()
|
||||
|
||||
def hotkey_C_E(self):
|
||||
if not bpy.context.selected_objects:
|
||||
@@ -1493,5 +1494,442 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
|
||||
)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# CAD/Rhino-style box selection ("Cross Select").
|
||||
#
|
||||
# The selection logic below is ported from the GPL-3.0 "Blender Cross Select"
|
||||
# add-on by RARA, CYX, Witty.Ming and Shuimeng
|
||||
# (https://github.com/theoryshaw/Blender-Cross-Select), reduced to box-only mode
|
||||
# and adapted to read its settings from Bonsai's add-on preferences. It is wired
|
||||
# into every Bonsai tool via ``tool.Blender.get_default_selection_keypmap()``.
|
||||
#
|
||||
# Dragging the box left-to-right performs a "window" selection (only elements
|
||||
# fully enclosed by the box are selected); dragging right-to-left performs a
|
||||
# "crossing" selection (anything the box touches is selected).
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
def _cs_is_point_in_rect(point: tuple[float, float], rect: tuple[tuple[float, float], tuple[float, float]]) -> bool:
|
||||
x, y = point
|
||||
return rect[0][0] <= x <= rect[1][0] and rect[0][1] <= y <= rect[1][1]
|
||||
|
||||
|
||||
def _cs_ccw(a, b, c) -> bool:
|
||||
return (c[1] - a[1]) * (b[0] - a[0]) > (b[1] - a[1]) * (c[0] - a[0])
|
||||
|
||||
|
||||
def _cs_segments_intersect(a, b, c, d) -> bool:
|
||||
return _cs_ccw(a, c, d) != _cs_ccw(b, c, d) and _cs_ccw(a, b, c) != _cs_ccw(a, b, d)
|
||||
|
||||
|
||||
def _cs_is_segment_intersecting_rect(p1, p2, rect) -> bool:
|
||||
min_x, min_y = rect[0]
|
||||
max_x, max_y = rect[1]
|
||||
if max(p1.x, p2.x) < min_x or min(p1.x, p2.x) > max_x or max(p1.y, p2.y) < min_y or min(p1.y, p2.y) > max_y:
|
||||
return False
|
||||
r1, r2, r3, r4 = (min_x, min_y), (max_x, min_y), (max_x, max_y), (min_x, max_y)
|
||||
line_start, line_end = (p1.x, p1.y), (p2.x, p2.y)
|
||||
return (
|
||||
_cs_segments_intersect(line_start, line_end, r1, r2)
|
||||
or _cs_segments_intersect(line_start, line_end, r2, r3)
|
||||
or _cs_segments_intersect(line_start, line_end, r3, r4)
|
||||
or _cs_segments_intersect(line_start, line_end, r4, r1)
|
||||
)
|
||||
|
||||
|
||||
def _cs_make_3d_to_region_2d(verts, matrix_world, region, region_data, use_smart_sampling=True) -> list[Vector]:
|
||||
"""Batch-project a sequence of 3D vertices to 2D region coordinates using numpy."""
|
||||
vlen = len(verts)
|
||||
if vlen == 0:
|
||||
return []
|
||||
perspective_matrix = region_data.perspective_matrix
|
||||
if use_smart_sampling:
|
||||
step = max(1, 1 + ((vlen - 1) // 1000) * 10)
|
||||
coords_3d = np.array([verts[i].co.to_tuple() for i in range(0, vlen, step)])
|
||||
else:
|
||||
coords_3d = np.array([v.co.to_tuple() for v in verts])
|
||||
coords_3d = np.hstack((coords_3d, np.ones((coords_3d.shape[0], 1))))
|
||||
coords_3d = np.dot(coords_3d, np.array(matrix_world.transposed()))
|
||||
coords_2d = np.dot(coords_3d, np.array(perspective_matrix.transposed()))
|
||||
coords_2d /= coords_2d[:, 3].reshape(-1, 1)
|
||||
coords_2d[:, 0] = (coords_2d[:, 0] + 1) * 0.5 * region.width
|
||||
coords_2d[:, 1] = (coords_2d[:, 1] + 1) * 0.5 * region.height
|
||||
return [Vector((co[0], co[1])) for co in coords_2d]
|
||||
|
||||
|
||||
def _cs_get_sampled_coords(obj, context) -> list[Vector]:
|
||||
if obj.type == "MESH" and obj.data.vertices:
|
||||
return _cs_make_3d_to_region_2d(
|
||||
obj.data.vertices, obj.matrix_world, context.region, context.region_data, use_smart_sampling=True
|
||||
)
|
||||
co = view3d_utils.location_3d_to_region_2d(context.region, context.region_data, obj.matrix_world @ Vector((0, 0, 0)))
|
||||
return [co] if co else []
|
||||
|
||||
|
||||
def _cs_is_object_in_rect(obj, context, rect) -> bool:
|
||||
"""True when every sampled vertex of ``obj`` projects inside ``rect`` (window match)."""
|
||||
coords = _cs_get_sampled_coords(obj, context)
|
||||
if not coords:
|
||||
return False
|
||||
return all(_cs_is_point_in_rect((co.x, co.y), rect) for co in coords)
|
||||
|
||||
|
||||
def _cs_draw_callback_px(operator, context) -> None:
|
||||
if not operator.is_dragging:
|
||||
return
|
||||
cs_prefs = operator.cs_prefs
|
||||
try:
|
||||
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
|
||||
except Exception:
|
||||
shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR")
|
||||
|
||||
gpu.state.blend_set("ALPHA")
|
||||
gpu.state.line_width_set(cs_prefs.line_width)
|
||||
|
||||
if not operator.enabled:
|
||||
# Disabled: selection is native, so draw a neutral (non-directional) box.
|
||||
color = (1.0, 1.0, 1.0, 1.0)
|
||||
line_style = "DASHED"
|
||||
elif operator.select_mode == "FULLY":
|
||||
color = (*cs_prefs.fully_color, 1.0)
|
||||
line_style = "SOLID"
|
||||
else:
|
||||
color = (*cs_prefs.partial_color, 1.0)
|
||||
line_style = "DASHED"
|
||||
|
||||
min_x, max_x, min_y, max_y = operator.box_path
|
||||
vertices = (
|
||||
(min_x, min_y),
|
||||
(max_x, min_y),
|
||||
(max_x, max_y),
|
||||
(min_x, max_y),
|
||||
(min_x, min_y),
|
||||
)
|
||||
shader.bind()
|
||||
shader.uniform_float("color", color)
|
||||
if line_style == "SOLID":
|
||||
batch_for_shader(shader, "LINE_STRIP", {"pos": vertices}).draw(shader)
|
||||
else:
|
||||
dash_length, gap_length = 10, 5
|
||||
dash_vertices = []
|
||||
for i in range(len(vertices) - 1):
|
||||
start = Vector(vertices[i])
|
||||
end = Vector(vertices[i + 1])
|
||||
line_dir = (end - start).normalized()
|
||||
line_length = (end - start).length
|
||||
num_dashes = int(line_length // (dash_length + gap_length))
|
||||
for j in range(num_dashes):
|
||||
dash_start = start + line_dir * (j * (dash_length + gap_length))
|
||||
dash_end = dash_start + line_dir * dash_length
|
||||
if (dash_end - start).length > line_length:
|
||||
dash_end = end
|
||||
dash_vertices.extend([dash_start, dash_end])
|
||||
remaining = line_length - num_dashes * (dash_length + gap_length)
|
||||
if remaining > 0:
|
||||
dash_start = start + line_dir * (num_dashes * (dash_length + gap_length))
|
||||
dash_end = dash_start + line_dir * min(dash_length, remaining)
|
||||
dash_vertices.extend([dash_start, dash_end])
|
||||
if dash_vertices:
|
||||
batch_for_shader(shader, "LINES", {"pos": dash_vertices}).draw(shader)
|
||||
|
||||
gpu.state.line_width_set(1.0)
|
||||
gpu.state.blend_set("NONE")
|
||||
|
||||
|
||||
class CrossSelect(bpy.types.Operator):
|
||||
bl_idname = "bim.cross_select"
|
||||
bl_label = "Cross Select"
|
||||
bl_options = {"REGISTER", "BLOCKING", "UNDO"}
|
||||
bl_description = (
|
||||
"CAD/Rhino-style box selection.\n\n"
|
||||
"Drag left to right: window (only fully-enclosed elements)\n"
|
||||
"Drag right to left: crossing (anything the box touches)\n\n"
|
||||
"Shift: add to selection\nCtrl: subtract from selection"
|
||||
)
|
||||
|
||||
DRAG_THRESHOLD = 5
|
||||
|
||||
def invoke(self, context, event):
|
||||
# Yield to ClickNearestDimensionAnchor when a parametric dimension dot
|
||||
# is near the cursor. Blender fires all matching tool-keymap entries'
|
||||
# invoke() even after an earlier entry returned RUNNING_MODAL, so without
|
||||
# this check our BLOCKING modal would win over click_nearest's modal and
|
||||
# prevent the dot from turning blue.
|
||||
if self._near_dimension_dot(context, event):
|
||||
return {"PASS_THROUGH"}
|
||||
|
||||
self.cs_prefs = tool.Blender.get_addon_preferences().cross_select
|
||||
self.enabled = self.cs_prefs.enabled
|
||||
self.start_mouse = (event.mouse_region_x, event.mouse_region_y)
|
||||
self.end_mouse = self.start_mouse
|
||||
self.is_dragging = False
|
||||
self.select_mode = "FULLY" # "FULLY" (window) or "HALF" (crossing)
|
||||
self.operation = "SET"
|
||||
self.draw_handle = None
|
||||
self.box_path = (0, 0, 0, 0) # (min_x, max_x, min_y, max_y)
|
||||
context.window_manager.modal_handler_add(self)
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
@staticmethod
|
||||
def _near_dimension_dot(context, event) -> bool:
|
||||
"""Return True if the click is within 15 px of a parametric dimension anchor dot."""
|
||||
try:
|
||||
import ifcopenshell.util.element as _ue
|
||||
from bpy_extras.view3d_utils import location_3d_to_region_2d
|
||||
|
||||
region = next(
|
||||
(r for a in context.screen.areas if a.type == "VIEW_3D" for r in a.regions if r.type == "WINDOW"),
|
||||
None,
|
||||
)
|
||||
rv3d = next(
|
||||
(s.region_3d for a in context.screen.areas if a.type == "VIEW_3D" for s in a.spaces if s.type == "VIEW_3D"),
|
||||
None,
|
||||
)
|
||||
if not region or not rv3d:
|
||||
return False
|
||||
|
||||
cx = event.mouse_x - region.x
|
||||
cy = event.mouse_y - region.y
|
||||
r2 = 15 ** 2
|
||||
|
||||
for obj in context.scene.objects:
|
||||
if obj.type != "CURVE" or not obj.visible_get():
|
||||
continue
|
||||
elem = tool.Ifc.get_entity(obj) if tool.Ifc.get() else None
|
||||
if not elem or not elem.is_a("IfcAnnotation"):
|
||||
continue
|
||||
pset = _ue.get_pset(elem, "BBIM_Dimension")
|
||||
if not pset or not pset.get("Anchors"):
|
||||
continue
|
||||
if not obj.data.splines:
|
||||
continue
|
||||
for pt in obj.data.splines[0].points:
|
||||
sp = location_3d_to_region_2d(region, rv3d, obj.matrix_world @ pt.co.to_3d())
|
||||
if sp and (cx - sp.x) ** 2 + (cy - sp.y) ** 2 < r2:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
def modal(self, context, event):
|
||||
if event.shift:
|
||||
self.operation = "ADD"
|
||||
elif event.ctrl:
|
||||
self.operation = "SUB"
|
||||
else:
|
||||
self.operation = "SET"
|
||||
|
||||
if event.type == "MOUSEMOVE":
|
||||
if not self.is_dragging:
|
||||
delta = Vector((event.mouse_region_x, event.mouse_region_y)) - Vector(self.start_mouse)
|
||||
if delta.length > self.DRAG_THRESHOLD:
|
||||
self._start_dragging(context)
|
||||
if self.is_dragging:
|
||||
self._update_drag_position(event)
|
||||
context.area.tag_redraw()
|
||||
|
||||
if event.type == "LEFTMOUSE" and event.value == "RELEASE":
|
||||
if self.is_dragging:
|
||||
self._finish_box_select(context)
|
||||
else:
|
||||
self._handle_single_click(context, event)
|
||||
return {"FINISHED"}
|
||||
|
||||
if event.type in {"RIGHTMOUSE", "ESC"}:
|
||||
self._cleanup_drawing()
|
||||
context.area.tag_redraw()
|
||||
return {"CANCELLED"}
|
||||
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
def _start_dragging(self, context):
|
||||
self.is_dragging = True
|
||||
self.draw_handle = bpy.types.SpaceView3D.draw_handler_add(
|
||||
_cs_draw_callback_px, (self, context), "WINDOW", "POST_PIXEL"
|
||||
)
|
||||
context.area.tag_redraw()
|
||||
|
||||
def _update_drag_position(self, event):
|
||||
self.end_mouse = (event.mouse_region_x, event.mouse_region_y)
|
||||
# Dragging rightward selects only fully-enclosed elements (window),
|
||||
# dragging leftward selects anything touched (crossing).
|
||||
self.select_mode = "FULLY" if self.end_mouse[0] > self.start_mouse[0] else "HALF"
|
||||
x1, y1 = self.start_mouse
|
||||
x2, y2 = self.end_mouse
|
||||
self.box_path = (min(x1, x2), max(x1, x2), min(y1, y2), max(y1, y2))
|
||||
|
||||
def _cleanup_drawing(self):
|
||||
if self.draw_handle:
|
||||
bpy.types.SpaceView3D.draw_handler_remove(self.draw_handle, "WINDOW")
|
||||
self.draw_handle = None
|
||||
|
||||
def _finish_box_select(self, context):
|
||||
self._cleanup_drawing()
|
||||
min_x, max_x, min_y, max_y = self.box_path
|
||||
|
||||
# Cross-select disabled: reproduce Blender's native box selection.
|
||||
if not self.enabled:
|
||||
bpy.ops.view3d.select_box(
|
||||
wait_for_input=False, xmin=min_x, xmax=max_x, ymin=min_y, ymax=max_y, mode=self.operation
|
||||
)
|
||||
context.area.tag_redraw()
|
||||
return
|
||||
|
||||
if context.mode == "OBJECT":
|
||||
self._process_selection_object(context)
|
||||
elif context.mode == "EDIT_MESH":
|
||||
self._process_selection_edit(context)
|
||||
context.area.tag_redraw()
|
||||
|
||||
# --- Object mode ---------------------------------------------------------
|
||||
def _process_selection_object(self, context):
|
||||
original_selection = set(context.selected_objects)
|
||||
|
||||
# Use the native box select to collect everything the box touches.
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
min_x, max_x, min_y, max_y = self.box_path
|
||||
bpy.ops.view3d.select_box(wait_for_input=False, xmin=min_x, xmax=max_x, ymin=min_y, ymax=max_y, mode="ADD")
|
||||
touched_objects = set(context.selected_objects)
|
||||
|
||||
# Filter touched objects by the window/crossing rule.
|
||||
rect = ((min_x, min_y), (max_x, max_y))
|
||||
if self.select_mode == "HALF":
|
||||
valid_selection = touched_objects
|
||||
else:
|
||||
valid_selection = {obj for obj in touched_objects if _cs_is_object_in_rect(obj, context, rect)}
|
||||
|
||||
if self.operation == "SET":
|
||||
final_selected = valid_selection
|
||||
elif self.operation == "ADD":
|
||||
final_selected = original_selection | valid_selection
|
||||
else: # SUB
|
||||
final_selected = original_selection - valid_selection
|
||||
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
for obj in final_selected:
|
||||
obj.select_set(True)
|
||||
|
||||
# --- Edit mesh mode ------------------------------------------------------
|
||||
def _process_selection_edit(self, context):
|
||||
if hasattr(context, "objects_in_mode_unique_data"):
|
||||
edit_objs = [obj for obj in context.objects_in_mode_unique_data if obj.type == "MESH"]
|
||||
elif hasattr(context, "objects_in_mode"):
|
||||
edit_objs = [obj for obj in context.objects_in_mode if obj.type == "MESH"]
|
||||
else:
|
||||
obj = context.edit_object
|
||||
edit_objs = [obj] if obj and obj.type == "MESH" else []
|
||||
if not edit_objs:
|
||||
return
|
||||
|
||||
is_vert, is_edge, is_face = context.tool_settings.mesh_select_mode
|
||||
min_x, max_x, min_y, max_y = self.box_path
|
||||
|
||||
# Vertex mode (and crossing face mode) match Blender's native box select.
|
||||
if is_vert or (is_face and self.select_mode == "HALF"):
|
||||
bpy.ops.view3d.select_box(
|
||||
wait_for_input=False, xmin=min_x, xmax=max_x, ymin=min_y, ymax=max_y, mode=self.operation
|
||||
)
|
||||
return
|
||||
|
||||
element_key = "edges" if is_edge else "faces"
|
||||
bm_cache = {}
|
||||
original = {}
|
||||
for obj in edit_objs:
|
||||
bm = bmesh.from_edit_mesh(obj.data)
|
||||
bm.verts.ensure_lookup_table()
|
||||
bm.edges.ensure_lookup_table()
|
||||
bm.faces.ensure_lookup_table()
|
||||
bm_cache[obj] = bm
|
||||
elems = bm.edges if is_edge else bm.faces
|
||||
original[obj] = {ele for ele in elems if ele.select}
|
||||
|
||||
# Collect every element the box touches via native box select on faces+edges.
|
||||
bpy.ops.mesh.select_all(action="DESELECT")
|
||||
for temp_select_mode in ((False, False, True), (False, True, False)):
|
||||
context.tool_settings.mesh_select_mode = temp_select_mode
|
||||
bpy.ops.view3d.select_box(wait_for_input=False, xmin=min_x, xmax=max_x, ymin=min_y, ymax=max_y, mode="ADD")
|
||||
context.tool_settings.mesh_select_mode = (is_vert, is_edge, is_face)
|
||||
|
||||
rect = ((min_x, min_y), (max_x, max_y))
|
||||
valid = {}
|
||||
for obj, bm in bm_cache.items():
|
||||
elems = bm.edges if is_edge else bm.faces
|
||||
touched = {ele for ele in elems if ele.select}
|
||||
valid[obj] = {ele for ele in touched if self._is_element_valid(context, obj, rect, ele, element_key)}
|
||||
|
||||
final = {}
|
||||
for obj in edit_objs:
|
||||
if self.operation == "SET":
|
||||
final[obj] = valid[obj]
|
||||
elif self.operation == "ADD":
|
||||
final[obj] = original[obj] | valid[obj]
|
||||
else: # SUB
|
||||
final[obj] = original[obj] - valid[obj]
|
||||
|
||||
bpy.ops.mesh.select_all(action="DESELECT")
|
||||
if is_edge:
|
||||
for obj, bm in bm_cache.items():
|
||||
for e in final[obj]:
|
||||
e.select = True
|
||||
for f in e.link_faces:
|
||||
if f.select:
|
||||
continue
|
||||
if all(edge in final[obj] for edge in f.edges):
|
||||
f.select = True
|
||||
bmesh.update_edit_mesh(obj.data)
|
||||
else:
|
||||
for obj, bm in bm_cache.items():
|
||||
for f in final[obj]:
|
||||
f.select = True
|
||||
bmesh.update_edit_mesh(obj.data)
|
||||
|
||||
def _is_element_valid(self, context, obj, rect, ele, ele_type):
|
||||
region = context.region
|
||||
rv3d = context.region_data
|
||||
matrix = obj.matrix_world
|
||||
vert_2d = []
|
||||
all_inside = True
|
||||
any_inside = False
|
||||
for v in ele.verts:
|
||||
co_2d = view3d_utils.location_3d_to_region_2d(region, rv3d, matrix @ v.co)
|
||||
if not co_2d:
|
||||
all_inside = False
|
||||
continue
|
||||
vert_2d.append(co_2d)
|
||||
if _cs_is_point_in_rect((co_2d.x, co_2d.y), rect):
|
||||
any_inside = True
|
||||
else:
|
||||
all_inside = False
|
||||
|
||||
if self.select_mode == "FULLY":
|
||||
return all_inside
|
||||
if any_inside:
|
||||
return True
|
||||
# Crossing: element edges intersecting the box border also count.
|
||||
n = len(vert_2d)
|
||||
if ele_type == "edges":
|
||||
return n == 2 and _cs_is_segment_intersecting_rect(vert_2d[0], vert_2d[1], rect)
|
||||
for i in range(n):
|
||||
if _cs_is_segment_intersecting_rect(vert_2d[i], vert_2d[(i + 1) % n], rect):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _handle_single_click(self, context, event):
|
||||
# A plain click uses Blender's native pick selection. ``deselect_all`` clears
|
||||
# the selection when clicking empty space (in both Object and Edit Mesh modes),
|
||||
# matching Blender's default selection tool; Shift toggles and Ctrl deselects.
|
||||
try:
|
||||
bpy.ops.view3d.select(
|
||||
"INVOKE_DEFAULT",
|
||||
extend=event.shift,
|
||||
deselect=event.ctrl,
|
||||
toggle=False,
|
||||
deselect_all=not (event.shift or event.ctrl),
|
||||
location=(event.mouse_region_x, event.mouse_region_y),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
custom_icon_previews = None
|
||||
display_mode = None
|
||||
|
||||
@@ -28,10 +28,6 @@ classes = (
|
||||
operator.AppendLibraryElementByQuery,
|
||||
operator.AssignLibraryDeclaration,
|
||||
operator.BIM_FH_import_ifc,
|
||||
operator.BIM_OT_apply_pending_opening_cuts,
|
||||
operator.BIM_OT_dismiss_multi_instance_warning,
|
||||
operator.BIM_OT_dismiss_pending_opening_cuts,
|
||||
operator.BIM_OT_select_pending_opening_cuts,
|
||||
operator.BIM_OT_load_clipping_planes,
|
||||
operator.BIM_OT_save_clipping_planes,
|
||||
operator.ChangeLibraryElement,
|
||||
@@ -86,7 +82,6 @@ classes = (
|
||||
prop.FilterCategory,
|
||||
prop.Link,
|
||||
prop.EditedObj,
|
||||
prop.PendingOpeningRecut,
|
||||
prop.BIMProjectProperties,
|
||||
prop.MeasureToolSettings,
|
||||
ui.BIM_MT_new_project,
|
||||
|
||||
@@ -63,7 +63,6 @@ import bonsai.core.project as core
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim import export_ifc, import_ifc
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
from bonsai.bim.module.model import preview_base
|
||||
from bonsai.bim.module.model.decorator import FaceAreaDecorator, PolylineDecorator
|
||||
from bonsai.bim.module.model.polyline import PolylineOperator
|
||||
from bonsai.bim.module.project.data import LinksData, ProjectLibraryData
|
||||
@@ -1223,19 +1222,6 @@ class LoadProjectElements(bpy.types.Operator):
|
||||
props = tool.Project.get_project_props()
|
||||
props.is_loading = False
|
||||
|
||||
# Stash elements the kernel skipped opening cuts on (HasOpenings > void_limit).
|
||||
# The Project panel banner offers the user a one-click recut.
|
||||
props.pending_opening_recut.clear()
|
||||
if ifc_importer.gross_elements:
|
||||
for element in ifc_importer.gross_elements:
|
||||
item = props.pending_opening_recut.add()
|
||||
item.ifc_definition_id = element.id()
|
||||
self.report(
|
||||
{"WARNING"},
|
||||
f"{len(ifc_importer.gross_elements)} element(s) had too many openings and were loaded without cuts. "
|
||||
f"Apply manually from the Project panel.",
|
||||
)
|
||||
|
||||
tool.Project.load_default_thumbnails()
|
||||
tool.Project.set_default_context()
|
||||
tool.Project.set_default_modeling_dimensions()
|
||||
@@ -1950,10 +1936,6 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
|
||||
|
||||
def _execute(self, context):
|
||||
committed, failed_commits = tool.Parametric.commit_pending_edits()
|
||||
# Previews are session-transient — discard rather than commit. Sibling
|
||||
# gizmo polls gate on each preview's is_active flag, and a stuck flag
|
||||
# persisted through the save would silently hide them on reload.
|
||||
preview_base.discard_pending_previews(context.scene)
|
||||
# Suffix is appended to the IFC save-success report below so the auto-commit
|
||||
# info isn't immediately overwritten by the success message in Blender's
|
||||
# status bar (only the latest self.report({"INFO"}, ...) sticks).
|
||||
@@ -3434,108 +3416,3 @@ class GenerateUVMap(bpy.types.Operator):
|
||||
tool.Loader.load_generated_uv_map(obj.data)
|
||||
self.report({"INFO"}, "Generated UV map for selected mesh.")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class BIM_OT_apply_pending_opening_cuts(bpy.types.Operator, tool.Ifc.Operator):
|
||||
"""Recompute the wall mesh including opening subtractions for every host
|
||||
that the load-time ``void_limit`` filter skipped. Clears the deferred
|
||||
list on completion so the panel banner disappears."""
|
||||
|
||||
bl_idname = "bim.apply_pending_opening_cuts"
|
||||
bl_label = "Apply Pending Opening Cuts"
|
||||
bl_description = (
|
||||
"Recompute meshes for elements whose openings were skipped at load because they had too many openings"
|
||||
)
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
pending = tool.Project.get_project_props().pending_opening_recut
|
||||
applied = 0
|
||||
skipped = 0
|
||||
failed = 0
|
||||
for item in pending:
|
||||
try:
|
||||
element = tool.Ifc.get().by_id(item.ifc_definition_id)
|
||||
except RuntimeError:
|
||||
skipped += 1
|
||||
continue
|
||||
obj = tool.Ifc.get_object(element)
|
||||
if obj is None:
|
||||
skipped += 1
|
||||
continue
|
||||
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
|
||||
if body is None:
|
||||
skipped += 1
|
||||
continue
|
||||
try:
|
||||
tool.Geometry.reimport_element_representations(obj, body, apply_openings=True)
|
||||
applied += 1
|
||||
except (RuntimeError, OSError, AttributeError) as exc:
|
||||
# Programmer errors (TypeError, ValueError, etc.) must surface — don't swallow them.
|
||||
failed += 1
|
||||
print(f"apply_pending_opening_cuts: failed to recompute {element} ({exc})")
|
||||
|
||||
pending.clear()
|
||||
message = f"Applied opening cuts to {applied} element(s)."
|
||||
if skipped:
|
||||
message += f" {skipped} entry/entries skipped (entity or object no longer available)."
|
||||
if failed:
|
||||
message += f" {failed} entry/entries failed (see system console)."
|
||||
self.report({"WARNING"}, message)
|
||||
else:
|
||||
self.report({"INFO"}, message)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class BIM_OT_dismiss_pending_opening_cuts(bpy.types.Operator):
|
||||
bl_idname = "bim.dismiss_pending_opening_cuts"
|
||||
bl_label = "Dismiss Pending Opening Cuts"
|
||||
bl_description = "Clear the pending opening-cut list without applying it. Walls stay solid where openings would have been subtracted."
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context: bpy.types.Context) -> set[str]:
|
||||
tool.Project.get_project_props().pending_opening_recut.clear()
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class BIM_OT_dismiss_multi_instance_warning(bpy.types.Operator):
|
||||
bl_idname = "bim.dismiss_multi_instance_warning"
|
||||
bl_label = "Dismiss Multi-Instance Warning"
|
||||
bl_description = (
|
||||
"Hide the warning that another Blender instance has this IFC file open. Sticky for the current session."
|
||||
)
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
def execute(self, context: bpy.types.Context) -> set[str]:
|
||||
from bonsai.bim.ifc import dismiss_multi_instance_warning
|
||||
|
||||
dismiss_multi_instance_warning()
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class BIM_OT_select_pending_opening_cuts(bpy.types.Operator):
|
||||
bl_idname = "bim.select_pending_opening_cuts"
|
||||
bl_label = "Select Elements With Skipped Opening Cuts"
|
||||
bl_description = "Select the Blender objects whose openings were skipped at load. Useful for locating which elements need attention."
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context: bpy.types.Context) -> set[str]:
|
||||
ifc_file = tool.Ifc.get()
|
||||
if ifc_file is None:
|
||||
self.report({"INFO"}, "No IFC file loaded.")
|
||||
return {"CANCELLED"}
|
||||
objects: list[bpy.types.Object] = []
|
||||
for item in tool.Project.get_project_props().pending_opening_recut:
|
||||
try:
|
||||
element = ifc_file.by_id(item.ifc_definition_id)
|
||||
except RuntimeError:
|
||||
continue
|
||||
obj = tool.Ifc.get_object(element)
|
||||
if obj is not None:
|
||||
objects.append(obj)
|
||||
if not objects:
|
||||
self.report({"INFO"}, "No matching Blender objects found for the pending list.")
|
||||
return {"CANCELLED"}
|
||||
tool.Blender.set_objects_selection(context, active_object=objects[0], selected_objects=objects)
|
||||
self.report({"INFO"}, f"Selected {len(objects)} element(s).")
|
||||
return {"FINISHED"}
|
||||
|
||||
@@ -295,17 +295,6 @@ class LibraryBreadcrumb(PropertyGroup):
|
||||
library_id: int
|
||||
|
||||
|
||||
class PendingOpeningRecut(PropertyGroup):
|
||||
"""One element whose ``HasOpenings`` exceeded ``void_limit`` at load time
|
||||
and was imported without opening subtractions. The user can later apply
|
||||
them on demand from the Project panel banner."""
|
||||
|
||||
ifc_definition_id: IntProperty(name="IFC Definition ID")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
ifc_definition_id: int
|
||||
|
||||
|
||||
class BIMProjectProperties(PropertyGroup):
|
||||
is_editing: BoolProperty(name="Is Editing", default=False)
|
||||
is_loading: BoolProperty(name="Is Loading", default=False)
|
||||
@@ -371,7 +360,6 @@ class BIMProjectProperties(PropertyGroup):
|
||||
default=30,
|
||||
description="Maxium number of openings that object can have. If object has more openings, it will be loaded without openings",
|
||||
)
|
||||
pending_opening_recut: CollectionProperty(name="Pending Opening Recut", type=PendingOpeningRecut)
|
||||
style_limit: IntProperty(
|
||||
name="Style Limit",
|
||||
default=300,
|
||||
@@ -537,7 +525,6 @@ class BIMProjectProperties(PropertyGroup):
|
||||
deflection_tolerance: float
|
||||
angular_tolerance: float
|
||||
void_limit: int
|
||||
pending_opening_recut: bpy.types.bpy_prop_collection_idprop[PendingOpeningRecut]
|
||||
style_limit: int
|
||||
distance_limit: float
|
||||
false_origin_mode: Literal["AUTOMATIC", "MANUAL", "DISABLED"]
|
||||
|
||||
@@ -28,9 +28,8 @@ from bpy.types import Menu, Panel, UIList
|
||||
import bonsai.bim
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.helper import draw_attributes, prop_with_search
|
||||
from bonsai.bim.ifc import IfcStore, is_cache_locked_by_other_process
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
from bonsai.bim.module.project.data import LinksData, ProjectData
|
||||
from bonsai.bim.ui import draw_multiline_text
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bonsai.bim.module.project.prop import (
|
||||
@@ -167,20 +166,6 @@ class BIM_PT_project(Panel):
|
||||
if pprops.is_loading:
|
||||
self.draw_advanced_loading_ui(context)
|
||||
elif self.file or props.ifc_file:
|
||||
if is_cache_locked_by_other_process():
|
||||
box = self.layout.box()
|
||||
box.alert = True
|
||||
row = box.row(align=True)
|
||||
row.label(text="IFC Already Open in Another Blender Instance", icon="ERROR")
|
||||
row.operator("bim.dismiss_multi_instance_warning", text="", icon="CANCEL")
|
||||
draw_multiline_text(
|
||||
box.column(align=True),
|
||||
"This file is open in another Blender instance. Editing the same "
|
||||
"IFC from two instances at once can lose your work or display "
|
||||
"outdated geometry. Close the other Blender instances to continue safely.",
|
||||
context=context,
|
||||
)
|
||||
|
||||
if props.has_blend_warning:
|
||||
box = self.layout.box()
|
||||
box.alert = True
|
||||
@@ -190,21 +175,6 @@ class BIM_PT_project(Panel):
|
||||
op.uri = "https://docs.bonsaibim.org/guides/troubleshooting.html#saving-and-loading-blend-files"
|
||||
row.operator("bim.close_blend_warning", text="", icon="CANCEL")
|
||||
|
||||
if pending := pprops.pending_opening_recut:
|
||||
box = self.layout.box()
|
||||
box.alert = True
|
||||
box.label(text="Opening Cuts Skipped", icon="ERROR")
|
||||
draw_multiline_text(
|
||||
box.column(align=True),
|
||||
f"{len(pending)} element(s) had too many openings to cut during load. "
|
||||
f"Apply to recompute their meshes, or dismiss to leave them as they are.",
|
||||
context=context,
|
||||
)
|
||||
row = box.row(align=True)
|
||||
row.operator("bim.select_pending_opening_cuts", text="Select Elements", icon="RESTRICT_SELECT_OFF")
|
||||
row.operator("bim.apply_pending_opening_cuts", text="Apply Openings", icon="PLAY")
|
||||
row.operator("bim.dismiss_pending_opening_cuts", text="", icon="CANCEL")
|
||||
|
||||
if props.ifc_file:
|
||||
self.draw_loaded_project_ui(context)
|
||||
else:
|
||||
|
||||
@@ -15,10 +15,9 @@
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was modified with the assistance of an AI coding tool.
|
||||
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import gpu
|
||||
from bpy.app.handlers import persistent
|
||||
@@ -79,9 +78,15 @@ class SystemDecorator:
|
||||
batch.draw(shader)
|
||||
|
||||
def draw_faces(self, bm, vertices_coords):
|
||||
"""Submit a non-mutating beauty-triangulated TRIS batch over ``bm``'s faces."""
|
||||
"""mutates original bm (triangulates it)
|
||||
so the triangulation edges will be shown too
|
||||
"""
|
||||
traingulated_bm = bm
|
||||
bmesh.ops.triangulate(traingulated_bm, faces=traingulated_bm.faces)
|
||||
|
||||
face_indices = [[v.index for v in f.verts] for f in traingulated_bm.faces]
|
||||
faces_color = transparent_color(self.addon_prefs.decorator_color_special)
|
||||
tool.Blender.draw_bmesh_face_tris(bm, vertices_coords, faces_color, self.draw_batch)
|
||||
self.draw_batch("TRIS", vertices_coords, faces_color, face_indices)
|
||||
|
||||
def __call__(self, context, get_custom_bmesh=None, draw_faces=False, exit_edit_mode_callback=None):
|
||||
self.addon_prefs = tool.Blender.get_addon_preferences()
|
||||
|
||||
@@ -317,23 +317,13 @@ class MEPConnectElements(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_label = "Connect MEP Elements"
|
||||
bl_description = "Connects two selected elements by their closest located ports and adjusts them"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
obj1_guid: bpy.props.StringProperty(name="Object 1 GlobalId")
|
||||
obj2_guid: bpy.props.StringProperty(name="Object 2 GlobalId")
|
||||
obj1_name: bpy.props.StringProperty(name="Object 1")
|
||||
obj2_name: bpy.props.StringProperty(name="Object 2")
|
||||
|
||||
def _execute(self, context):
|
||||
if self.obj1_guid and self.obj2_guid:
|
||||
ifc_file = tool.Ifc.get()
|
||||
try:
|
||||
el1_lookup = ifc_file.by_guid(self.obj1_guid)
|
||||
el2_lookup = ifc_file.by_guid(self.obj2_guid)
|
||||
except RuntimeError:
|
||||
self.report({"ERROR"}, "Could not resolve MEP elements from supplied GlobalIds.")
|
||||
return {"CANCELLED"}
|
||||
obj1 = tool.Ifc.get_object(el1_lookup)
|
||||
obj2 = tool.Ifc.get_object(el2_lookup)
|
||||
if not obj1 or not obj2:
|
||||
self.report({"ERROR"}, "Supplied MEP elements have no Blender object bound.")
|
||||
return {"CANCELLED"}
|
||||
if self.obj1_name and self.obj2_name:
|
||||
obj1 = bpy.data.objects.get(self.obj1_name)
|
||||
obj2 = bpy.data.objects.get(self.obj2_name)
|
||||
else:
|
||||
if not context.selected_objects or len(context.selected_objects) != 2:
|
||||
self.report({"ERROR"}, "Need to select 2 objects.")
|
||||
|
||||
@@ -36,17 +36,9 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_description = (
|
||||
"Apply opening objects to an Element.\n\n"
|
||||
"The Element and the openings to be applied should be selected. The order of selection is not important.\n"
|
||||
"Opening can be just a Blender mesh object.\n\n"
|
||||
"Shift+click: keep the filling at its current matrix_world — skip the wall-axis snap "
|
||||
"and the rl1/rl2 Z-elevation default that the regular click applies."
|
||||
"Opening can be just a Blender mesh object."
|
||||
)
|
||||
|
||||
# Toggled by ``invoke`` when the user holds SHIFT during a gizmo / hotkey
|
||||
# click. The filling-opening generator gates its snap-to-wall-axis block
|
||||
# on this flag. HIDDEN + SKIP_SAVE so the flag doesn't surface in the F6
|
||||
# redo panel or persist into saved keymaps.
|
||||
preserve_placement: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"})
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
if len(context.selected_objects) < 2:
|
||||
@@ -54,10 +46,6 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
|
||||
return False
|
||||
return True
|
||||
|
||||
def invoke(self, context, event):
|
||||
self.preserve_placement = bool(event.shift)
|
||||
return self.execute(context)
|
||||
|
||||
def _execute(self, context):
|
||||
selected_objects = context.selected_objects
|
||||
target_object = selected_objects[0]
|
||||
@@ -80,12 +68,7 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
|
||||
elif not element1.is_a("IfcOpeningElement") and not element2.is_a("IfcOpeningElement"):
|
||||
if element1.is_a("IfcWindow") or element1.is_a("IfcDoor"): # Add a fill to an element.
|
||||
obj1, obj2 = obj2, obj1
|
||||
FilledOpeningGenerator().generate(
|
||||
obj2,
|
||||
obj1,
|
||||
target=obj2.matrix_world.translation,
|
||||
preserve_placement=self.preserve_placement,
|
||||
)
|
||||
FilledOpeningGenerator().generate(obj2, obj1, target=obj2.matrix_world.translation)
|
||||
continue
|
||||
elif element1.is_a("IfcOpeningElement") or element2.is_a("IfcOpeningElement"):
|
||||
if element1.is_a("IfcOpeningElement"): # Reassign an opening to another element.
|
||||
|
||||
@@ -61,7 +61,7 @@ Pattern selection (which approach a new feature should adopt):
|
||||
The authoritative list of registered parametric types — and which use
|
||||
`build_edit_lifecycle` vs. standalone operators — lives in
|
||||
`tool/parametric.py`'s `EDIT_TYPES` and is enforced by the registry
|
||||
contract tests.
|
||||
contract tests in `test/bim/test_parametric_registry.py`.
|
||||
|
||||
This module hosts operator-side mixins that import ``bonsai.tool`` freely.
|
||||
The lightweight parametric registry consumed at addon-enable time must stay
|
||||
@@ -71,16 +71,18 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from typing import ClassVar, get_args
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
import bpy
|
||||
import ifcopenshell.util.element
|
||||
from bpy.app.handlers import persistent
|
||||
from ifcopenshell import entity_instance
|
||||
|
||||
import bonsai.core.geometry
|
||||
import bonsai.tool as tool
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ifcopenshell import entity_instance
|
||||
|
||||
|
||||
class ParametricEditMixinBase:
|
||||
"""Common scaffolding for parametric edit-lifecycle mixins.
|
||||
@@ -377,202 +379,6 @@ class PathPreservingEditMixin(ParametricEditMixinBase):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
# --- Type-selection mixins (Cycle / Pick) ------------------------------------
|
||||
|
||||
|
||||
class TypeAccessorBase:
|
||||
"""Shared contract for operators that resolve and write a Literal type
|
||||
attribute on a Bonsai PropertyGroup.
|
||||
|
||||
Subclasses define ``element_checker``, ``props_getter``, ``type_literal``,
|
||||
``type_attr``; ``skip_element_check`` bypasses element validation. Concrete
|
||||
subclasses (``CycleTypeMixin``, ``PickTypeMixin``) add the interaction
|
||||
shape on top.
|
||||
|
||||
Test doubles must be set on the operator instance — the predicates are
|
||||
bound at class-definition time, so patching the underlying tool module
|
||||
has no effect."""
|
||||
|
||||
element_checker: Callable[[entity_instance], bool]
|
||||
props_getter: Callable[[bpy.types.Object], bpy.types.PropertyGroup]
|
||||
type_literal: type
|
||||
type_attr: str
|
||||
skip_element_check: bool = False
|
||||
|
||||
def _resolve_target(self, context: bpy.types.Context) -> bpy.types.Object | None:
|
||||
"""Return the active object iff it passes ``element_checker`` (or the
|
||||
check is skipped). ``None`` signals the operator should bail with
|
||||
``{'CANCELLED'}``."""
|
||||
obj = context.active_object
|
||||
if not obj:
|
||||
return None
|
||||
if not self.skip_element_check:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element or not self.element_checker(element):
|
||||
return None
|
||||
return obj
|
||||
|
||||
|
||||
class CycleTypeMixin(TypeAccessorBase):
|
||||
"""Operator mixin that cycles through ``type_literal``'s values.
|
||||
|
||||
Shift-click reverses direction."""
|
||||
|
||||
reverse: bpy.props.BoolProperty(name="Reverse", default=False, options={"HIDDEN", "SKIP_SAVE"})
|
||||
|
||||
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]:
|
||||
self.reverse = event.shift
|
||||
return self.execute(context)
|
||||
|
||||
def _cycle_type(self, context: bpy.types.Context) -> set[str]:
|
||||
obj = self._resolve_target(context)
|
||||
if obj is None:
|
||||
return {"CANCELLED"}
|
||||
|
||||
props = self.props_getter(obj)
|
||||
types = get_args(self.type_literal)
|
||||
current = getattr(props, self.type_attr)
|
||||
idx = types.index(current) if current in types else 0
|
||||
direction = -1 if self.reverse else 1
|
||||
setattr(props, self.type_attr, types[(idx + direction) % len(types)])
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class PickTypeMixin(TypeAccessorBase):
|
||||
"""Operator mixin that opens a popup menu listing ``type_literal``'s values.
|
||||
|
||||
Empty ``value`` ⇒ ``invoke`` opens the popup; non-empty ⇒ the user picked
|
||||
an item and ``_pick_type`` applies it.
|
||||
|
||||
When invoked mid-click (e.g. from a gizmo's ``target_set_operator``), the
|
||||
menu opens only after the originating ``LEFTMOUSE`` releases. Otherwise
|
||||
the still-pressed click flows straight into Blender's drag-through-pick
|
||||
gesture and the menu commits whichever item the cursor drifts over on
|
||||
release. Other invocation paths (command-palette / F3, EXEC_DEFAULT, F6
|
||||
redo) bypass the wait and open the menu immediately.
|
||||
|
||||
The ``value`` StringProperty is declared on this mixin but registered via
|
||||
the concrete Operator subclass's MRO scan — do not instantiate the mixin
|
||||
standalone."""
|
||||
|
||||
# Carries the picked value through invoke→execute; empty default
|
||||
# distinguishes "open popup" from "apply".
|
||||
value: bpy.props.StringProperty(default="", options={"HIDDEN", "SKIP_SAVE"})
|
||||
|
||||
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]:
|
||||
"""Open the picker menu, or apply a value that was preset by a
|
||||
menu-item click.
|
||||
|
||||
Routing through ``execute()`` keeps subclass IFC-transaction wrapping
|
||||
in the loop and means F6 redo / ``EXEC_DEFAULT`` reach the apply path."""
|
||||
if self.value:
|
||||
return self.execute(context)
|
||||
|
||||
if self._resolve_target(context) is None:
|
||||
return {"CANCELLED"}
|
||||
|
||||
if event.value == "PRESS":
|
||||
context.window_manager.modal_handler_add(self)
|
||||
return {"RUNNING_MODAL"}
|
||||
return self._open_picker(context)
|
||||
|
||||
def modal(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]:
|
||||
if event.type == "LEFTMOUSE" and event.value == "RELEASE":
|
||||
self._open_picker(context)
|
||||
# INTERFACE does not remove a modal handler; only FINISHED /
|
||||
# CANCELLED do.
|
||||
return {"CANCELLED"}
|
||||
if event.type in {"RIGHTMOUSE", "ESC"}:
|
||||
return {"CANCELLED"}
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
def _open_picker(self, context: bpy.types.Context) -> set[str]:
|
||||
bl_idname = self.bl_idname
|
||||
values = list(get_args(self.type_literal))
|
||||
|
||||
def draw(menu_self, _menu_context):
|
||||
layout = menu_self.layout
|
||||
for v in values:
|
||||
op = layout.operator(bl_idname, text=v)
|
||||
op.value = v
|
||||
|
||||
context.window_manager.popup_menu(draw, title=self.bl_label, icon="MENU_PANEL")
|
||||
# The type change is a two-step interaction: this invocation just OPENS
|
||||
# the menu (no state change yet); a SECOND invocation fires when the
|
||||
# user clicks a menu item — that one writes ``props.<type_attr>`` and
|
||||
# returns FINISHED. By returning INTERFACE here (and not FINISHED), the
|
||||
# menu-open step is excluded from Blender's undo stack so the user
|
||||
# gets exactly ONE undo entry per type change. If we returned FINISHED
|
||||
# here too, the stack would gain a no-op "opened the menu" entry that
|
||||
# Ctrl+Z would dismiss before reverting the actual type change —
|
||||
# confusing UX where the first Ctrl+Z appears to do nothing.
|
||||
return {"INTERFACE"}
|
||||
|
||||
def _pick_type(self, context: bpy.types.Context) -> set[str]:
|
||||
if not self.value:
|
||||
# No-op rather than re-open the menu, so command-palette misuse
|
||||
# doesn't infinite-loop.
|
||||
return {"CANCELLED"}
|
||||
|
||||
obj = self._resolve_target(context)
|
||||
if obj is None:
|
||||
return {"CANCELLED"}
|
||||
|
||||
if self.value not in get_args(self.type_literal):
|
||||
self.report({"WARNING"}, f"Unknown {self.type_attr}: {self.value!r}")
|
||||
return {"CANCELLED"}
|
||||
|
||||
props = self.props_getter(obj)
|
||||
setattr(props, self.type_attr, self.value)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class IntegerInputDialogMixin:
|
||||
"""Operator mixin that mirrors a per-feature ``IntProperty`` on the
|
||||
operator into a draft attribute on the active object's parametric props,
|
||||
via Blender's ``invoke_props_dialog`` popup.
|
||||
|
||||
Subclasses declare:
|
||||
|
||||
- ``attr_name`` — name of the IntProperty on the subclass AND of the
|
||||
attribute on the resolved props (same name on both sides).
|
||||
- ``props_getter`` — ``staticmethod(tool.Model.get_<feature>_props)``.
|
||||
- ``requires_editing`` — True iff the operator must no-op outside an
|
||||
active edit lifecycle. Default False.
|
||||
- ``value_min`` — minimum value to clamp to. Default 1."""
|
||||
|
||||
attr_name: ClassVar[str] = ""
|
||||
props_getter: ClassVar[Callable[[bpy.types.Object], bpy.types.PropertyGroup]]
|
||||
requires_editing: ClassVar[bool] = False
|
||||
value_min: ClassVar[int] = 1
|
||||
|
||||
def _resolve_props(self, context: bpy.types.Context) -> bpy.types.PropertyGroup | None:
|
||||
"""Return the active object's parametric props if the operator is
|
||||
allowed to fire, ``None`` otherwise (caller bails with ``CANCELLED``)."""
|
||||
obj = context.active_object
|
||||
if not obj:
|
||||
return None
|
||||
props = self.props_getter(obj)
|
||||
if self.requires_editing and not props.is_editing:
|
||||
return None
|
||||
return props
|
||||
|
||||
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]: # noqa: ARG002
|
||||
props = self._resolve_props(context)
|
||||
if props is None:
|
||||
return {"CANCELLED"}
|
||||
setattr(self, self.attr_name, max(self.value_min, getattr(props, self.attr_name)))
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
def execute(self, context: bpy.types.Context) -> set[str]:
|
||||
props = self._resolve_props(context)
|
||||
if props is None:
|
||||
return {"CANCELLED"}
|
||||
setattr(props, self.attr_name, max(self.value_min, getattr(self, self.attr_name)))
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
# --- Undo-resync registry ----------------------------------------------------
|
||||
#
|
||||
# Per-type regenerators called from ``resync_parametric_drafts_after_undo``
|
||||
|
||||
+315
-26
@@ -275,37 +275,264 @@ class BIM_UL_panel_visibilities(bpy.types.UIList):
|
||||
row.prop(item, "is_bookmarked", text="", icon="SOLO_ON" if item.is_bookmarked else "SOLO_OFF", emboss=False)
|
||||
|
||||
|
||||
class GizmoPreferencesDoor(bpy.types.PropertyGroup):
|
||||
"""Property group for door gizmo visibility settings."""
|
||||
|
||||
overall_height: BoolProperty(name="Overall Height", default=True)
|
||||
overall_width: BoolProperty(name="Overall Width", default=True)
|
||||
threshold_thickness: BoolProperty(name="Threshold Thickness", default=True)
|
||||
threshold_depth: BoolProperty(name="Threshold Depth", default=True)
|
||||
threshold_offset: BoolProperty(name="Threshold Offset", default=True)
|
||||
lining_offset: BoolProperty(name="Lining Offset", default=True)
|
||||
lining_depth: BoolProperty(name="Lining Depth", default=True)
|
||||
lining_thickness: BoolProperty(name="Lining Thickness", default=True)
|
||||
transom_offset: BoolProperty(name="Transom Offset", default=True)
|
||||
transom_thickness: BoolProperty(name="Transom Thickness", default=True)
|
||||
casing_thickness: BoolProperty(name="Casing Thickness", default=True)
|
||||
casing_depth: BoolProperty(name="Casing Depth", default=True)
|
||||
swing_arc: BoolProperty(name="Swing Arc", default=True, description="Show door swing direction arc")
|
||||
flip_arc: BoolProperty(name="Flip Arc", default=True, description="Show flip door orientation arc")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
overall_height: bool
|
||||
overall_width: bool
|
||||
threshold_thickness: bool
|
||||
threshold_depth: bool
|
||||
threshold_offset: bool
|
||||
lining_offset: bool
|
||||
lining_depth: bool
|
||||
lining_thickness: bool
|
||||
transom_offset: bool
|
||||
transom_thickness: bool
|
||||
casing_thickness: bool
|
||||
casing_depth: bool
|
||||
swing_arc: bool
|
||||
flip_arc: bool
|
||||
|
||||
|
||||
class GizmoPreferencesWindow(bpy.types.PropertyGroup):
|
||||
"""Property group for window gizmo visibility settings."""
|
||||
|
||||
overall_height: BoolProperty(name="Overall Height", default=True)
|
||||
overall_width: BoolProperty(name="Overall Width", default=True)
|
||||
lining_offset: BoolProperty(name="Lining Offset", default=True)
|
||||
lining_depth: BoolProperty(name="Lining Depth", default=True)
|
||||
lining_thickness: BoolProperty(name="Lining Thickness", default=True)
|
||||
lining_to_panel_offset_x: BoolProperty(name="Lining to Panel Offset X", default=True)
|
||||
lining_to_panel_offset_y: BoolProperty(name="Lining to Panel Offset Y", default=True)
|
||||
frame_depth: BoolProperty(name="Frame Depth", default=True)
|
||||
frame_thickness: BoolProperty(name="Frame Thickness", default=True)
|
||||
mullion_thickness: BoolProperty(name="Mullion Thickness", default=True)
|
||||
first_mullion_offset: BoolProperty(name="First Mullion Offset", default=True)
|
||||
second_mullion_offset: BoolProperty(name="Second Mullion Offset", default=True)
|
||||
transom_thickness: BoolProperty(name="Transom Thickness", default=True)
|
||||
first_transom_offset: BoolProperty(name="First Transom Offset", default=True)
|
||||
second_transom_offset: BoolProperty(name="Second Transom Offset", default=True)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
overall_height: bool
|
||||
overall_width: bool
|
||||
lining_offset: bool
|
||||
lining_depth: bool
|
||||
lining_thickness: bool
|
||||
lining_to_panel_offset_x: bool
|
||||
lining_to_panel_offset_y: bool
|
||||
frame_depth: bool
|
||||
frame_thickness: bool
|
||||
mullion_thickness: bool
|
||||
first_mullion_offset: bool
|
||||
second_mullion_offset: bool
|
||||
transom_thickness: bool
|
||||
first_transom_offset: bool
|
||||
second_transom_offset: bool
|
||||
|
||||
|
||||
class GizmoPreferencesStair(bpy.types.PropertyGroup):
|
||||
"""Property group for stair gizmo visibility settings."""
|
||||
|
||||
width: BoolProperty(name="Width", default=True)
|
||||
height: BoolProperty(name="Height", default=True)
|
||||
tread_run: BoolProperty(name="Tread Run", default=True)
|
||||
tread_depth: BoolProperty(name="Tread Depth", default=True)
|
||||
riser_height: BoolProperty(name="Riser Height", default=True)
|
||||
nosing_length: BoolProperty(name="Nosing Length", default=True)
|
||||
nosing_depth: BoolProperty(name="Nosing Depth", default=True)
|
||||
total_length_target: BoolProperty(name="Total Length Target", default=True)
|
||||
base_slab_depth: BoolProperty(name="Base Slab Depth", default=True)
|
||||
top_slab_depth: BoolProperty(name="Top Slab Depth", default=True)
|
||||
lock: BoolProperty(name="Total Length Lock", default=True)
|
||||
plus: BoolProperty(name="Add Tread (+)", default=True)
|
||||
minus: BoolProperty(name="Remove Tread (-)", default=True)
|
||||
cycle: BoolProperty(name="Cycle Stair Type", default=True)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
width: bool
|
||||
height: bool
|
||||
tread_run: bool
|
||||
tread_depth: bool
|
||||
riser_height: bool
|
||||
nosing_length: bool
|
||||
nosing_depth: bool
|
||||
total_length_target: bool
|
||||
base_slab_depth: bool
|
||||
top_slab_depth: bool
|
||||
lock: bool
|
||||
plus: bool
|
||||
minus: bool
|
||||
cycle: bool
|
||||
|
||||
|
||||
class GizmoPreferencesWall(bpy.types.PropertyGroup):
|
||||
"""Property group for wall gizmo visibility settings."""
|
||||
|
||||
length: BoolProperty(
|
||||
name="Length",
|
||||
default=True,
|
||||
description="Show the length dimension gizmo along the wall axis.",
|
||||
)
|
||||
height: BoolProperty(
|
||||
name="Height",
|
||||
default=True,
|
||||
description="Show the height dimension gizmo at the wall's start endpoint.",
|
||||
)
|
||||
height_end: BoolProperty(
|
||||
name="Height (far end, walls > 5m)",
|
||||
default=True,
|
||||
description=(
|
||||
"Show a second height gizmo at the wall's far end so long walls don't "
|
||||
"require panning to reach the handle."
|
||||
),
|
||||
)
|
||||
x_angle: BoolProperty(
|
||||
name="Slope",
|
||||
default=True,
|
||||
description="Show the slope gizmo at the wall top measuring horizontal displacement of the top face.",
|
||||
)
|
||||
cycle: BoolProperty(
|
||||
name="Cycle Offset Baseline",
|
||||
default=True,
|
||||
description="Show the baseline-state icon (Exterior / Centreline / Interior) in the editing icon row.",
|
||||
)
|
||||
scissors: BoolProperty(
|
||||
name="Split at cursor",
|
||||
default=True,
|
||||
description="Show the split icon at the 3D cursor when it lies within the wall's length range.",
|
||||
)
|
||||
extend: BoolProperty(
|
||||
name="Extend length to cursor X",
|
||||
default=True,
|
||||
description="Show the extend-length icon at the 3D cursor's projected wall-axis X.",
|
||||
)
|
||||
extend_height: BoolProperty(
|
||||
name="Extend height to cursor Z",
|
||||
default=True,
|
||||
description="Show the extend-height icon at the 3D cursor's Z, on the wall axis.",
|
||||
)
|
||||
rotate: BoolProperty(
|
||||
name="Rotate 90°",
|
||||
default=True,
|
||||
description="Show the rotate-90 icon in the editing icon row (rotates the wall around its Z axis).",
|
||||
)
|
||||
toggle_openings: BoolProperty(
|
||||
name="Toggle Openings",
|
||||
default=True,
|
||||
description="Show the toggle-openings icon next to the pen (toggles opening fill visibility in the viewport).",
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
length: bool
|
||||
height: bool
|
||||
height_end: bool
|
||||
x_angle: bool
|
||||
cycle: bool
|
||||
scissors: bool
|
||||
extend: bool
|
||||
extend_height: bool
|
||||
rotate: bool
|
||||
toggle_openings: bool
|
||||
|
||||
|
||||
class GizmoPreferences(bpy.types.PropertyGroup):
|
||||
"""Aggregator for parametric gizmo visibility settings. One flat bool per
|
||||
parametric feature; controls whether that feature's gizmo group polls
|
||||
visible in the viewport."""
|
||||
"""Property group for all gizmo visibility settings."""
|
||||
|
||||
draw_gizmos_in_3d_viewport: BoolProperty(
|
||||
name="Draw Gizmos In 3D Viewport",
|
||||
default=True,
|
||||
description="Show interactive gizmos in the 3D viewport for parametric elements",
|
||||
)
|
||||
door: BoolProperty(name="Door", default=True)
|
||||
window: BoolProperty(name="Window", default=True)
|
||||
stair: BoolProperty(name="Stair", default=True)
|
||||
railing: BoolProperty(name="Railing", default=True)
|
||||
roof: BoolProperty(name="Roof", default=True)
|
||||
array: BoolProperty(name="Array", default=True)
|
||||
pipe_segment: BoolProperty(name="Pipe Segment", default=True)
|
||||
duct_segment: BoolProperty(name="Duct Segment", default=True)
|
||||
wall: BoolProperty(name="Wall", default=True)
|
||||
door: bpy.props.PointerProperty(type=GizmoPreferencesDoor)
|
||||
window: bpy.props.PointerProperty(type=GizmoPreferencesWindow)
|
||||
stair: bpy.props.PointerProperty(type=GizmoPreferencesStair)
|
||||
wall: bpy.props.PointerProperty(type=GizmoPreferencesWall)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
draw_gizmos_in_3d_viewport: bool
|
||||
door: bool
|
||||
window: bool
|
||||
stair: bool
|
||||
railing: bool
|
||||
roof: bool
|
||||
array: bool
|
||||
pipe_segment: bool
|
||||
duct_segment: bool
|
||||
wall: bool
|
||||
door: GizmoPreferencesDoor
|
||||
window: GizmoPreferencesWindow
|
||||
stair: GizmoPreferencesStair
|
||||
wall: GizmoPreferencesWall
|
||||
|
||||
|
||||
def _apply_cross_select_pref() -> None:
|
||||
"""Timer callback: re-register Bonsai tools so the Cross Select toggle takes effect."""
|
||||
tool.Blender.apply_cross_select_preference()
|
||||
return None # run once
|
||||
|
||||
|
||||
def _update_cross_select_enabled(self: "CrossSelectPreferences", context: bpy.types.Context) -> None:
|
||||
# Re-registering tools mid property-update is unsafe, so defer to a one-shot timer.
|
||||
if not bpy.app.background and not bpy.app.timers.is_registered(_apply_cross_select_pref):
|
||||
bpy.app.timers.register(_apply_cross_select_pref, first_interval=0.0)
|
||||
|
||||
|
||||
class CrossSelectPreferences(bpy.types.PropertyGroup):
|
||||
"""CAD/Rhino-style box selection settings for Bonsai tools.
|
||||
|
||||
When enabled, every Bonsai workspace tool uses ``bim.cross_select`` instead of
|
||||
Blender's native box/click selection: dragging the box left-to-right selects only
|
||||
fully-enclosed elements (window), right-to-left selects anything touched (crossing).
|
||||
"""
|
||||
|
||||
enabled: BoolProperty(
|
||||
name="Cross Select",
|
||||
default=True,
|
||||
description=(
|
||||
"Use CAD/Rhino-style box selection in Bonsai tools.\n"
|
||||
"Drag left to right to select only fully-enclosed elements (window).\n"
|
||||
"Drag right to left to select anything the box touches (crossing).\n"
|
||||
"Disable to use Blender's native box/click selection"
|
||||
),
|
||||
update=_update_cross_select_enabled,
|
||||
)
|
||||
fully_color: bpy.props.FloatVectorProperty(
|
||||
name="Window Match Color",
|
||||
subtype="COLOR",
|
||||
default=(0.2, 0.6, 1.0), # blue
|
||||
min=0.0,
|
||||
max=1.0,
|
||||
description="Box color when dragging left to right (fully-enclosed / window match)",
|
||||
)
|
||||
partial_color: bpy.props.FloatVectorProperty(
|
||||
name="Crossing Match Color",
|
||||
subtype="COLOR",
|
||||
default=(1.0, 0.4, 0.1), # orange
|
||||
min=0.0,
|
||||
max=1.0,
|
||||
description="Box color when dragging right to left (partial / crossing match)",
|
||||
)
|
||||
line_width: bpy.props.IntProperty(
|
||||
name="Line Width",
|
||||
default=2,
|
||||
min=0,
|
||||
max=20,
|
||||
description="Width of the selection box border",
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
enabled: bool
|
||||
fully_color: tuple[float, float, float]
|
||||
partial_color: tuple[float, float, float]
|
||||
line_width: int
|
||||
|
||||
|
||||
class DocPreferences(bpy.types.PropertyGroup):
|
||||
@@ -587,6 +814,7 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
|
||||
description="Code that will be evaluated to generate occurrence name for CUSTOM occurrence name style",
|
||||
)
|
||||
gizmos: bpy.props.PointerProperty(type=GizmoPreferences)
|
||||
cross_select: bpy.props.PointerProperty(type=CrossSelectPreferences)
|
||||
|
||||
def update_data_dir(self, context: bpy.types.Context) -> None:
|
||||
import bonsai.bim.schema
|
||||
@@ -690,6 +918,7 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
|
||||
occurrence_name_style: Literal["CLASS", "TYPE", "CUSTOM"]
|
||||
occurrence_name_function: str
|
||||
gizmos: GizmoPreferences
|
||||
cross_select: CrossSelectPreferences
|
||||
data_dir: str
|
||||
cache_dir: str
|
||||
pset_dir: str
|
||||
@@ -752,14 +981,74 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
|
||||
self.draw_gizmo_parameters,
|
||||
)
|
||||
|
||||
layout.prop(self.cross_select, "enabled")
|
||||
if self.cross_select.enabled:
|
||||
box = layout.box()
|
||||
box.label(text="Drag left to right: window (only fully-enclosed elements)", icon="FORWARD")
|
||||
box.label(text="Drag right to left: crossing (anything the box touches)", icon="BACK")
|
||||
row = box.row(align=True)
|
||||
row.prop(self.cross_select, "fully_color", text="")
|
||||
row.label(text="Window Match")
|
||||
row.prop(self.cross_select, "partial_color", text="")
|
||||
row.label(text="Crossing Match")
|
||||
box.prop(self.cross_select, "line_width")
|
||||
|
||||
def draw_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
|
||||
"""Render one enabled-toggle per parametric feature."""
|
||||
layout.label(text="Toggle visibility of gizmos in editing mode")
|
||||
box = layout.box()
|
||||
annotations = type(self.gizmos).__annotations__
|
||||
for feature in tool.Parametric.EDIT_TYPES:
|
||||
if feature.name in annotations:
|
||||
box.prop(self.gizmos, feature.name)
|
||||
bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Door", self.draw_door_gizmo_parameters)
|
||||
bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Window", self.draw_window_gizmo_parameters)
|
||||
bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Stair", self.draw_stair_gizmo_parameters)
|
||||
bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Wall", self.draw_wall_gizmo_parameters)
|
||||
|
||||
def _draw_parametric_gizmo_parameters(
|
||||
self,
|
||||
layout: bpy.types.UILayout,
|
||||
gizmo_pg: bpy.types.PropertyGroup,
|
||||
dimension_gizmo_class: type,
|
||||
special_gizmo_names: frozenset[str] = frozenset(),
|
||||
) -> None:
|
||||
"""Draw the per-element gizmo visibility toggles. Surfaces every annotation
|
||||
on ``gizmo_pg`` that either maps to one of ``dimension_gizmo_class``'s
|
||||
dimension gizmos or is named in ``special_gizmo_names`` (non-dimension icons
|
||||
like baseline cycle, scissors, rotate, …)."""
|
||||
visible_names = {p.attr_name for p in dimension_gizmo_class.dimension_gizmo_props} | special_gizmo_names
|
||||
try:
|
||||
annotations = gizmo_pg.__annotations__
|
||||
except AttributeError:
|
||||
annotations = type(gizmo_pg).__annotations__
|
||||
for prop in annotations:
|
||||
if prop in visible_names:
|
||||
layout.prop(gizmo_pg, prop)
|
||||
|
||||
def draw_door_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
|
||||
from bonsai.bim.module.model.door import GizmoDoorEdition
|
||||
|
||||
self._draw_parametric_gizmo_parameters(
|
||||
layout, self.gizmos.door, GizmoDoorEdition, frozenset({"swing_arc", "flip_arc"})
|
||||
)
|
||||
|
||||
def draw_window_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
|
||||
from bonsai.bim.module.model.window import GizmoWindowEdition
|
||||
|
||||
self._draw_parametric_gizmo_parameters(layout, self.gizmos.window, GizmoWindowEdition)
|
||||
|
||||
def draw_stair_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
|
||||
from bonsai.bim.module.model.stair import GizmoStairEdition
|
||||
|
||||
self._draw_parametric_gizmo_parameters(
|
||||
layout, self.gizmos.stair, GizmoStairEdition, frozenset({"lock", "plus", "minus", "cycle"})
|
||||
)
|
||||
|
||||
def draw_wall_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
|
||||
from bonsai.bim.module.model.wall import GizmoWallEdition
|
||||
|
||||
self._draw_parametric_gizmo_parameters(
|
||||
layout,
|
||||
self.gizmos.wall,
|
||||
GizmoWallEdition,
|
||||
frozenset({"cycle", "scissors", "extend", "extend_height", "rotate", "toggle_openings"}),
|
||||
)
|
||||
|
||||
def draw_model_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
|
||||
layout.prop(self, "occurrence_name_style")
|
||||
|
||||
@@ -161,73 +161,23 @@ def align_objects(
|
||||
model.align_objects(reference_obj, objs, align_type)
|
||||
|
||||
|
||||
def regenerate_wall_to_underside(
|
||||
ifc: type[tool.Ifc],
|
||||
geometry: type[tool.Geometry],
|
||||
model: type[tool.Model],
|
||||
wall_objs: list[bpy.types.Object],
|
||||
) -> None:
|
||||
"""Re-clip walls to their connected underside objects after the slab has moved."""
|
||||
clipped_objs = []
|
||||
for obj in wall_objs:
|
||||
wall = ifc.get_entity(obj)
|
||||
slab_objs = model.get_connected_slab_objs(wall)
|
||||
if not slab_objs:
|
||||
continue
|
||||
if ifc.is_moved(obj):
|
||||
geometry.run_edit_object_placement(obj=obj)
|
||||
# Sync each slab's Blender mesh to its current IFC representation before
|
||||
# reading face geometry, so a changed profile is picked up correctly.
|
||||
model.reload_body_representation(slab_objs)
|
||||
model.remove_wall_to_underside_booleans(wall)
|
||||
for slab_obj in slab_objs:
|
||||
clip = model.get_slab_clipping_bmesh(slab_obj)
|
||||
if clip:
|
||||
model.clip_wall_to_slab(wall, clip)
|
||||
clipped_objs.append(obj)
|
||||
if clipped_objs:
|
||||
model.reload_body_representation(clipped_objs)
|
||||
|
||||
|
||||
def extend_wall_to_slab(
|
||||
ifc: type[tool.Ifc],
|
||||
geometry: type[tool.Geometry],
|
||||
model: type[tool.Model],
|
||||
slab_objs: list[bpy.types.Object],
|
||||
slab_obj: bpy.types.Object,
|
||||
wall_objs: list[bpy.types.Object],
|
||||
) -> None:
|
||||
# If any wall is currently in item mode, exit it before modifying the
|
||||
# representation. Leaving stale item objects around causes delete_ifc_item
|
||||
# to later remove the extrusion (or other pre-boolean items) from inside
|
||||
# the boolean chain, corrupting the IFC model.
|
||||
geom_props = geometry.get_geometry_props()
|
||||
if geom_props.representation_obj in wall_objs:
|
||||
geometry.disable_item_mode()
|
||||
clipped_walls = []
|
||||
if not (clip := model.get_slab_clipping_bmesh(slab_obj)):
|
||||
return # Nothing to clip?
|
||||
slab = ifc.get_entity(slab_obj)
|
||||
for obj in wall_objs:
|
||||
if ifc.is_moved(obj):
|
||||
geometry.run_edit_object_placement(obj=obj)
|
||||
wall = ifc.get_entity(obj)
|
||||
# Merge previously connected slabs with newly requested ones so that
|
||||
# re-running the operator never produces duplicate booleans and never
|
||||
# silently discards clips that were applied in an earlier call.
|
||||
existing = model.get_connected_slab_objs(wall)
|
||||
seen = {id(s) for s in existing}
|
||||
all_slab_objs = list(existing) + [s for s in slab_objs if id(s) not in seen]
|
||||
# Remove stale booleans once, then re-clip against the full set.
|
||||
model.remove_wall_to_underside_booleans(wall)
|
||||
did_clip = False
|
||||
for slab_obj in all_slab_objs:
|
||||
clip = model.get_slab_clipping_bmesh(slab_obj)
|
||||
if not clip:
|
||||
continue
|
||||
model.clip_wall_to_slab(wall, clip)
|
||||
model.connect_wall_to_slab(wall, ifc.get_entity(slab_obj))
|
||||
did_clip = True
|
||||
if did_clip:
|
||||
clipped_walls.append(obj)
|
||||
if clipped_walls:
|
||||
model.reload_body_representation(clipped_walls)
|
||||
model.clip_wall_to_slab(wall, clip)
|
||||
model.connect_wall_to_slab(wall, slab)
|
||||
model.reload_body_representation(wall_objs)
|
||||
|
||||
|
||||
class RequireTwoWallsError(Exception):
|
||||
|
||||
@@ -20,6 +20,8 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
import ifcopenshell.util.element
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
@@ -56,12 +58,31 @@ def copy_class(
|
||||
geometry.change_object_data(obj, data, is_global=True)
|
||||
geometry.rename_object(data, geometry.get_representation_name(ifc.get_entity(data)))
|
||||
# Only assign styles if element doesn't get them from material
|
||||
if not root.has_material_styles(new):
|
||||
if not _has_material_styles(ifc, new):
|
||||
root.assign_body_styles(new, obj)
|
||||
collector.assign(obj)
|
||||
return new
|
||||
|
||||
|
||||
def _has_material_styles(ifc: type[tool.Ifc], element: ifcopenshell.entity_instance) -> bool:
|
||||
"""Check if element has styles defined through its material.
|
||||
|
||||
Returns True if any constituent material has a style representation,
|
||||
which means styles should NOT be applied directly to the geometry.
|
||||
"""
|
||||
materials = ifcopenshell.util.element.get_materials(element)
|
||||
|
||||
if not materials:
|
||||
return False
|
||||
|
||||
# Check if any of the constituent materials have styles
|
||||
for material in materials:
|
||||
if hasattr(material, "HasRepresentation") and material.HasRepresentation:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def assign_class(
|
||||
ifc: type[tool.Ifc],
|
||||
collector: type[tool.Collector],
|
||||
|
||||
@@ -64,11 +64,10 @@ def assign_container(
|
||||
spatial.disable_editing(obj)
|
||||
all_elements.add(root_element)
|
||||
all_elements.update(spatial.get_decomposition(root_element))
|
||||
if products := [e for e in root_elements if spatial.can_contain(container, e)]:
|
||||
if products := [e for e in root_elements if spatial.can_contain(container, root_element)]:
|
||||
ifc.run("spatial.assign_container", products=products, relating_structure=container)
|
||||
for element in all_elements:
|
||||
if obj := ifc.get_object(element):
|
||||
collector.assign(obj)
|
||||
collector.assign(ifc.get_object(element))
|
||||
|
||||
|
||||
def enable_editing_container(spatial: type[tool.Spatial], obj: bpy.types.Object) -> None:
|
||||
|
||||
@@ -459,6 +459,7 @@ class Geometry:
|
||||
def has_axis_representation(cls, element): pass
|
||||
def has_data_users(cls, data): pass
|
||||
def has_material_style_override(cls, obj): pass
|
||||
def has_material_styles(cls, element): pass
|
||||
def import_representation_parameters(cls, data): pass
|
||||
def is_body_representation(cls, representation): pass
|
||||
def is_box_representation(cls, representation): pass
|
||||
@@ -680,9 +681,6 @@ class Model:
|
||||
def export_profile(cls, obj, position=None): pass
|
||||
def generate_occurrence_name(cls, element_type, ifc_class): pass
|
||||
def get_extrusion(cls, representation): pass
|
||||
def get_connected_slab_objs(cls, wall): pass
|
||||
def get_connected_wall_objs(cls, slab): pass
|
||||
def has_underside_connection(cls, element): pass
|
||||
def get_manual_booleans(cls, element): pass
|
||||
def get_material_layer_parameters(cls, element): pass
|
||||
def get_slab_clipping_bmesh(cls, obj): pass
|
||||
@@ -698,7 +696,6 @@ class Model:
|
||||
def regenerate_profile(cls, obj): pass
|
||||
def regenerate_slab(cls, obj): pass
|
||||
def reload_body_representation(cls, obj_or_objects): pass
|
||||
def remove_wall_to_underside_booleans(cls, wall): pass
|
||||
def replace_object_ifc_representation(cls, ifc_file, ifc_context, obj, new_representation): pass
|
||||
|
||||
|
||||
@@ -795,7 +792,7 @@ class Profile:
|
||||
@interface
|
||||
class Parametric:
|
||||
def get_geom_generation(cls) -> int: pass
|
||||
def refresh_post_commit(cls, operator) -> None: pass
|
||||
def refresh_post_commit(cls) -> None: pass
|
||||
|
||||
|
||||
@interface
|
||||
@@ -887,7 +884,6 @@ class Root:
|
||||
def get_object_name(cls, obj): pass
|
||||
def get_object_representation(cls, obj): pass
|
||||
def get_representation_context(cls, representation): pass
|
||||
def has_material_styles(cls, element): pass
|
||||
def is_containable(cls, element): pass
|
||||
def is_drawing_annotation(cls, element): pass
|
||||
def is_element_a(cls, element, ifc_class): pass
|
||||
|
||||
+210
-187
@@ -22,7 +22,6 @@ from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import importlib
|
||||
import math
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
@@ -230,22 +229,15 @@ class Blender(bonsai.core.tool.Blender):
|
||||
|
||||
@classmethod
|
||||
def get_active_object(cls, is_selected: bool = False) -> Union[bpy.types.Object, None]:
|
||||
"""Return the active object, or ``None`` when the current context
|
||||
exposes neither ``active_object`` nor a ``view_layer`` (stripped
|
||||
operator contexts).
|
||||
"""Gets the active object
|
||||
|
||||
:param is_selected: If true, the active object also needs to be selected.
|
||||
"""
|
||||
obj = getattr(bpy.context, "active_object", None)
|
||||
if obj is None:
|
||||
view_layer = getattr(bpy.context, "view_layer", None)
|
||||
if view_layer is not None:
|
||||
obj = view_layer.objects.active
|
||||
if obj is None:
|
||||
return None
|
||||
if is_selected and not obj.select_get():
|
||||
return None
|
||||
return obj
|
||||
if obj := (getattr(bpy.context, "active_object", None) or bpy.context.view_layer.objects.active):
|
||||
if not is_selected:
|
||||
return obj
|
||||
if obj.select_get():
|
||||
return obj
|
||||
|
||||
@classmethod
|
||||
def get_selected_objects(cls, include_active: bool = True) -> set[bpy.types.Object]:
|
||||
@@ -761,26 +753,12 @@ class Blender(bonsai.core.tool.Blender):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def get_default_selection_keypmap(cls) -> tuple:
|
||||
"""keymap to replicate default blender selection behaviour with click and box selection"""
|
||||
# code below comes from blender_default.py which is part of default blender scripts licensed under GPL v2
|
||||
# https://github.com/blender/blender/blob/master/release/scripts/presets/keyconfig/keymap_data/blender_default.py
|
||||
# the code is the data from evaluating km_3d_view_tool_select() and km_3d_view_tool_select_box()
|
||||
#
|
||||
# You can run the snippet below in Blender console
|
||||
# to regenerate those keybindings in case of errors in the future
|
||||
# ```
|
||||
# import os
|
||||
# version = ".".join(bpy.app.version_string.split(".")[:2])
|
||||
# fl = os.path.join(os.getcwd(), version, "scripts/presets/keyconfig/keymap_data/blender_default.py")
|
||||
# def_keymap = bpy.utils.execfile(fl)
|
||||
# params = def_keymap.Params
|
||||
# box_keymap = def_keymap.km_3d_view_tool_select_box(def_keymap.Params(), fallback=None)[2]["items"]
|
||||
# click_keymap = def_keymap.km_3d_view_tool_select(def_keymap.Params(select_mouse="LEFTMOUSE"), fallback=None)[2]["items"]
|
||||
# ```
|
||||
# https://docs.blender.org/api/current/bpy.types.KeyMapItems.html
|
||||
keymap = (
|
||||
# box selection keymap
|
||||
def get_native_selection_keymap(cls) -> tuple:
|
||||
"""Blender's default click + box selection keymap (used when Cross Select is off)."""
|
||||
# Data from blender_default.py (GPL v2): the items of km_3d_view_tool_select_box()
|
||||
# and km_3d_view_tool_select(select_mouse="LEFTMOUSE"). See git history for the
|
||||
# console snippet to regenerate these if Blender's defaults ever change.
|
||||
return (
|
||||
("view3d.select_box", {"type": "LEFTMOUSE", "value": "CLICK_DRAG"}, None),
|
||||
(
|
||||
"view3d.select_box",
|
||||
@@ -797,7 +775,6 @@ class Blender(bonsai.core.tool.Blender):
|
||||
{"type": "LEFTMOUSE", "value": "CLICK_DRAG", "shift": True, "ctrl": True},
|
||||
{"properties": [("mode", "AND")]},
|
||||
),
|
||||
# left-click selection keymap
|
||||
("view3d.select", {"type": "LEFTMOUSE", "value": "PRESS"}, {"properties": [("deselect_all", True)]}),
|
||||
(
|
||||
"view3d.select",
|
||||
@@ -805,7 +782,126 @@ class Blender(bonsai.core.tool.Blender):
|
||||
{"properties": [("toggle", True)]},
|
||||
),
|
||||
)
|
||||
return keymap
|
||||
|
||||
@classmethod
|
||||
def get_cross_select_keymap(cls) -> tuple:
|
||||
"""CAD/Rhino-style box selection keymap, routed through ``bim.cross_select``.
|
||||
|
||||
A single ``LEFTMOUSE`` ``PRESS`` binding lets the modal operator decide between a
|
||||
click and a box drag itself (drag left-to-right = window, right-to-left = crossing),
|
||||
reading Shift/Ctrl to add or subtract from the selection.
|
||||
"""
|
||||
return (
|
||||
("bim.cross_select", {"type": "LEFTMOUSE", "value": "PRESS"}, {"properties": []}),
|
||||
("bim.cross_select", {"type": "LEFTMOUSE", "value": "PRESS", "shift": True}, {"properties": []}),
|
||||
("bim.cross_select", {"type": "LEFTMOUSE", "value": "PRESS", "ctrl": True}, {"properties": []}),
|
||||
(
|
||||
"bim.cross_select",
|
||||
{"type": "LEFTMOUSE", "value": "PRESS", "shift": True, "ctrl": True},
|
||||
{"properties": []},
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def is_cross_select_enabled(cls) -> bool:
|
||||
try:
|
||||
return bool(cls.get_addon_preferences().cross_select.enabled)
|
||||
except (KeyError, AttributeError):
|
||||
# Preferences are not registered yet (e.g. at tool import/registration time).
|
||||
# Default to Cross Select; the saved preference is applied on startup via
|
||||
# apply_cross_select_preference().
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def get_default_selection_keypmap(cls) -> tuple:
|
||||
"""Selection keymap shared by every Bonsai tool.
|
||||
|
||||
Returns the Cross Select keymap when the add-on preference is enabled, otherwise
|
||||
Blender's native selection keymap. ``bl_keymap`` is evaluated once when the tool
|
||||
classes are imported, so toggling the preference re-applies the keymap live via
|
||||
:meth:`apply_cross_select_preference`.
|
||||
"""
|
||||
return cls.get_cross_select_keymap() if cls.is_cross_select_enabled() else cls.get_native_selection_keymap()
|
||||
|
||||
# Length of the selection block in bl_keymap, keyed by the operator idname of its
|
||||
# first entry. Used to locate and replace the selection block during a rebuild
|
||||
# while preserving any pre-selection or post-selection entries the tool declares.
|
||||
_SELECTION_PREFIX_LENGTHS = {"bim.cross_select": 4, "view3d.select_box": 6}
|
||||
|
||||
@classmethod
|
||||
def _iter_selection_tools(cls):
|
||||
"""Yield ``(tool_cls, after, separator, group)`` for every Bonsai WorkSpaceTool that
|
||||
uses the shared selection keymap, in registration order so re-registration preserves
|
||||
the toolbar layout.
|
||||
|
||||
NOTE: keep in sync with each module's ``register()`` tool registration. The model
|
||||
tools are read from ``model.tools``; the single-tool modules are listed explicitly.
|
||||
"""
|
||||
from bonsai.bim.module import project, model, cad, drawing, spatial, structural, covering
|
||||
|
||||
yield (project.workspace.ExploreTool, {"builtin.transform"}, True, False)
|
||||
for td in model.tools:
|
||||
yield (td.tool, td.after, td.separator, td.group)
|
||||
yield (cad.workspace.CadTool, {"builtin.transform"}, True, False)
|
||||
yield (drawing.workspace.AnnotationTool, {"bim.bim_tool"}, True, False)
|
||||
yield (spatial.workspace.SpatialTool, {"bim.annotation_tool"}, False, False)
|
||||
yield (structural.workspace.StructuralTool, {"bim.spatial_tool"}, False, False)
|
||||
yield (covering.workspace.CoveringTool, {"bim.wall_tool"}, False, False)
|
||||
|
||||
@classmethod
|
||||
def _split_tool_keymap(cls, tool_cls) -> tuple[tuple, tuple]:
|
||||
"""Split bl_keymap into (pre_selection, post_selection), discarding the selection block.
|
||||
|
||||
Locates the selection block by finding the first entry whose op is in
|
||||
``_SELECTION_PREFIX_LENGTHS``, then skips the declared number of entries.
|
||||
Entries before the block become *pre_selection*; entries after become
|
||||
*post_selection*. The caller inserts the desired selection keymap between them.
|
||||
|
||||
When no selection block is found (shouldn't happen in practice) all entries
|
||||
are returned as post_selection so the rebuild still produces a valid keymap.
|
||||
"""
|
||||
km = tuple(tool_cls.bl_keymap)
|
||||
sel_start = next((i for i, entry in enumerate(km) if entry[0] in cls._SELECTION_PREFIX_LENGTHS), None)
|
||||
if sel_start is None:
|
||||
return (), km
|
||||
sel_len = cls._SELECTION_PREFIX_LENGTHS[km[sel_start][0]]
|
||||
return km[:sel_start], km[sel_start + sel_len:]
|
||||
|
||||
@classmethod
|
||||
def apply_cross_select_preference(cls) -> None:
|
||||
"""Rebuild every Bonsai tool's selection keymap from the current preference and
|
||||
re-register the tools so the Cross Select toggle takes effect without a restart.
|
||||
|
||||
Safe to call repeatedly: it no-ops when the tools already carry the desired keymap.
|
||||
"""
|
||||
if bpy.app.background:
|
||||
return
|
||||
selection_keymap = tuple(cls.get_default_selection_keypmap())
|
||||
tools = list(cls._iter_selection_tools())
|
||||
if not tools:
|
||||
return
|
||||
|
||||
desired_op = selection_keymap[0][0] if selection_keymap else None
|
||||
current = tuple(tools[0][0].bl_keymap)
|
||||
# The selection block may be preceded by pre-selection entries (e.g. the
|
||||
# AnnotationTool's ClickNearestDimensionAnchor), so search for it by op name
|
||||
# rather than assuming it starts at position 0.
|
||||
sel_start = next((i for i, entry in enumerate(current) if entry[0] in cls._SELECTION_PREFIX_LENGTHS), None)
|
||||
if sel_start is not None and current[sel_start][0] == desired_op:
|
||||
return # already applied
|
||||
|
||||
# Capture each tool's own entries before mutating any ``bl_keymap`` (model subclasses
|
||||
# share BimTool's inherited keymap, so this must be done up front).
|
||||
pre_post = {tool_cls: cls._split_tool_keymap(tool_cls) for tool_cls, *_ in tools}
|
||||
for tool_cls, *_ in reversed(tools):
|
||||
try:
|
||||
bpy.utils.unregister_tool(tool_cls)
|
||||
except Exception:
|
||||
pass
|
||||
for tool_cls, after, separator, group in tools:
|
||||
pre, post = pre_post[tool_cls]
|
||||
tool_cls.bl_keymap = pre + selection_keymap + post
|
||||
bpy.utils.register_tool(tool_cls, after=after, separator=separator, group=group)
|
||||
|
||||
KEY_MODIFIERS = {
|
||||
"A": ("EVENT_ALT", "OPTION" if sys.platform == "Darwin" else "ALT"),
|
||||
@@ -888,57 +984,19 @@ class Blender(bonsai.core.tool.Blender):
|
||||
# ( 1.0, 1.0, -1.0), # 7
|
||||
# ]
|
||||
bound_box = obj.bound_box
|
||||
min_pt = Vector(bound_box[0])
|
||||
max_pt = Vector(bound_box[6])
|
||||
bbox_dict = {
|
||||
"min_x": min_pt.x,
|
||||
"max_x": max_pt.x,
|
||||
"min_y": min_pt.y,
|
||||
"max_y": max_pt.y,
|
||||
"min_z": min_pt.z,
|
||||
"max_z": max_pt.z,
|
||||
"min_point": min_pt,
|
||||
"max_point": max_pt,
|
||||
"center": (max_pt + min_pt) / 2,
|
||||
# Intrinsic per-axis size in object-local space. Distinct from
|
||||
# ``obj.dimensions``, which folds object-level scale into its
|
||||
# output; this is the raw mesh bbox extent.
|
||||
"dimensions": (max_pt.x - min_pt.x, max_pt.y - min_pt.y, max_pt.z - min_pt.z),
|
||||
"min_x": bound_box[0][0],
|
||||
"max_x": bound_box[6][0],
|
||||
"min_y": bound_box[0][1],
|
||||
"max_y": bound_box[6][1],
|
||||
"min_z": bound_box[0][2],
|
||||
"max_z": bound_box[6][2],
|
||||
"min_point": Vector(bound_box[0]),
|
||||
"max_point": Vector(bound_box[6]),
|
||||
"center": (Vector(bound_box[6]) + Vector(bound_box[0])) / 2,
|
||||
}
|
||||
return bbox_dict
|
||||
|
||||
@classmethod
|
||||
def get_object_world_bounding_box(cls, obj: bpy.types.Object) -> dict[str, Union[float, Vector]]:
|
||||
"""Same shape as ``get_object_bounding_box`` but with ``matrix_world``
|
||||
applied — extents are computed across the 8 transformed corners, so
|
||||
a rotated or scaled object reports its actual world-axis AABB rather
|
||||
than the misleading transform of the local-space corners.
|
||||
|
||||
``bound_box[0]`` / ``bound_box[6]`` are the local min/max corners but
|
||||
do NOT correspond to the world AABB extremes once the object is
|
||||
rotated, so min/max must be taken per-axis across all 8 corners."""
|
||||
corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
|
||||
xs = [c.x for c in corners]
|
||||
ys = [c.y for c in corners]
|
||||
zs = [c.z for c in corners]
|
||||
min_point = Vector((min(xs), min(ys), min(zs)))
|
||||
max_point = Vector((max(xs), max(ys), max(zs)))
|
||||
return {
|
||||
"min_x": min_point.x,
|
||||
"max_x": max_point.x,
|
||||
"min_y": min_point.y,
|
||||
"max_y": max_point.y,
|
||||
"min_z": min_point.z,
|
||||
"max_z": max_point.z,
|
||||
"min_point": min_point,
|
||||
"max_point": max_point,
|
||||
"center": (min_point + max_point) / 2,
|
||||
# World-axis-aligned per-axis size. For rotated objects this is
|
||||
# the AABB extent, not the intrinsic mesh size (use the local
|
||||
# variant for that).
|
||||
"dimensions": (max_point.x - min_point.x, max_point.y - min_point.y, max_point.z - min_point.z),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def select_and_activate_single_object(cls, context: bpy.types.Context, active_object: bpy.types.Object) -> None:
|
||||
for obj in context.selected_objects:
|
||||
@@ -1378,6 +1436,74 @@ class Blender(bonsai.core.tool.Blender):
|
||||
return True
|
||||
|
||||
class Modifier:
|
||||
# ----------------------------------------------------------------------
|
||||
# FIXME(PR5): backward-compat shims for callers still using the
|
||||
# pre-refactor API. The is_<type> predicates now live on tool.Parametric;
|
||||
# the Array helper bag now lives on tool.Array. PR4 migrates each caller;
|
||||
# this whole shim block is removed in PR5's cleanup.
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def is_door(cls, element: entity_instance) -> bool:
|
||||
return tool.Parametric.is_door(element)
|
||||
|
||||
@classmethod
|
||||
def is_railing(cls, element: entity_instance) -> bool:
|
||||
return tool.Parametric.is_railing(element)
|
||||
|
||||
@classmethod
|
||||
def is_roof(cls, element: entity_instance) -> bool:
|
||||
return tool.Parametric.is_roof(element)
|
||||
|
||||
@classmethod
|
||||
def is_stair(cls, element: entity_instance) -> bool:
|
||||
return tool.Parametric.is_stair(element)
|
||||
|
||||
@classmethod
|
||||
def is_wall(cls, element: entity_instance) -> bool:
|
||||
return tool.Parametric.is_wall(element)
|
||||
|
||||
@classmethod
|
||||
def is_window(cls, element: entity_instance) -> bool:
|
||||
return tool.Parametric.is_window(element)
|
||||
|
||||
class Array:
|
||||
@classmethod
|
||||
def bake_children_transform(cls, parent_element: ifcopenshell.entity_instance, item: int) -> None:
|
||||
tool.Array.bake_children_transform(parent_element, item)
|
||||
|
||||
@classmethod
|
||||
def constrain_children_to_parent(cls, parent_element: ifcopenshell.entity_instance) -> None:
|
||||
tool.Array.constrain_children_to_parent(parent_element)
|
||||
|
||||
@classmethod
|
||||
def get_all_children_objects(cls, parent_element: ifcopenshell.entity_instance) -> list:
|
||||
return tool.Array.get_all_children_objects(parent_element)
|
||||
|
||||
@classmethod
|
||||
def get_all_objects(cls, parent_element: ifcopenshell.entity_instance) -> list:
|
||||
return tool.Array.get_all_objects(parent_element)
|
||||
|
||||
@classmethod
|
||||
def get_children_objects(cls, modifier_data: dict) -> list:
|
||||
return tool.Array.get_children_objects(modifier_data)
|
||||
|
||||
@classmethod
|
||||
def get_modifiers_data(cls, parent_element: ifcopenshell.entity_instance):
|
||||
return tool.Array.get_modifiers_data(parent_element)
|
||||
|
||||
@classmethod
|
||||
def remove_constraints(cls, parent_element: ifcopenshell.entity_instance) -> None:
|
||||
tool.Array.remove_constraints(parent_element)
|
||||
|
||||
@classmethod
|
||||
def set_children_lock_state(
|
||||
cls, parent_element: ifcopenshell.entity_instance, item: int, lock: bool
|
||||
) -> None:
|
||||
tool.Array.set_children_lock_state(parent_element, item, lock)
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def try_applying_edit_mode(cls, obj: bpy.types.Object, element: entity_instance) -> bool:
|
||||
"""Tries to validate the current BIM modifier parameters for the active object
|
||||
@@ -1459,38 +1585,6 @@ class Blender(bonsai.core.tool.Blender):
|
||||
parent_guid = pset.get("Parent")
|
||||
return parent_guid is not None and parent_guid != element.GlobalId
|
||||
|
||||
@classmethod
|
||||
def any_selected_is_array_child(cls) -> bool:
|
||||
"""True if any selected IFC-linked object is a Bonsai array child.
|
||||
|
||||
Multi-object wall topology gizmos (merge / join / extend / unjoin
|
||||
/ fillet) and their bound operators gate on this: any mutation
|
||||
applied to a child is overwritten on the next
|
||||
``regenerate_array``, and merge specifically would leave the
|
||||
parent's ``BBIM_Array.Data`` list pointing at a deleted GUID.
|
||||
|
||||
Memoised against (selection signature, IFC geometry generation)
|
||||
so gizmo polls that fire per input event don't re-walk the pset
|
||||
for every selected object every frame. Identity-keyed so plain
|
||||
Python objects (used by tests) work alongside real Blender
|
||||
``bpy_struct`` wrappers."""
|
||||
selected = tool.Blender.get_selected_objects()
|
||||
selection_sig = frozenset(id(obj) for obj in selected)
|
||||
current_gen = tool.Parametric.get_geom_generation()
|
||||
cached = cls._any_selected_array_child_memo
|
||||
if cached is not None and cached[0] == selection_sig and cached[1] == current_gen:
|
||||
return cached[2]
|
||||
result = False
|
||||
for obj in selected:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if element is not None and cls.is_array_child(element):
|
||||
result = True
|
||||
break
|
||||
cls._any_selected_array_child_memo = (selection_sig, current_gen, result)
|
||||
return result
|
||||
|
||||
_any_selected_array_child_memo: tuple[frozenset[int], int, bool] | None = None
|
||||
|
||||
@classmethod
|
||||
def is_slab(cls, element: entity_instance) -> bool:
|
||||
"""A slab is host-eligible for the parametric add-opening gizmo if
|
||||
@@ -2028,18 +2122,6 @@ class Blender(bonsai.core.tool.Blender):
|
||||
dct = {cls.bl_idname: cls.ifc_element_type for cls in (BimTool.__subclasses__())}
|
||||
return types.MappingProxyType(dct)
|
||||
|
||||
@classmethod
|
||||
@lru_cache
|
||||
def get_property_header_tools(cls) -> frozenset[str]:
|
||||
"""``BimTool`` plus its parametric subclasses — the workspace
|
||||
tools whose 3D-view / N-panel header surfaces BIM Tool property
|
||||
floats (extrusion_depth, length, x_angle). ``AnnotationTool``
|
||||
and the non-``BimTool`` workspace tools (spatial / structural /
|
||||
cad / covering) are excluded by construction."""
|
||||
from bonsai.bim.module.model.workspace import BimTool
|
||||
|
||||
return frozenset(cls.bl_idname for cls in (BimTool.__subclasses__() + [BimTool]))
|
||||
|
||||
@classmethod
|
||||
def get_object_constraint_props(cls, obj: bpy.types.Object) -> BIMObjectConstraintProperties:
|
||||
return obj.BIMObjectConstraintProperties # pyright: ignore[reportAttributeAccessIssue]
|
||||
@@ -2260,65 +2342,6 @@ class Blender(bonsai.core.tool.Blender):
|
||||
return False
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def draw_bmesh_face_tris(
|
||||
cls,
|
||||
bm: bmesh.types.BMesh,
|
||||
world_vert_coords: list,
|
||||
color: Any,
|
||||
draw_batch: Callable[[str, list, Any, list], None],
|
||||
) -> None:
|
||||
"""Submit a non-mutating beauty-triangulated TRIS batch for ``bm``'s faces.
|
||||
|
||||
``world_vert_coords`` must be indexed by ``bm.verts`` index. Never call
|
||||
``bmesh.ops.triangulate`` on a live bmesh to compute draw indices — it
|
||||
mutates the input and produces ear-clip fans that render as visible
|
||||
streaks at low alpha.
|
||||
"""
|
||||
tris = [[loop.vert.index for loop in tri] for tri in bm.calc_loop_triangles()]
|
||||
draw_batch("TRIS", world_vert_coords, color, tris)
|
||||
|
||||
@classmethod
|
||||
def build_dashed_line_segments(
|
||||
cls,
|
||||
world_verts: Sequence[Sequence[float]],
|
||||
edges_indices: Sequence[Sequence[int]],
|
||||
dash_period: float,
|
||||
dash_width: float,
|
||||
) -> tuple[list[tuple[float, float, float]], list[tuple[int, int]]]:
|
||||
"""Pre-segment edges into world-space dash chunks for a vanilla LINES batch.
|
||||
|
||||
Each input edge is sliced into segments of length ``dash_width`` spaced
|
||||
``dash_period`` apart (dash phase resets per-edge). The result is a fresh
|
||||
``(verts, edges)`` pair that draws as dashes through any standard line
|
||||
shader — letting both passes of a visible/occluded outline reuse the
|
||||
same shader so depth values match exactly across passes.
|
||||
"""
|
||||
new_verts: list[tuple[float, float, float]] = []
|
||||
new_edges: list[tuple[int, int]] = []
|
||||
if dash_period <= 0 or dash_width <= 0:
|
||||
return new_verts, new_edges
|
||||
n = len(world_verts)
|
||||
for i, j in edges_indices:
|
||||
if not (0 <= i < n and 0 <= j < n) or i == j:
|
||||
continue
|
||||
v0 = world_verts[i]
|
||||
v1 = world_verts[j]
|
||||
dx, dy, dz = v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]
|
||||
edge_length = math.sqrt(dx * dx + dy * dy + dz * dz)
|
||||
if edge_length == 0.0:
|
||||
continue
|
||||
ux, uy, uz = dx / edge_length, dy / edge_length, dz / edge_length
|
||||
t = 0.0
|
||||
while t < edge_length:
|
||||
t_end = min(t + dash_width, edge_length)
|
||||
idx = len(new_verts)
|
||||
new_verts.append((v0[0] + ux * t, v0[1] + uy * t, v0[2] + uz * t))
|
||||
new_verts.append((v0[0] + ux * t_end, v0[1] + uy * t_end, v0[2] + uz * t_end))
|
||||
new_edges.append((idx, idx + 1))
|
||||
t += dash_period
|
||||
return new_verts, new_edges
|
||||
|
||||
@classmethod
|
||||
def extract_error_reports(cls, exception: RuntimeError) -> list[str]:
|
||||
"""Extracts error report lines from a runtime exception during operator execution.
|
||||
@@ -2469,7 +2492,7 @@ class Blender(bonsai.core.tool.Blender):
|
||||
|
||||
See https://projects.blender.org/blender/blender/issues/149283
|
||||
"""
|
||||
if len(bytedata) == (n * 8): # float64 has 8 bytes per element
|
||||
if len(bytedata) == (n * 2):
|
||||
return np.frombuffer(bytedata, dtype=np.float64).astype(np.float32)
|
||||
return np.frombuffer(bytedata, dtype=np.float32)
|
||||
|
||||
|
||||
@@ -177,14 +177,6 @@ class Cad:
|
||||
return False
|
||||
return (x + tolerance) > value > (x - tolerance)
|
||||
|
||||
@classmethod
|
||||
def is_multiple_of_pi(cls, value: float) -> bool:
|
||||
"""True when ``value`` is an integer multiple of π within tolerance —
|
||||
the parallelism / anti-parallelism check rotation-difference logic
|
||||
reaches for (segments aligned modulo a 180° flip)."""
|
||||
n = round(value / math.pi)
|
||||
return cls.is_x(abs(value - n * math.pi), 0)
|
||||
|
||||
@classmethod
|
||||
def normalise_angle(cls, angle: float) -> float:
|
||||
"""Normalise an angle between -179 and 180"""
|
||||
|
||||
@@ -257,13 +257,7 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
break
|
||||
mesh = obj.data
|
||||
assert isinstance(mesh, bpy.types.Mesh)
|
||||
item_id = tool.Geometry.get_mesh_props(mesh).ifc_definition_id
|
||||
try:
|
||||
item = tool.Ifc.get().by_id(item_id)
|
||||
except RuntimeError:
|
||||
# Entity already deleted (e.g. removed as part of a sibling boolean collapse).
|
||||
bpy.data.objects.remove(obj)
|
||||
return
|
||||
item = tool.Ifc.get().by_id(tool.Geometry.get_mesh_props(mesh).ifc_definition_id)
|
||||
rep_obj = props.representation_obj
|
||||
assert (rep_obj := props.representation_obj) and (rep_element := tool.Ifc.get_entity(rep_obj))
|
||||
cls.remove_representation_item(item, rep_element)
|
||||
@@ -849,6 +843,15 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def has_material_styles(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
"""True when any of ``element``'s materials exposes an
|
||||
``IfcSurfaceStyle``. Gate body-style assignment to avoid double-styling."""
|
||||
return any(
|
||||
tool.Material.get_style(material) is not None
|
||||
for material in ifcopenshell.util.element.get_materials(element)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def reimport_element_representations(
|
||||
cls, obj: bpy.types.Object, representation: ifcopenshell.entity_instance, apply_openings: bool = True
|
||||
@@ -1154,16 +1157,11 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
@classmethod
|
||||
def get_representation_item(cls, obj: bpy.types.Object) -> Union[ifcopenshell.entity_instance, None]:
|
||||
data = obj.data
|
||||
if not isinstance(data, Geometry.TYPES_WITH_MESH_PROPERTIES):
|
||||
return None
|
||||
ifc_id = tool.Geometry.get_mesh_props(data).ifc_definition_id
|
||||
if not ifc_id:
|
||||
return None
|
||||
try:
|
||||
item = tool.Ifc.get().by_id(ifc_id)
|
||||
except RuntimeError:
|
||||
return None
|
||||
if item.is_a("IfcRepresentationItem"):
|
||||
if (
|
||||
isinstance(data, Geometry.TYPES_WITH_MESH_PROPERTIES)
|
||||
and (ifc_id := tool.Geometry.get_mesh_props(data).ifc_definition_id)
|
||||
and ((item := tool.Ifc.get().by_id(ifc_id)).is_a("IfcRepresentationItem"))
|
||||
):
|
||||
return item
|
||||
return None
|
||||
|
||||
@@ -1337,8 +1335,6 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
cls, representation: ifcopenshell.entity_instance
|
||||
) -> ifcopenshell.entity_instance:
|
||||
if representation.RepresentationType == "MappedRepresentation":
|
||||
if not representation.Items:
|
||||
return representation
|
||||
return cls.resolve_mapped_representation(representation.Items[0].MappingSource.MappedRepresentation)
|
||||
return representation
|
||||
|
||||
|
||||
@@ -1216,7 +1216,7 @@ class Loader(bonsai.core.tool.Loader):
|
||||
) -> bool:
|
||||
items = [i["item"] for i in ifcopenshell.util.representation.resolve_items(representation)]
|
||||
if len(items) == 1 and items[0].is_a("IfcSweptDiskSolid"):
|
||||
if tool.Parametric.is_railing(element):
|
||||
if tool.Blender.Modifier.is_railing(element):
|
||||
return False
|
||||
return True
|
||||
elif len(items) and ( # See #2508 why we accommodate for invalid IFCs here
|
||||
@@ -1224,7 +1224,7 @@ class Loader(bonsai.core.tool.Loader):
|
||||
and len({i.is_a() for i in items}) == 1
|
||||
and len({i.Radius for i in items}) == 1
|
||||
):
|
||||
if tool.Parametric.is_railing(element):
|
||||
if tool.Blender.Modifier.is_railing(element):
|
||||
return False
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -227,8 +227,10 @@ class Misc(bonsai.core.tool.Misc):
|
||||
|
||||
@classmethod
|
||||
def set_object_origin_to_bottom(cls, obj: bpy.types.Object) -> None:
|
||||
absolute_bound_box = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
|
||||
min_z = min([c[2] for c in absolute_bound_box])
|
||||
new_origin = obj.matrix_world.translation.copy()
|
||||
new_origin[2] = tool.Blender.get_object_world_bounding_box(obj)["min_z"]
|
||||
new_origin[2] = min_z
|
||||
assert isinstance(obj.data, bpy.types.Mesh)
|
||||
obj.data.transform(
|
||||
Matrix.Translation(
|
||||
@@ -247,8 +249,11 @@ class Misc(bonsai.core.tool.Misc):
|
||||
|
||||
@classmethod
|
||||
def scale_object_to_height(cls, obj: bpy.types.Object, height: float) -> None:
|
||||
bbox = tool.Blender.get_object_world_bounding_box(obj)
|
||||
scale_factor = height / (bbox["max_z"] - bbox["min_z"])
|
||||
absolute_bound_box = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
|
||||
max_z = max([c[2] for c in absolute_bound_box])
|
||||
min_z = min([c[2] for c in absolute_bound_box])
|
||||
current_absolute_height = max_z - min_z
|
||||
scale_factor = height / current_absolute_height
|
||||
obj.matrix_world @= Matrix.Scale(
|
||||
scale_factor, 4, obj.matrix_world.inverted().to_quaternion() @ Vector((0, 0, 1))
|
||||
)
|
||||
|
||||
+23
-157
@@ -75,10 +75,8 @@ if TYPE_CHECKING:
|
||||
from bonsai.bim.module.model.prop import (
|
||||
BIMArrayProperties,
|
||||
BIMDoorProperties,
|
||||
BIMDuctSegmentProperties,
|
||||
BIMExternalParametricGeometryProperties,
|
||||
BIMModelProperties,
|
||||
BIMPipeSegmentProperties,
|
||||
BIMPolylineProperties,
|
||||
BIMRailingProperties,
|
||||
BIMRoofProperties,
|
||||
@@ -118,14 +116,6 @@ class Model(bonsai.core.tool.Model):
|
||||
def get_railing_props(cls, obj: bpy.types.Object) -> BIMRailingProperties:
|
||||
return obj.BIMRailingProperties # pyright: ignore[reportAttributeAccessIssue]
|
||||
|
||||
@classmethod
|
||||
def get_pipe_segment_props(cls, obj: bpy.types.Object) -> BIMPipeSegmentProperties:
|
||||
return obj.BIMPipeSegmentProperties # pyright: ignore[reportAttributeAccessIssue]
|
||||
|
||||
@classmethod
|
||||
def get_duct_segment_props(cls, obj: bpy.types.Object) -> BIMDuctSegmentProperties:
|
||||
return obj.BIMDuctSegmentProperties # pyright: ignore[reportAttributeAccessIssue]
|
||||
|
||||
@classmethod
|
||||
def get_sverchok_props(cls, obj: bpy.types.Object) -> BIMSverchokProperties:
|
||||
return obj.BIMSverchokProperties # pyright: ignore[reportAttributeAccessIssue]
|
||||
@@ -361,8 +351,6 @@ class Model(bonsai.core.tool.Model):
|
||||
@classmethod
|
||||
def get_extrusion(cls, representation: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]:
|
||||
"""Return first found IfcExtrudedAreaSolid"""
|
||||
if not representation.Items:
|
||||
return None
|
||||
item = representation.Items[0]
|
||||
while True:
|
||||
if item.is_a("IfcExtrudedAreaSolid"):
|
||||
@@ -372,28 +360,6 @@ class Model(bonsai.core.tool.Model):
|
||||
else:
|
||||
break
|
||||
|
||||
@classmethod
|
||||
def get_sibling_occurrence_count(cls, element: ifcopenshell.entity_instance) -> int:
|
||||
"""Number of *other* products sharing this element's body representation.
|
||||
|
||||
Returns the count of products bound to the same resolved body rep, minus
|
||||
``element`` itself and minus its type (if any). Zero when the element has
|
||||
no body rep, no resolved rep, or no siblings. A non-zero result means a
|
||||
parametric edit on ``element`` will silently mutate other instances'
|
||||
geometry."""
|
||||
body_rep = tool.Geometry.get_body_representation(element)
|
||||
if not body_rep:
|
||||
return 0
|
||||
resolved = ifcopenshell.util.representation.resolve_representation(body_rep)
|
||||
if not resolved:
|
||||
return 0
|
||||
elements = tool.Geometry.get_elements_by_representation(resolved)
|
||||
elements.discard(element)
|
||||
element_type = ifcopenshell.util.element.get_type(element)
|
||||
if element_type is not None:
|
||||
elements.discard(element_type)
|
||||
return len(elements)
|
||||
|
||||
unit_scale: float
|
||||
vertices: list[Vector]
|
||||
edges: list[Sequence[int]]
|
||||
@@ -877,57 +843,6 @@ class Model(bonsai.core.tool.Model):
|
||||
items.append(item.FirstOperand)
|
||||
return booleans
|
||||
|
||||
@classmethod
|
||||
def get_connected_slab_objs(cls, wall: ifcopenshell.entity_instance) -> list[bpy.types.Object]:
|
||||
"""Return Blender objects for slabs connected to wall via IfcRelConnectsElements(TOP)."""
|
||||
result = []
|
||||
for rel in wall.ConnectedFrom:
|
||||
if rel.is_a("IfcRelConnectsElements") and rel.Description == "TOP":
|
||||
slab_obj = tool.Ifc.get_object(rel.RelatingElement)
|
||||
if slab_obj:
|
||||
result.append(slab_obj)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def get_connected_wall_objs(cls, slab: ifcopenshell.entity_instance) -> list[bpy.types.Object]:
|
||||
"""Return Blender objects for LAYER2 walls connected to slab via IfcRelConnectsElements(TOP)."""
|
||||
result = []
|
||||
for rel in slab.ConnectedTo:
|
||||
if rel.is_a("IfcRelConnectsElements") and rel.Description == "TOP":
|
||||
wall_obj = tool.Ifc.get_object(rel.RelatedElement)
|
||||
if wall_obj:
|
||||
result.append(wall_obj)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def has_underside_connection(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
"""Return True if element has an IfcRelConnectsElements(TOP) relationship."""
|
||||
return any(rel.is_a("IfcRelConnectsElements") and rel.Description == "TOP" for rel in element.ConnectedFrom)
|
||||
|
||||
@classmethod
|
||||
def remove_wall_to_underside_booleans(cls, wall: ifcopenshell.entity_instance) -> None:
|
||||
"""Remove all IfcBooleanResult items previously added by extend_walls_to_underside."""
|
||||
manual_booleans = cls.get_manual_booleans(wall)
|
||||
if not manual_booleans:
|
||||
return
|
||||
ifc_file = tool.Ifc.get()
|
||||
for b in manual_booleans:
|
||||
sec = b.SecondOperand
|
||||
if sec is None:
|
||||
# The IfcPolygonalFaceSet was already deleted externally. Splice the
|
||||
# orphaned IfcBooleanResult out of the chain so the representation stays valid.
|
||||
parents = list(ifc_file.get_inverse(b))
|
||||
for parent in parents:
|
||||
if parent.is_a("IfcBooleanResult") and parent.FirstOperand == b:
|
||||
parent.FirstOperand = b.FirstOperand
|
||||
elif parent.is_a("IfcShapeRepresentation"):
|
||||
new_items = tuple((set(parent.Items) - {b}) | {b.FirstOperand})
|
||||
parent.Items = new_items
|
||||
cls.unmark_manual_booleans(wall, [b.id()])
|
||||
ifc_file.remove(b)
|
||||
elif sec.is_a("IfcTessellatedFaceSet"):
|
||||
tool.Geometry.remove_representation_item(sec, wall)
|
||||
|
||||
@classmethod
|
||||
def get_manual_booleans(
|
||||
cls, element: ifcopenshell.entity_instance, representation: Optional[ifcopenshell.entity_instance] = None
|
||||
@@ -940,8 +855,7 @@ class Model(bonsai.core.tool.Model):
|
||||
representation = tool.Geometry.get_body_representation(element)
|
||||
if not representation:
|
||||
return []
|
||||
all_chain_booleans = cls.get_booleans(element, representation)
|
||||
booleans = [b for b in all_chain_booleans if b.id() in boolean_ids]
|
||||
booleans = [b for b in cls.get_booleans(element, representation) if b.id() in boolean_ids]
|
||||
return booleans
|
||||
|
||||
@classmethod
|
||||
@@ -1588,7 +1502,8 @@ class Model(bonsai.core.tool.Model):
|
||||
element = tool.Ifc.get_entity(object)
|
||||
if not element:
|
||||
return
|
||||
pset_data = ifcopenshell.util.element.get_pset(element, pset_name)
|
||||
psets = ifcopenshell.util.element.get_psets(element)
|
||||
pset_data = psets.get(pset_name, None)
|
||||
if not pset_data:
|
||||
return
|
||||
pset_data["data_dict"] = json.loads(pset_data.get("Data", "[]") or "[]")
|
||||
@@ -2642,15 +2557,12 @@ class Model(bonsai.core.tool.Model):
|
||||
clipping_bm = bmesh.new()
|
||||
vertex_map = {}
|
||||
|
||||
kept = 0
|
||||
for face in bm.faces:
|
||||
face.normal_update()
|
||||
normal = face.normal.to_4d()
|
||||
normal.w = 0
|
||||
world_normal_z = (obj.matrix_world @ normal).z
|
||||
if world_normal_z >= -0.5:
|
||||
if (obj.matrix_world @ normal).z >= -0.5:
|
||||
continue
|
||||
kept += 1
|
||||
new_verts = []
|
||||
for vert in face.verts:
|
||||
if not (new_vert := vertex_map.get(vert.index, None)):
|
||||
@@ -2663,7 +2575,6 @@ class Model(bonsai.core.tool.Model):
|
||||
return
|
||||
|
||||
bmesh.ops.recalc_face_normals(clipping_bm, faces=clipping_bm.faces)
|
||||
clipping_bm.faces.ensure_lookup_table()
|
||||
return clipping_bm # clipping_bm is in project units
|
||||
|
||||
@classmethod
|
||||
@@ -2677,53 +2588,17 @@ class Model(bonsai.core.tool.Model):
|
||||
min_z = min(zs)
|
||||
max_z = max(zs)
|
||||
|
||||
ifc_file = tool.Ifc.get()
|
||||
builder = ifcopenshell.util.shape_builder.ShapeBuilder(ifc_file)
|
||||
operand = None
|
||||
if (z := max_z - min_z) and not np.isclose(z, 0.0):
|
||||
builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get())
|
||||
|
||||
# Build one IfcPolygonalFaceSet clip solid per clipping face.
|
||||
# Each solid uses a rectangle on the slope plane rather than the exact face
|
||||
# footprint. The original approach (exact footprint) caused a kissing-solid /
|
||||
# boundary-coincidence bug when the operator is called twice for a ridge roof: the
|
||||
# two slope solids share an exact ridge edge, and OCCT produces spurious extra
|
||||
# vertices. Extending each solid slightly past the ridge (by margin) creates a
|
||||
# volumetric overlap instead of a kissing boundary — OCCT handles overlapping
|
||||
# DIFFERENCE operands correctly.
|
||||
margin = 1.0 # project units past the face edge — enough to ensure overlap at ridge
|
||||
operands = []
|
||||
for face in bm.faces:
|
||||
face.normal_update()
|
||||
normal = Vector(face.normal).normalized()
|
||||
result = bmesh.ops.extrude_face_region(bm, geom=bm.faces)
|
||||
extruded_verts = [elem for elem in result["geom"] if isinstance(elem, bmesh.types.BMVert)]
|
||||
bmesh.ops.translate(bm, verts=extruded_verts, vec=(0, 0, z))
|
||||
|
||||
# Orthonormal basis spanning the slope plane.
|
||||
ref = Vector((0, 0, 1)) if abs(normal.z) < 0.9 else Vector((1, 0, 0))
|
||||
tangent1 = normal.cross(ref).normalized()
|
||||
tangent2 = normal.cross(tangent1).normalized()
|
||||
|
||||
centroid = sum((v.co for v in face.verts), Vector()) / len(face.verts)
|
||||
|
||||
# Tight bounding rectangle in slope-plane coords, plus a small margin.
|
||||
t1_coords = [(v.co - centroid).dot(tangent1) for v in face.verts]
|
||||
t2_coords = [(v.co - centroid).dot(tangent2) for v in face.verts]
|
||||
half1 = max(abs(c) for c in t1_coords) + margin
|
||||
half2 = max(abs(c) for c in t2_coords) + margin
|
||||
|
||||
# Rectangle on the slope plane, extruded upward in wall-local Z.
|
||||
clip_bm = bmesh.new()
|
||||
v0 = clip_bm.verts.new(centroid + half1 * tangent1 + half2 * tangent2)
|
||||
v1 = clip_bm.verts.new(centroid - half1 * tangent1 + half2 * tangent2)
|
||||
v2 = clip_bm.verts.new(centroid - half1 * tangent1 - half2 * tangent2)
|
||||
v3 = clip_bm.verts.new(centroid + half1 * tangent1 - half2 * tangent2)
|
||||
bottom_face = clip_bm.faces.new([v0, v1, v2, v3])
|
||||
result = bmesh.ops.extrude_face_region(clip_bm, geom=[bottom_face])
|
||||
top_verts = [e for e in result["geom"] if isinstance(e, bmesh.types.BMVert)]
|
||||
bmesh.ops.translate(clip_bm, verts=top_verts, vec=Vector((0, 0, max_z - min_z)))
|
||||
clip_bm.verts.ensure_lookup_table()
|
||||
|
||||
clip_verts = [v.co for v in clip_bm.verts]
|
||||
clip_faces = [[v.index for v in f.verts] for f in clip_bm.faces]
|
||||
operand = builder.mesh(clip_verts, clip_faces)
|
||||
clip_bm.free()
|
||||
operands.append(operand)
|
||||
verts = [v.co for v in bm.verts]
|
||||
faces = [[v.index for v in p.verts] for p in bm.faces]
|
||||
operand = builder.mesh(verts, faces)
|
||||
|
||||
for extrusion in ifcopenshell.util.shape.get_base_extrusions(wall) or []:
|
||||
if extrusion.Position:
|
||||
@@ -2740,9 +2615,10 @@ class Model(bonsai.core.tool.Model):
|
||||
|
||||
extrusion.Depth = max_z / direction[2]
|
||||
|
||||
if operands:
|
||||
body_repr = ifcopenshell.util.representation.get_representation(wall, "Model", "Body", "MODEL_VIEW")
|
||||
booleans = ifcopenshell.api.geometry.add_boolean(ifc_file, first_item=extrusion, second_items=operands)
|
||||
if operand:
|
||||
booleans = ifcopenshell.api.geometry.add_boolean(
|
||||
tool.Ifc.get(), first_item=extrusion, second_items=[operand]
|
||||
)
|
||||
tool.Model.mark_manual_booleans(wall, booleans)
|
||||
|
||||
@classmethod
|
||||
@@ -2974,7 +2850,7 @@ class Model(bonsai.core.tool.Model):
|
||||
def offset_wall(cls, wall: bpy.types.Object, baseline: Literal["EXTERIOR", "INTERIOR", "CENTER"]) -> None:
|
||||
element = tool.Ifc.get_entity(wall)
|
||||
usage = ifcopenshell.util.element.get_material(element)
|
||||
if usage is None or not usage.is_a("IfcMaterialLayerSetUsage"):
|
||||
if not usage.is_a("IfcMaterialLayerSetUsage"):
|
||||
return
|
||||
layer_set = usage.ForLayerSet
|
||||
if baseline == "CENTER":
|
||||
@@ -2995,20 +2871,10 @@ class Model(bonsai.core.tool.Model):
|
||||
|
||||
@classmethod
|
||||
def recreate_wall(cls, element: ifcopenshell.entity_instance, obj: bpy.types.Object) -> None:
|
||||
# Curved fillet-corner walls own a hand-built banana body that
|
||||
# ``regenerate_wall_representation`` would flatten — it reads the axis
|
||||
# as a 2-point reference line and builds a straight extrusion. Rebuild
|
||||
# the curve in place instead: ``regenerate_fillet_corner_wall`` keeps
|
||||
# radius + placement from the pset / current ``ObjectPlacement`` while
|
||||
# picking up new thickness / height from the wall type, which is what
|
||||
# we want when a type-property edit triggered this call.
|
||||
if tool.Parametric.is_fillet_corner_wall(element):
|
||||
# Lazy import: ``tool.Model`` loads before ``bim/module/model`` at
|
||||
# addon enable; a module-level import would cycle.
|
||||
from bonsai.bim.module.model.wall import regenerate_fillet_corner_wall
|
||||
|
||||
regenerate_fillet_corner_wall(element, obj)
|
||||
return
|
||||
# FIXME(PR4): the fillet-corner branch lands with PR4's
|
||||
# `regenerate_fillet_corner_wall` (bim/module/model/wall.py). On v0.8.0
|
||||
# the function doesn't exist; falling through to the straight-extrusion
|
||||
# path preserves v0.8.0 behaviour for fillet walls until PR4 ships.
|
||||
rep = ifcopenshell.api.geometry.regenerate_wall_representation(tool.Ifc.get(), element)
|
||||
bonsai.core.geometry.switch_representation(
|
||||
tool.Ifc,
|
||||
@@ -3043,7 +2909,7 @@ class Model(bonsai.core.tool.Model):
|
||||
if not wall:
|
||||
continue
|
||||
is_layer2_usage = tool.Model.get_usage_type(element) == "LAYER2"
|
||||
is_fillet_corner = tool.Parametric.is_fillet_corner_wall(element)
|
||||
is_fillet_corner = bool(ifcopenshell.util.element.get_pset(element, "BBIM_Wall", "IsFilletCorner"))
|
||||
if not (is_layer2_usage or is_fillet_corner):
|
||||
continue
|
||||
if is_layer2_usage:
|
||||
|
||||
@@ -147,15 +147,17 @@ class Parametric(bonsai.core.tool.Parametric):
|
||||
self._data.clear()
|
||||
self._gen = None
|
||||
|
||||
# FIXME(PR4): array / pipe_segment / duct_segment land with their
|
||||
# finish/cancel operators in PR4. Adding them to EDIT_TYPES without those
|
||||
# operators makes auto-commit-on-save dispatch bim.finish_editing_<name>
|
||||
# for objects flagged as in-edit, which then raises because the operator
|
||||
# doesn't exist. PR4 re-adds the three entries together with the operators.
|
||||
EDIT_TYPES: list[ParametricObject] = [
|
||||
ParametricObject("door", has_non_editable_path=True, supports_build_edit_lifecycle=True),
|
||||
ParametricObject("window", has_non_editable_path=True, supports_build_edit_lifecycle=True),
|
||||
ParametricObject("stair", has_non_editable_path=True, supports_build_edit_lifecycle=True),
|
||||
ParametricObject("railing", supports_build_edit_lifecycle=True),
|
||||
ParametricObject("roof", supports_build_edit_lifecycle=True),
|
||||
ParametricObject("array", supports_build_edit_lifecycle=True),
|
||||
ParametricObject("pipe_segment", supports_build_edit_lifecycle=True),
|
||||
ParametricObject("duct_segment", supports_build_edit_lifecycle=True),
|
||||
ParametricObject("wall"),
|
||||
]
|
||||
|
||||
@@ -167,9 +169,6 @@ class Parametric(bonsai.core.tool.Parametric):
|
||||
STAIR: ClassVar[ParametricObject]
|
||||
RAILING: ClassVar[ParametricObject]
|
||||
ROOF: ClassVar[ParametricObject]
|
||||
ARRAY: ClassVar[ParametricObject]
|
||||
PIPE_SEGMENT: ClassVar[ParametricObject]
|
||||
DUCT_SEGMENT: ClassVar[ParametricObject]
|
||||
WALL: ClassVar[ParametricObject]
|
||||
|
||||
_geom_generation: int = 0
|
||||
@@ -179,24 +178,16 @@ class Parametric(bonsai.core.tool.Parametric):
|
||||
return cls._geom_generation
|
||||
|
||||
@classmethod
|
||||
def refresh_post_commit(cls, operator: bpy.types.Operator) -> None:
|
||||
"""Post-commit hook for ``tool.Ifc.Operator``: bumps the geometry
|
||||
generation counter so caches keyed off it drop stale entries on
|
||||
the next draw, and tags viewports for redraw.
|
||||
def refresh_post_commit(cls) -> None:
|
||||
"""Post-commit hook for ``tool.Ifc.Operator``: re-syncs scene-level
|
||||
workspace-tool header fields from current IFC state and bumps the
|
||||
geometry generation counter so caches keyed off it drop stale
|
||||
entries on the next draw."""
|
||||
import bonsai.bim.handler # late import: bim.handler imports tool.*
|
||||
|
||||
Additionally refreshes the BIM Tool header floats for the
|
||||
validate-gizmo path — operators whose ``bl_idname`` is the
|
||||
``finish_op`` of an entry in ``EDIT_TYPES``. That is the only
|
||||
commit class where selection didn't change but the header
|
||||
values displayed did. Other operators skip the refresh: they
|
||||
don't target an active-object header edit, and their commit
|
||||
context may lack the view-layer attributes the refresh reads."""
|
||||
cls._geom_generation += 1
|
||||
bonsai.bim.handler.update_bim_tool_props()
|
||||
tool.Blender.update_all_viewports()
|
||||
if operator.bl_idname in {feature.finish_op for feature in cls.EDIT_TYPES}:
|
||||
import bonsai.bim.handler # late import: bim.handler imports tool.*
|
||||
|
||||
bonsai.bim.handler.refresh_bim_tool_headers()
|
||||
|
||||
@classmethod
|
||||
def find_by_name(cls, name: str) -> Optional[ParametricObject]:
|
||||
@@ -271,18 +262,6 @@ class Parametric(bonsai.core.tool.Parametric):
|
||||
for obj in bpy.data.objects:
|
||||
cls._validated_editing_feature(obj)
|
||||
|
||||
@classmethod
|
||||
def on_load_post(cls, scene: bpy.types.Scene) -> None:
|
||||
"""Drain load-transient parametric state on a freshly opened scene
|
||||
so no draft edit flag, preview flag, or cache entry persists from
|
||||
the saved file."""
|
||||
from bonsai.bim.module.model import wall_offset_gizmos
|
||||
from bonsai.bim.module.model.preview_base import discard_pending_previews
|
||||
|
||||
cls.heal_stale_edit_flags()
|
||||
discard_pending_previews(scene)
|
||||
wall_offset_gizmos.clear_caches()
|
||||
|
||||
@classmethod
|
||||
def get_pending_edits(cls) -> list[tuple[bpy.types.Object, str]]:
|
||||
"""``(object, finish_operator_bl_idname)`` pairs for every object
|
||||
@@ -403,6 +382,29 @@ class Parametric(bonsai.core.tool.Parametric):
|
||||
if hasattr(bpy.types.Object, feature.props_attr):
|
||||
delattr(bpy.types.Object, feature.props_attr)
|
||||
|
||||
@classmethod
|
||||
def iter_gizmo_preference_classes(cls, ui_module) -> list[type]:
|
||||
"""``GizmoPreferences<Name>`` classes that exist on ``ui_module`` for
|
||||
every registry entry, plus the shared ``GizmoPreferencesFeature`` if
|
||||
present. Order matches ``EDIT_TYPES``. Used by ``bim/__init__.py`` to
|
||||
inject the per-type ``GizmoPreferences<X>`` classes at the correct
|
||||
point — before ``ui.GizmoPreferences``, which references them via
|
||||
``PointerProperty``."""
|
||||
# FIXME(PR5): drop the per-feature loop once PR4 consolidates
|
||||
# bim/ui.py to use a single shared GizmoPreferencesFeature class
|
||||
# and rewrites GizmoPreferences accordingly. The shared-class
|
||||
# branch is the forward-compat path; the per-feature loop keeps
|
||||
# v0.8.0's bim/ui.py working until then.
|
||||
out: list[type] = []
|
||||
for feature in cls.EDIT_TYPES:
|
||||
gpref = getattr(ui_module, f"GizmoPreferences{feature.name.capitalize()}", None)
|
||||
if gpref is not None:
|
||||
out.append(gpref)
|
||||
shared = getattr(ui_module, "GizmoPreferencesFeature", None)
|
||||
if shared is not None:
|
||||
out.append(shared)
|
||||
return out
|
||||
|
||||
# --- Feature-kind predicates ------------------------------------------------
|
||||
# One predicate per registered parametric type. Each is total: accepts any
|
||||
# IFC entity (or None), returns a bool, never raises. Predicates live with
|
||||
@@ -485,13 +487,6 @@ class Parametric(bonsai.core.tool.Parametric):
|
||||
return False
|
||||
if tool.Model.get_usage_type(element) == "LAYER2":
|
||||
return True
|
||||
return cls.is_fillet_corner_wall(element)
|
||||
|
||||
@classmethod
|
||||
def is_fillet_corner_wall(cls, element: entity_instance) -> bool:
|
||||
"""``True`` if the wall carries the ``BBIM_Wall.IsFilletCorner`` flag,
|
||||
marking it as a curved corner whose banana body is hand-built rather
|
||||
than regenerated from the wall's axis + layer set."""
|
||||
import ifcopenshell.util.element
|
||||
|
||||
return bool(ifcopenshell.util.element.get_pset(element, "BBIM_Wall", "IsFilletCorner"))
|
||||
|
||||
@@ -373,38 +373,26 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
except:
|
||||
loc = Vector((0, 0, 0))
|
||||
|
||||
snap_obj._ensure_bvh()
|
||||
verts_2d = [
|
||||
view3d_utils.location_3d_to_region_2d(region, rv3d, v) for v in snap_obj.verts_3d
|
||||
] # Numpy version is worst in performance
|
||||
|
||||
intersected = snap_obj.raycast_boxes(
|
||||
context, event, snap_obj.root, intersected=[], rays=(ray_origin, ray_direction)
|
||||
)
|
||||
|
||||
# Collect edges from intersected BVH boxes
|
||||
edges = []
|
||||
for it in intersected:
|
||||
edges.extend(it.edges)
|
||||
edges = set(edges)
|
||||
|
||||
# Build only the vertices indices that belong to these edges
|
||||
verts_idx: set[int] = set()
|
||||
for e in edges:
|
||||
ev = snap_obj.obj.data.edges[e].vertices
|
||||
verts_idx.add(ev[0])
|
||||
verts_idx.add(ev[1])
|
||||
|
||||
# Lazily project only the needed vertices to 2D screen space
|
||||
verts_2d: dict[int, Vector] = {}
|
||||
for idx in verts_idx:
|
||||
v2d = view3d_utils.location_3d_to_region_2d(region, rv3d, snap_obj.verts_3d[idx])
|
||||
if v2d is not None:
|
||||
verts_2d[idx] = v2d
|
||||
|
||||
edge_verts = {}
|
||||
for e in edges:
|
||||
verts_idx = snap_obj.obj.data.edges[e].vertices
|
||||
v1 = snap_obj.verts_3d[verts_idx[0]]
|
||||
v2 = snap_obj.verts_3d[verts_idx[1]]
|
||||
v1_2d = verts_2d.get(verts_idx[0])
|
||||
v2_2d = verts_2d.get(verts_idx[1])
|
||||
verts_idx = tuple(snap_obj.obj.data.edges[e].vertices)
|
||||
verts = snap_obj.obj.data.vertices
|
||||
v1 = snap_obj.obj.matrix_world @ verts[verts_idx[0]].co
|
||||
v1_2d = verts_2d[verts_idx[0]]
|
||||
v2 = snap_obj.obj.matrix_world @ verts[verts_idx[1]].co
|
||||
v2_2d = verts_2d[verts_idx[1]]
|
||||
if (v1_2d is None) ^ (v2_2d is None):
|
||||
point, _ = cls.intersect_edge_region_border(region, context.space_data, rv3d, v1, v2)
|
||||
if v1_2d is None:
|
||||
@@ -416,16 +404,10 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
|
||||
snap_threshold = 10.0
|
||||
|
||||
# Check all vertices for proximity to mouse position.
|
||||
# Re-use the 2D projections already computed for edge endpoints.
|
||||
for i, v3d in enumerate(snap_obj.verts_3d):
|
||||
if i in verts_2d:
|
||||
v2d = verts_2d[i]
|
||||
else:
|
||||
v2d = view3d_utils.location_3d_to_region_2d(region, rv3d, v3d)
|
||||
if v2d is None:
|
||||
continue
|
||||
distance = (Vector(mouse_pos) - v2d).length
|
||||
for i, point in enumerate(verts_2d):
|
||||
if not point:
|
||||
continue
|
||||
distance = (Vector(mouse_pos) - point).length
|
||||
if distance <= snap_threshold:
|
||||
snap_point = {
|
||||
"object": snap_obj.obj,
|
||||
@@ -817,30 +799,6 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
else:
|
||||
return None, None, None
|
||||
|
||||
@classmethod
|
||||
def process_wireframe_snap_obj(
|
||||
cls,
|
||||
context: bpy.types.Context,
|
||||
event: bpy.types.Event,
|
||||
snap_obj,
|
||||
ray_origin: Vector,
|
||||
closest_snaps: list,
|
||||
):
|
||||
snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
|
||||
hit_obj = None
|
||||
hit = None
|
||||
if snap_points:
|
||||
closest_length_squared = float("inf")
|
||||
for point in snap_points:
|
||||
point["group"] = "Wireframe"
|
||||
closest_snaps.append(point)
|
||||
length = (point["point"] - ray_origin).length_squared
|
||||
if length < closest_length_squared:
|
||||
closest_length_squared = length
|
||||
hit = point["point"]
|
||||
hit_obj = point["object"]
|
||||
return hit_obj, hit
|
||||
|
||||
@classmethod
|
||||
def ray_cast_and_get_closest_to_camera_snaps(
|
||||
cls,
|
||||
@@ -855,43 +813,35 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
|
||||
ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event)
|
||||
|
||||
space = context.space_data
|
||||
xray_mode = (space.shading.type == "SOLID" and space.shading.show_xray) or (
|
||||
space.shading.type == "WIREFRAME" and space.shading.show_xray_wireframe
|
||||
)
|
||||
|
||||
closest_snaps = []
|
||||
hit = None
|
||||
|
||||
if not xray_mode and objs_to_raycast:
|
||||
# Non-xray - only the closest solid object's Face snap is kept by
|
||||
# the caller (detect_snapping_points). Process solids in distance
|
||||
# order and stop at the first hit to minimise raycasts.
|
||||
wireframe_objs = []
|
||||
solid_objs = []
|
||||
for snap_obj in objs_to_raycast:
|
||||
if snap_obj.obj.type in {"EMPTY", "CURVE"} or (
|
||||
hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0
|
||||
):
|
||||
wireframe_objs.append(snap_obj)
|
||||
else:
|
||||
solid_objs.append(snap_obj)
|
||||
for snap_obj in objs_to_raycast:
|
||||
if snap_obj.obj.type in {"EMPTY", "CURVE"} or (
|
||||
hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0
|
||||
):
|
||||
# For wireframe objects we have to test all the snaps to see which is closer
|
||||
snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
|
||||
closest_wf_hit = None
|
||||
closest_wf_length_squared = 1.0
|
||||
closest_wf_point = None
|
||||
if snap_points:
|
||||
for point in snap_points:
|
||||
point["group"] = "Wireframe"
|
||||
closest_snaps.append(point)
|
||||
length = (point["point"] - ray_origin).length_squared
|
||||
if closest_wf_hit is None or length < closest_wf_length_squared:
|
||||
closest_wf_length_squared = length
|
||||
closest_wf_hit = point["point"]
|
||||
closest_wf_point = point
|
||||
|
||||
# Rough distance - object origin to ray origin
|
||||
solid_objs.sort(key=lambda so: (so.obj.matrix_world.translation - ray_origin).length_squared)
|
||||
if closest_wf_point:
|
||||
hit_obj = closest_wf_point["object"]
|
||||
hit = closest_wf_point["point"]
|
||||
face_index = None
|
||||
|
||||
# Process wireframe objects first (all of them, always collected)
|
||||
for snap_obj in wireframe_objs:
|
||||
hit_obj, hit = cls.process_wireframe_snap_obj(context, event, snap_obj, ray_origin, closest_snaps)
|
||||
if hit is not None:
|
||||
length_squared = (hit - ray_origin).length_squared
|
||||
if closest_obj is None or length_squared < closest_length_squared:
|
||||
closest_length_squared = length_squared
|
||||
closest_obj = hit_obj
|
||||
closest_hit = hit
|
||||
closest_face_index = None
|
||||
|
||||
# Process solid objects in distance order, stop at first hit
|
||||
for snap_obj in solid_objs:
|
||||
else:
|
||||
# Solid objects
|
||||
hit_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, snap_obj.obj)
|
||||
|
||||
if hit:
|
||||
@@ -905,45 +855,14 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
}
|
||||
closest_snaps.append(snap_point)
|
||||
|
||||
length_squared = (hit - ray_origin).length_squared
|
||||
if closest_obj is None or length_squared < closest_length_squared:
|
||||
closest_length_squared = length_squared
|
||||
closest_obj = hit_obj
|
||||
closest_hit = hit
|
||||
closest_face_index = face_index
|
||||
|
||||
break
|
||||
|
||||
else:
|
||||
# Xray mode - process all objects (all snaps are kept by the caller)
|
||||
for snap_obj in objs_to_raycast:
|
||||
if snap_obj.obj.type in {"EMPTY", "CURVE"} or (
|
||||
hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0
|
||||
):
|
||||
hit_obj, hit = cls.process_wireframe_snap_obj(context, event, snap_obj, ray_origin, closest_snaps)
|
||||
face_index = None
|
||||
else:
|
||||
# Solid objects
|
||||
hit_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, snap_obj.obj)
|
||||
|
||||
if hit:
|
||||
snap_point = {
|
||||
"point": hit,
|
||||
"type": "Face",
|
||||
"group": "Object",
|
||||
"object": hit_obj,
|
||||
"face_index": face_index,
|
||||
"distance": 9, # High value so it has low priority
|
||||
}
|
||||
closest_snaps.append(snap_point)
|
||||
|
||||
if hit is not None:
|
||||
length_squared = (hit - ray_origin).length_squared
|
||||
if closest_obj is None or length_squared < closest_length_squared:
|
||||
closest_length_squared = length_squared
|
||||
closest_obj = hit_obj
|
||||
closest_hit = hit
|
||||
closest_face_index = face_index
|
||||
# Here we test which is closer, including wireframe and solid objects
|
||||
if hit is not None:
|
||||
length_squared = (hit - ray_origin).length_squared
|
||||
if closest_obj is None or length_squared < closest_length_squared:
|
||||
closest_length_squared = length_squared
|
||||
closest_obj = hit_obj
|
||||
closest_hit = hit
|
||||
closest_face_index = face_index
|
||||
|
||||
# Label snaps from the closest object
|
||||
if closest_obj is not None:
|
||||
@@ -1017,19 +936,12 @@ class SnapObj:
|
||||
def __init__(self, obj: bpy.types.Object):
|
||||
self.__class__.all.append(self)
|
||||
self.obj = obj
|
||||
self.root = None
|
||||
self._bvh_built = False
|
||||
self.root = self._create_root_node()
|
||||
self.root.edges = [e.index for e in obj.data.edges]
|
||||
self.split_box(self.root, 0)
|
||||
self.verts_3d = [obj.matrix_world @ v.co for v in obj.data.vertices]
|
||||
self.snap_points = []
|
||||
|
||||
def _ensure_bvh(self):
|
||||
if self._bvh_built:
|
||||
return
|
||||
self.root = self._create_root_node()
|
||||
self.root.edges = [e.index for e in self.obj.data.edges]
|
||||
self.split_box(self.root, 0)
|
||||
self._bvh_built = True
|
||||
|
||||
def __clear_all__():
|
||||
for instance in SnapObj.all:
|
||||
del instance
|
||||
|
||||
@@ -71,18 +71,6 @@ class Root(bonsai.core.tool.Root):
|
||||
should_use_presentation_style_assignment=props.should_use_presentation_style_assignment,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def has_material_styles(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
"""``True`` if any constituent material on ``element`` carries a style
|
||||
representation. Body styles should NOT be applied directly when this
|
||||
is True — the material-inherited style is the authoritative source.
|
||||
Paired with ``assign_body_styles``: callers check this first and only
|
||||
call ``assign_body_styles`` when it returns False."""
|
||||
materials = ifcopenshell.util.element.get_materials(element)
|
||||
if not materials:
|
||||
return False
|
||||
return any(getattr(m, "HasRepresentation", None) for m in materials)
|
||||
|
||||
@classmethod
|
||||
def copy_representation(
|
||||
cls, source: ifcopenshell.entity_instance, dest: ifcopenshell.entity_instance
|
||||
@@ -393,7 +381,7 @@ class Root(bonsai.core.tool.Root):
|
||||
# Make sure that the array children also get reassigned to the correct aggregate
|
||||
pset = ifcopenshell.util.element.get_pset(new[0], "BBIM_Array")
|
||||
if pset:
|
||||
array_children = tool.Array.get_all_children_objects(new[0])
|
||||
array_children = tool.Blender.Modifier.Array.get_all_children_objects(new[0])
|
||||
for obj in array_children:
|
||||
bonsai.core.aggregate.assign_object(
|
||||
tool.Ifc,
|
||||
|
||||
@@ -80,7 +80,10 @@ class Spatial(bonsai.core.tool.Spatial):
|
||||
def get_root_element(cls, element: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance:
|
||||
while True:
|
||||
if parent := (
|
||||
ifcopenshell.util.element.get_aggregate(element) or ifcopenshell.util.element.get_nest(element)
|
||||
ifcopenshell.util.element.get_aggregate(element)
|
||||
or ifcopenshell.util.element.get_nest(element)
|
||||
or ifcopenshell.util.element.get_filled_void(element)
|
||||
or ifcopenshell.util.element.get_voided_element(element)
|
||||
):
|
||||
element = parent
|
||||
else:
|
||||
|
||||
@@ -488,24 +488,6 @@ class System(bonsai.core.tool.System):
|
||||
def is_mep_element(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
return element.is_a("IfcFlowSegment") or element.is_a("IfcFlowFitting")
|
||||
|
||||
@classmethod
|
||||
def has_parametric_body(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
"""True when the MEP element's body representation is a profile sweep
|
||||
(``IfcExtrudedAreaSolid`` for segments, ``IfcSweptDiskSolid`` for
|
||||
fittings) — the shape the parametric edit + MEP action gizmos can
|
||||
actually mutate. Tessellation- or brep-imported MEP elements return
|
||||
False so their gizmos hide rather than offer edits the geometry
|
||||
kernel can't honour."""
|
||||
import bonsai.tool as tool
|
||||
|
||||
body = tool.Geometry.get_body_representation(element)
|
||||
if body is None:
|
||||
return False
|
||||
for item in tool.Ifc.get().traverse(body):
|
||||
if item.is_a("IfcExtrudedAreaSolid") or item.is_a("IfcSweptDiskSolid"):
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def walk_connected_mep_elements(
|
||||
cls, start_element: ifcopenshell.entity_instance
|
||||
|
||||
@@ -687,10 +687,8 @@ Scenario: Saving with a door mid-edit auto-commits the draft value to the IFC ps
|
||||
Then "active_object.BIMDoorProperties.is_editing" is "True"
|
||||
When I press "bim.save_project(filepath='{temp_project_path}', should_save_as=True)"
|
||||
Then "active_object.BIMDoorProperties.is_editing" is "False"
|
||||
# BBIM_<Type> psets store project units, not raw Blender SI. The empty project
|
||||
# used in an_empty_blender_session is METRIC_MM, so 2.5 m → 2500 mm in the pset.
|
||||
And the variable "saved_height" is "__import__('json').loads(ifcopenshell.util.element.get_pset({ifc}.by_type('IfcDoor')[0], 'BBIM_Door', 'Data'))['overall_height']"
|
||||
And the variable "saved_height" equals "2500.0"
|
||||
And the variable "saved_height" equals "2.5"
|
||||
|
||||
Scenario: Saving with no parametric edits in progress leaves the door pset unchanged
|
||||
Given an empty IFC project
|
||||
@@ -707,10 +705,14 @@ Scenario: Saving with no parametric edits in progress leaves the door pset uncha
|
||||
|
||||
Scenario: Saving with a wall mid-edit auto-commits the draft to IFC
|
||||
Given an empty IFC project
|
||||
And I load the demo construction library
|
||||
And I add a cube
|
||||
And the object "Cube" is selected
|
||||
And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType"
|
||||
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
|
||||
And I press "bim.assign_class"
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
|
||||
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
|
||||
And the variable "cube" is "{ifc}.by_type('IfcWallType')[0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{cube}"
|
||||
And I press "bim.add_occurrence"
|
||||
And the object "IfcWall/Wall" is selected
|
||||
And I press "bim.enable_editing_wall()"
|
||||
@@ -720,18 +722,21 @@ Scenario: Saving with a wall mid-edit auto-commits the draft to IFC
|
||||
|
||||
Scenario: Enabling and finishing a wall edit with no drag is a no-op
|
||||
Given an empty IFC project
|
||||
And I load the demo construction library
|
||||
And I add a cube
|
||||
And the object "Cube" is selected
|
||||
And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType"
|
||||
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
|
||||
And I press "bim.assign_class"
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
|
||||
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
|
||||
And the variable "cube" is "{ifc}.by_type('IfcWallType')[0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{cube}"
|
||||
And I press "bim.add_occurrence"
|
||||
And the object "IfcWall/Wall" is selected
|
||||
And the variable "entity_count_before" is "len(list({ifc}))"
|
||||
When I press "bim.enable_editing_wall()"
|
||||
And I press "bim.finish_editing_wall()"
|
||||
Then "active_object.BIMWallProperties.is_editing" is "False"
|
||||
And the variable "entity_count_after" is "len(list({ifc}))"
|
||||
And the variable "entity_count_after" equals "{entity_count_before}"
|
||||
And "len(list({ifc}))" is "{entity_count_before}"
|
||||
|
||||
Scenario: Cancelling a wall edit clears is_editing
|
||||
Given an empty IFC project
|
||||
@@ -751,10 +756,14 @@ Scenario: Cancelling a wall edit clears is_editing
|
||||
|
||||
Scenario: Wall parametric edit works on IFC2X3 projects
|
||||
Given an empty IFC2X3 project
|
||||
And I load the demo construction library
|
||||
And I add a cube
|
||||
And the object "Cube" is selected
|
||||
And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType"
|
||||
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
|
||||
And I press "bim.assign_class"
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
|
||||
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
|
||||
And the variable "cube" is "{ifc}.by_type('IfcWallType')[0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{cube}"
|
||||
And I press "bim.add_occurrence"
|
||||
And the object "IfcWall/Wall" is selected
|
||||
When I press "bim.enable_editing_wall()"
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2021 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
@@ -1,61 +0,0 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Forward-compat AST contract: ``HasShapeAspects`` is an IFC4+ inverse;
|
||||
direct attribute access raises ``AttributeError`` on pre-IFC4 entity
|
||||
instances. Production code must read it through ``getattr`` so the
|
||||
absence in earlier schemas degrades to an empty iterable."""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.geometry
|
||||
|
||||
|
||||
BONSAI_ROOT = Path(__file__).parent.parent.parent.parent.parent / "bonsai"
|
||||
PRODUCTION_DIRS = (BONSAI_ROOT / "bim", BONSAI_ROOT / "tool", BONSAI_ROOT / "core")
|
||||
|
||||
ATTR_NAME = "HasShapeAspects"
|
||||
|
||||
|
||||
def _iter_production_sources():
|
||||
for root in PRODUCTION_DIRS:
|
||||
yield from root.rglob("*.py")
|
||||
|
||||
|
||||
def test_has_shape_aspects_access_uses_getattr_guard():
|
||||
"""Every read of ``HasShapeAspects`` in production code must go through
|
||||
``getattr(<expr>, "HasShapeAspects", <default>)`` so files using
|
||||
schemas that omit the inverse return the default instead of raising."""
|
||||
offenders = []
|
||||
for source in _iter_production_sources():
|
||||
tree = ast.parse(source.read_text(encoding="utf-8"))
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Attribute) and node.attr == ATTR_NAME:
|
||||
offenders.append(f"{source.relative_to(BONSAI_ROOT.parent)}:{node.lineno}")
|
||||
if offenders:
|
||||
joined = "\n ".join(sorted(offenders))
|
||||
pytest.fail(
|
||||
f"Direct .{ATTR_NAME} attribute access in production code:\n {joined}\n"
|
||||
f"Wrap with getattr(<expr>, '{ATTR_NAME}', ()) so pre-IFC4 schemas "
|
||||
f"do not raise AttributeError."
|
||||
)
|
||||
@@ -1,228 +0,0 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Shared fixtures and factories for ``test/bim/module/model/`` gizmo and
|
||||
decorator tests.
|
||||
|
||||
The boundary between Blender / IFC / Bonsai's ``tool.*`` layer is patched
|
||||
identically across many model-test files (viewport-state, selection, IFC
|
||||
entity lookup, modifier predicates, view-camera state). The ``patched_tool``
|
||||
fixture below centralises that patch stack so each test names only the
|
||||
boundary methods it cares about; everything else is left to production.
|
||||
|
||||
Factory helpers (``make_obj``, ``make_element``, ``make_context``,
|
||||
``make_ifc_file``) replace near-identical local helpers that previously
|
||||
lived in each file.
|
||||
|
||||
When to use these fixtures in a new test file:
|
||||
|
||||
- Adding a gizmo / decorator test that patches ``tool.Blender`` or
|
||||
``tool.Ifc`` boundary methods? Request the ``patched_tool`` fixture
|
||||
as a test parameter and call it as a context-manager factory.
|
||||
- Need a stub ``bpy.types.Object`` / ``ifcopenshell.entity_instance`` /
|
||||
``poll()`` context / ``ifcopenshell.file``? Import the matching factory
|
||||
from this module rather than re-rolling locally.
|
||||
- Need to reset module-level state (e.g. a decorator cache token) between
|
||||
tests? Define an ``@pytest.fixture(autouse=True)`` reset in the test
|
||||
file itself — these stay file-local because they target state specific
|
||||
to one decorator/module and globalising the reset would surprise
|
||||
unrelated tests.
|
||||
|
||||
Layout note: pure helpers (``make_*``) live alongside the fixture in this
|
||||
file rather than a sibling ``test_utils.py``. pytest's documented role for
|
||||
``conftest.py`` is fixtures, so this is a mild convention bend — kept here
|
||||
because the helper count is small and the dependencies (``tool``, ``Mock``)
|
||||
already need to be imported for the fixture itself. Split into a separate
|
||||
module if the helper count grows past ~6 or any helper picks up its own
|
||||
non-trivial dependencies."""
|
||||
|
||||
import contextlib
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import pytest
|
||||
|
||||
from bonsai import tool
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _require_real_bpy():
|
||||
"""Skip every test in this directory when ``bpy`` is mocked or absent.
|
||||
|
||||
The model gizmo / decorator suite reaches into Blender's RNA layer
|
||||
(``bpy.types.Operator``, registered ``bl_idname`` lookups, ``Modifier``
|
||||
predicates) that ``Mock`` cannot impersonate, so a tool-lane run with a
|
||||
stubbed ``bpy`` would error rather than meaningfully exercise the
|
||||
contract. The autouse scope means new test files added under this
|
||||
directory inherit the gate without re-declaring it."""
|
||||
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
|
||||
pytest.skip("requires real Blender (bpy is mocked or absent)")
|
||||
|
||||
|
||||
def make_obj(*, session_uid=None, selected=True, **attrs):
|
||||
"""Mock a ``bpy.types.Object`` with attributes commonly read by gizmos.
|
||||
|
||||
``session_uid`` is set only when provided so tests that don't care about
|
||||
object identity (most poll() tests use ``object()`` sentinels) can use
|
||||
``make_obj()`` without a spurious uid. ``selected`` wires ``select_get()``
|
||||
to return the given boolean. Extra attrs are set as plain attributes.
|
||||
|
||||
A bare ``Mock()`` is required because ``Mock(spec=bpy.types.Object)``
|
||||
rejects ``select_get`` — Blender's C-registered methods aren't exposed
|
||||
to Python introspection."""
|
||||
obj = Mock()
|
||||
if session_uid is not None:
|
||||
obj.session_uid = session_uid
|
||||
obj.select_get.return_value = selected
|
||||
for name, value in attrs.items():
|
||||
setattr(obj, name, value)
|
||||
return obj
|
||||
|
||||
|
||||
def make_element(step_id=None, *, ifc_class=None, **attrs):
|
||||
"""Mock an ``ifcopenshell.entity_instance`` with the surfaces gizmos read.
|
||||
|
||||
``step_id`` populates ``element.id()``. ``ifc_class`` wires ``is_a(name)``
|
||||
to return True only when ``name == ifc_class``. Extra kwargs become plain
|
||||
attributes (e.g. ``HasOpenings=()``)."""
|
||||
element = Mock()
|
||||
if step_id is not None:
|
||||
element.id.return_value = step_id
|
||||
if ifc_class is not None:
|
||||
element.is_a.side_effect = lambda type_name: type_name == ifc_class
|
||||
for name, value in attrs.items():
|
||||
setattr(element, name, value)
|
||||
return element
|
||||
|
||||
|
||||
def make_context(*, active=None, selected=(), scene=None):
|
||||
"""``SimpleNamespace`` stub with the ``poll()`` reads tests exercise:
|
||||
``active_object``, ``selected_objects``, and ``scene``. ``selected`` is
|
||||
materialised to a list so tests can iterate without re-walking a generator.
|
||||
``scene`` defaults to an empty namespace so guards that walk
|
||||
``context.scene.BIMPreviewProperties`` (via ``getattr(..., default=None)``)
|
||||
treat the preview as inactive — pass a custom namespace to activate."""
|
||||
return SimpleNamespace(
|
||||
active_object=active,
|
||||
selected_objects=list(selected),
|
||||
scene=scene if scene is not None else SimpleNamespace(),
|
||||
)
|
||||
|
||||
|
||||
def make_ifc_file(elements_by_guid: dict | None = None) -> MagicMock:
|
||||
"""Mock ``ifcopenshell.file`` with ``spec=`` so attribute typos surface as
|
||||
``AttributeError`` instead of silently auto-creating a child mock.
|
||||
|
||||
When ``elements_by_guid`` is given, ``by_guid`` is wired to look up the
|
||||
mapping and raise ``RuntimeError`` on a missing guid — same shape as the
|
||||
real ifcopenshell.file behaviour, so a test that depends on orphan handling
|
||||
sees an exception rather than a silent ``None``."""
|
||||
f = MagicMock(spec=ifcopenshell.file, name="ifc_file")
|
||||
if elements_by_guid is not None:
|
||||
|
||||
def _by_guid(guid):
|
||||
try:
|
||||
return elements_by_guid[guid]
|
||||
except KeyError:
|
||||
raise RuntimeError(f"no entity with guid {guid}")
|
||||
|
||||
f.by_guid.side_effect = _by_guid
|
||||
return f
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_tool():
|
||||
"""Context-manager factory for the ``tool.*`` boundary patches that nearly
|
||||
every gizmo / decorator test repeats. Use as::
|
||||
|
||||
with patched_tool(viewport_gizmos=True, selected=[obj_a, obj_b],
|
||||
modifier_predicates={"is_wall": True}):
|
||||
GizmoFoo.poll(context)
|
||||
|
||||
Only the kwargs you pass are patched — anything left as ``None`` (or
|
||||
omitted) keeps production behaviour. Values can be:
|
||||
|
||||
- ``viewport_gizmos`` / ``view_top_down`` / ``addon_prefs``: passed to
|
||||
``return_value=`` of the corresponding patch.
|
||||
- ``selected``: wrapped in ``set(...)`` for ``get_selected_objects``
|
||||
(matches the production return type for ``poll()``-side reads).
|
||||
- ``selected_list``: as-is for ``get_selected_objects`` when order
|
||||
matters (some operators iterate it). Mutually exclusive with
|
||||
``selected`` — if both are passed, ``selected`` wins and
|
||||
``selected_list`` is ignored. Pass only one.
|
||||
- ``entity``: either a callable (used as ``side_effect``) or a single
|
||||
value (used as ``return_value``).
|
||||
- ``modifier_predicates``: dict ``{predicate_name: bool_or_callable}``.
|
||||
Callables are wired as ``side_effect``, bools as ``return_value``.
|
||||
- ``screen_up``: ``return_value`` for ``get_screen_up_world``.
|
||||
|
||||
Patches close on context-manager exit via an ``ExitStack`` — no
|
||||
``try/finally`` bookkeeping in the test body."""
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _factory(
|
||||
*,
|
||||
viewport_gizmos=None,
|
||||
addon_prefs=None,
|
||||
selected=None,
|
||||
selected_list=None,
|
||||
entity=None,
|
||||
modifier_predicates=None,
|
||||
view_top_down=None,
|
||||
screen_up=None,
|
||||
):
|
||||
with contextlib.ExitStack() as stack:
|
||||
if viewport_gizmos is not None:
|
||||
stack.enter_context(
|
||||
patch.object(tool.Blender, "are_viewport_gizmos_enabled", return_value=viewport_gizmos)
|
||||
)
|
||||
if addon_prefs is not None:
|
||||
stack.enter_context(patch.object(tool.Blender, "get_addon_preferences", return_value=addon_prefs))
|
||||
if selected is not None:
|
||||
stack.enter_context(patch.object(tool.Blender, "get_selected_objects", return_value=set(selected)))
|
||||
elif selected_list is not None:
|
||||
stack.enter_context(
|
||||
patch.object(tool.Blender, "get_selected_objects", return_value=list(selected_list))
|
||||
)
|
||||
if entity is not None:
|
||||
if callable(entity):
|
||||
stack.enter_context(patch.object(tool.Ifc, "get_entity", side_effect=entity))
|
||||
else:
|
||||
stack.enter_context(patch.object(tool.Ifc, "get_entity", return_value=entity))
|
||||
if modifier_predicates:
|
||||
for name, value in modifier_predicates.items():
|
||||
# Parametric feature-kind predicates live on tool.Parametric; the
|
||||
# remaining cardinality / non-parametric predicates (is_array_child,
|
||||
# is_slab, is_eligible_for_*) stay on tool.Blender.Modifier.
|
||||
target = tool.Parametric if hasattr(tool.Parametric, name) else tool.Blender.Modifier
|
||||
if callable(value):
|
||||
stack.enter_context(patch.object(target, name, side_effect=value))
|
||||
else:
|
||||
stack.enter_context(patch.object(target, name, return_value=value))
|
||||
if view_top_down is not None:
|
||||
stack.enter_context(patch.object(tool.Blender, "is_view_top_down", return_value=view_top_down))
|
||||
if screen_up is not None:
|
||||
stack.enter_context(patch.object(tool.Blender, "get_screen_up_world", return_value=screen_up))
|
||||
yield
|
||||
|
||||
return _factory
|
||||
@@ -1,85 +0,0 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Forward-compat AST contract: decorators do not triangulate in-place.
|
||||
|
||||
``bmesh.ops.triangulate(bm, faces=bm.faces)`` mutates its input — adding tri
|
||||
edges and faces — and uses ear-clip fan triangulation that renders as visible
|
||||
streaks across n-gon faces at the low alphas decorators favour. The canonical
|
||||
draw path is ``tool.Blender.draw_bmesh_face_tris`` (wraps ``bm.calc_loop_triangles``,
|
||||
non-mutating, beauty triangulator)."""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
BONSAI_ROOT = Path(__file__).parent.parent.parent / "bonsai"
|
||||
BIM_MODULE_DIR = BONSAI_ROOT / "bim" / "module"
|
||||
|
||||
|
||||
def _iter_guarded_files():
|
||||
yield from sorted(BIM_MODULE_DIR.glob("*/decorator.py"))
|
||||
yield BIM_MODULE_DIR / "model" / "opening.py"
|
||||
|
||||
|
||||
def _is_guarded_class(node: ast.ClassDef) -> bool:
|
||||
return node.name.endswith("Decorator") or node.name == "DecorationsHandler"
|
||||
|
||||
|
||||
def _is_mutating_triangulate_call(node: ast.AST) -> bool:
|
||||
if not isinstance(node, ast.Call):
|
||||
return False
|
||||
func = node.func
|
||||
if not isinstance(func, ast.Attribute) or func.attr != "triangulate":
|
||||
return False
|
||||
receiver = func.value
|
||||
if not isinstance(receiver, ast.Attribute) or receiver.attr != "ops":
|
||||
return False
|
||||
inner = receiver.value
|
||||
return isinstance(inner, ast.Name) and inner.id == "bmesh"
|
||||
|
||||
|
||||
def test_no_decorator_calls_bmesh_ops_triangulate() -> None:
|
||||
violations: list[str] = []
|
||||
guarded_files = list(_iter_guarded_files())
|
||||
assert guarded_files, "Search root contains no decorator modules — test needs updating."
|
||||
|
||||
for path in guarded_files:
|
||||
try:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
except (SyntaxError, FileNotFoundError):
|
||||
continue
|
||||
for class_node in (n for n in ast.walk(tree) if isinstance(n, ast.ClassDef)):
|
||||
if not _is_guarded_class(class_node):
|
||||
continue
|
||||
for sub in ast.walk(class_node):
|
||||
if _is_mutating_triangulate_call(sub):
|
||||
violations.append(f"{path}:{sub.lineno} {class_node.name} calls bmesh.ops.triangulate")
|
||||
|
||||
assert not violations, (
|
||||
"Decorator classes must not call bmesh.ops.triangulate — it mutates "
|
||||
"the input bmesh and produces fan-clip artefacts at low alpha. "
|
||||
"Use tool.Blender.draw_bmesh_face_tris (wraps bm.calc_loop_triangles). "
|
||||
"Violations:\n " + "\n ".join(violations)
|
||||
)
|
||||
@@ -1,183 +0,0 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Contract tests for the door swing-arc readonly decorator.
|
||||
|
||||
Two layers:
|
||||
|
||||
- Pure tests on ``_visible_arcs`` pin the readonly decorator's arc selection
|
||||
per ``door_type`` enum value.
|
||||
- A forward-compat guard walks ``GizmoDoorEdition.swing_arc_props`` and
|
||||
asserts the readonly decorator picks the same arcs (hinge / width / mirror)
|
||||
the edit-mode gizmo would, so the two surfaces stay visually identical
|
||||
even when a new ``door_type`` is added."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import get_args
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# _visible_arcs — per-door-type arc selection
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _arcs(door_type, overall_width=0.9, lining_offset=0.05):
|
||||
from bonsai.bim.module.model.decorator import _visible_arcs
|
||||
|
||||
return _visible_arcs(door_type, overall_width, lining_offset)
|
||||
|
||||
|
||||
def test_single_swing_left_one_arc_hinged_at_origin():
|
||||
arcs = _arcs("SINGLE_SWING_LEFT")
|
||||
assert len(arcs) == 1
|
||||
arc = arcs[0]
|
||||
assert arc.hinge_x == pytest.approx(0.0)
|
||||
assert arc.hinge_y == pytest.approx(0.05)
|
||||
assert arc.panel_width == pytest.approx(0.9)
|
||||
assert arc.x_mirror is False
|
||||
|
||||
|
||||
def test_single_swing_right_one_arc_hinged_at_right_edge_x_mirrored():
|
||||
arcs = _arcs("SINGLE_SWING_RIGHT")
|
||||
assert len(arcs) == 1
|
||||
arc = arcs[0]
|
||||
assert arc.hinge_x == pytest.approx(0.9)
|
||||
assert arc.panel_width == pytest.approx(0.9)
|
||||
assert arc.x_mirror is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("door_type", ["DOUBLE_SWING_LEFT", "DOUBLE_SWING_RIGHT"])
|
||||
def test_double_swing_shares_recipe_with_single_swing(door_type):
|
||||
# DOUBLE_SWING_* is still a single panel (the hinge is on one side,
|
||||
# the panel swings both ways) — visually identical to SINGLE_SWING_*.
|
||||
single_type = door_type.replace("DOUBLE_SWING", "SINGLE_SWING")
|
||||
assert _arcs(door_type) == _arcs(single_type)
|
||||
|
||||
|
||||
def test_double_door_single_swing_emits_two_half_width_arcs():
|
||||
arcs = _arcs("DOUBLE_DOOR_SINGLE_SWING")
|
||||
assert len(arcs) == 2
|
||||
left, right = arcs
|
||||
assert left.hinge_x == pytest.approx(0.0)
|
||||
assert left.panel_width == pytest.approx(0.45)
|
||||
assert left.x_mirror is False
|
||||
assert right.hinge_x == pytest.approx(0.9)
|
||||
assert right.panel_width == pytest.approx(0.45)
|
||||
assert right.x_mirror is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("door_type", ["SLIDING_TO_LEFT", "SLIDING_TO_RIGHT", "DOUBLE_DOOR_SLIDING"])
|
||||
def test_sliding_doors_emit_no_arcs(door_type):
|
||||
assert _arcs(door_type) == []
|
||||
|
||||
|
||||
def test_unknown_door_type_falls_back_to_single_left_swing_arc():
|
||||
# Only ``"SLIDING"`` substrings short-circuit the swing predicate; any
|
||||
# other novel ``door_type`` falls through to the default left-hinged arc.
|
||||
arcs = _arcs("FUTURE_OPERATION_TYPE_42")
|
||||
assert len(arcs) == 1
|
||||
arc = arcs[0]
|
||||
assert arc.hinge_x == pytest.approx(0.0)
|
||||
assert arc.panel_width == pytest.approx(0.9)
|
||||
assert arc.x_mirror is False
|
||||
|
||||
|
||||
def test_lining_offset_drives_hinge_y_for_every_visible_arc():
|
||||
for door_type in ("SINGLE_SWING_LEFT", "SINGLE_SWING_RIGHT", "DOUBLE_DOOR_SINGLE_SWING"):
|
||||
for arc in _arcs(door_type, overall_width=0.9, lining_offset=0.12):
|
||||
assert arc.hinge_y == pytest.approx(0.12)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Forward-compat: readonly decorator and edit-mode gizmo agree per door_type
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _gizmo_expected(door_type, overall_width, lining_offset):
|
||||
"""What ``GizmoDoorEdition.swing_arc_props`` would render for the props
|
||||
snapshot, with ``is_editing=True`` so its visibility predicates pass."""
|
||||
from bonsai.bim.module.model.door import GizmoDoorEdition
|
||||
|
||||
props = SimpleNamespace(
|
||||
door_type=door_type,
|
||||
overall_width=overall_width,
|
||||
lining_offset=lining_offset,
|
||||
is_editing=True,
|
||||
)
|
||||
expected = []
|
||||
for cfg in GizmoDoorEdition.swing_arc_props:
|
||||
if cfg.visibility_condition(props):
|
||||
expected.append(
|
||||
(
|
||||
cfg.hinge_x(props),
|
||||
cfg.hinge_y(props),
|
||||
cfg.panel_width(props),
|
||||
cfg.x_mirror(props),
|
||||
)
|
||||
)
|
||||
return expected
|
||||
|
||||
|
||||
def test_visible_arcs_matches_gizmo_swing_arc_props_for_every_door_type():
|
||||
import bonsai.tool as tool
|
||||
|
||||
overall_width, lining_offset = 0.9, 0.05
|
||||
for door_type in get_args(tool.Model.DoorType):
|
||||
expected = _gizmo_expected(door_type, overall_width, lining_offset)
|
||||
actual = _arcs(door_type, overall_width, lining_offset)
|
||||
actual_tuples = [(a.hinge_x, a.hinge_y, a.panel_width, a.x_mirror) for a in actual]
|
||||
assert actual_tuples == expected, (
|
||||
f"Readonly decorator drifted from edit-mode gizmo for {door_type!r}: "
|
||||
f"expected {expected}, got {actual_tuples}"
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Decorator gating contract (draw() early-returns)
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_decorator_stub():
|
||||
"""Build a fresh ``DoorSwingReadonlyDecorator`` instance without going
|
||||
through ``install`` (which would attach a draw handler)."""
|
||||
from bonsai.bim.module.model.decorator import DoorSwingReadonlyDecorator
|
||||
|
||||
return DoorSwingReadonlyDecorator()
|
||||
|
||||
|
||||
def _draw_with_active(decorator, active_obj):
|
||||
"""Call ``draw`` with a minimal ``context`` stub."""
|
||||
ctx = SimpleNamespace(active_object=active_obj)
|
||||
decorator.draw(ctx)
|
||||
|
||||
|
||||
def test_draw_early_returns_when_no_active_object():
|
||||
# Should not raise; nothing to draw.
|
||||
_draw_with_active(_make_decorator_stub(), None)
|
||||
|
||||
|
||||
def test_draw_early_returns_when_active_not_selected():
|
||||
obj = SimpleNamespace(select_get=lambda: False)
|
||||
_draw_with_active(_make_decorator_stub(), obj)
|
||||
@@ -1,212 +0,0 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Contract tests for the door swing-arc gizmo positioning.
|
||||
|
||||
Each test calls ``GizmoDoorEdition.update_swing_gizmos`` as an unbound method
|
||||
against a SimpleNamespace stand-in that records ``matrix_basis`` assignments and
|
||||
``hide`` flags. The expected matrices are recomputed from first principles so
|
||||
the tests describe the geometric contract directly rather than echoing the
|
||||
implementation."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
def _make_props(door_type, overall_width=0.9, lining_offset=0.0, is_editing=True):
|
||||
return SimpleNamespace(
|
||||
door_type=door_type,
|
||||
overall_width=overall_width,
|
||||
lining_offset=lining_offset,
|
||||
is_editing=is_editing,
|
||||
)
|
||||
|
||||
|
||||
def _make_fake_group():
|
||||
"""Stand-in for ``GizmoDoorEdition``: one MagicMock per declared arc gizmo
|
||||
plus a stub ``update_gizmo_visibility`` that records the visibility flag on
|
||||
each mock's ``hide`` attribute."""
|
||||
from bonsai.bim.module.model.door import GizmoDoorEdition
|
||||
|
||||
fake = SimpleNamespace()
|
||||
fake.swing_arc_props = GizmoDoorEdition.swing_arc_props
|
||||
|
||||
def update_gizmo_visibility(gizmo, is_visible):
|
||||
gizmo.hide = not is_visible
|
||||
return is_visible
|
||||
|
||||
fake.update_gizmo_visibility = update_gizmo_visibility
|
||||
|
||||
for cfg in fake.swing_arc_props:
|
||||
setattr(fake, f"gizmo_swing_arc_{cfg.name}", MagicMock(spec=["matrix_basis", "hide"]))
|
||||
setattr(fake, f"gizmo_swing_arc_{cfg.name}_flip", MagicMock(spec=["matrix_basis", "hide"]))
|
||||
|
||||
return fake
|
||||
|
||||
|
||||
def _call_update(fake, props, mw=None):
|
||||
from bonsai.bim.module.model.door import GizmoDoorEdition
|
||||
|
||||
GizmoDoorEdition.update_swing_gizmos(fake, mw or Matrix.Identity(4), props)
|
||||
|
||||
|
||||
def _matrix_approx(actual, expected, abs_tol=1e-6):
|
||||
assert isinstance(actual, Matrix), f"matrix_basis was never assigned (got {type(actual).__name__})"
|
||||
for i in range(4):
|
||||
for j in range(4):
|
||||
assert actual[i][j] == pytest.approx(expected[i][j], abs=abs_tol), (
|
||||
f"Mismatch at [{i}][{j}]: got {actual[i][j]}, expected {expected[i][j]}\n"
|
||||
f"actual=\n{actual}\nexpected=\n{expected}"
|
||||
)
|
||||
|
||||
|
||||
_MIRROR_X = Matrix.Scale(-1, 4, (1, 0, 0))
|
||||
_MIRROR_Y = Matrix.Scale(-1, 4, (0, 1, 0))
|
||||
|
||||
|
||||
def test_single_swing_left_primary_arc_hinges_at_left_edge():
|
||||
"""Left-hinged single-swing: primary arc at (0, lining_offset), scaled to
|
||||
overall_width, no X-mirror. Flip arc same transform composed with Y-mirror.
|
||||
Secondary panel hidden."""
|
||||
fake = _make_fake_group()
|
||||
props = _make_props(door_type="SINGLE_SWING_LEFT", overall_width=0.9, lining_offset=0.05)
|
||||
_call_update(fake, props)
|
||||
|
||||
expected = Matrix.Translation(Vector((0.0, 0.05, 0.0))) @ Matrix.Scale(0.9, 4)
|
||||
_matrix_approx(fake.gizmo_swing_arc_primary.matrix_basis, expected)
|
||||
_matrix_approx(fake.gizmo_swing_arc_primary_flip.matrix_basis, expected @ _MIRROR_Y)
|
||||
assert fake.gizmo_swing_arc_secondary.hide is True
|
||||
assert fake.gizmo_swing_arc_secondary_flip.hide is True
|
||||
|
||||
|
||||
def test_single_swing_right_primary_arc_hinges_at_right_edge_with_x_mirror():
|
||||
"""Right-hinged single-swing: primary arc anchored at (overall_width, lining_offset)
|
||||
with an X-mirror applied so the arc sweeps back over the door panel rather
|
||||
than extending past the right edge."""
|
||||
fake = _make_fake_group()
|
||||
props = _make_props(door_type="SINGLE_SWING_RIGHT", overall_width=0.9, lining_offset=0.05)
|
||||
_call_update(fake, props)
|
||||
|
||||
expected = Matrix.Translation(Vector((0.9, 0.05, 0.0))) @ Matrix.Scale(0.9, 4) @ _MIRROR_X
|
||||
_matrix_approx(fake.gizmo_swing_arc_primary.matrix_basis, expected)
|
||||
_matrix_approx(fake.gizmo_swing_arc_primary_flip.matrix_basis, expected @ _MIRROR_Y)
|
||||
assert fake.gizmo_swing_arc_secondary.hide is True
|
||||
assert fake.gizmo_swing_arc_secondary_flip.hide is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("double_type", "single_type"),
|
||||
[
|
||||
("DOUBLE_SWING_LEFT", "SINGLE_SWING_LEFT"),
|
||||
("DOUBLE_SWING_RIGHT", "SINGLE_SWING_RIGHT"),
|
||||
],
|
||||
)
|
||||
def test_double_swing_uses_same_recipe_as_single_swing(double_type, single_type):
|
||||
"""DOUBLE_SWING_* (one panel that can open both ways) shares the
|
||||
single-panel positioning recipe with its SINGLE_SWING_* counterpart."""
|
||||
fake_a = _make_fake_group()
|
||||
fake_b = _make_fake_group()
|
||||
props_a = _make_props(door_type=double_type, overall_width=0.9, lining_offset=0.05)
|
||||
props_b = _make_props(door_type=single_type, overall_width=0.9, lining_offset=0.05)
|
||||
_call_update(fake_a, props_a)
|
||||
_call_update(fake_b, props_b)
|
||||
|
||||
_matrix_approx(
|
||||
fake_a.gizmo_swing_arc_primary.matrix_basis,
|
||||
fake_b.gizmo_swing_arc_primary.matrix_basis,
|
||||
)
|
||||
_matrix_approx(
|
||||
fake_a.gizmo_swing_arc_primary_flip.matrix_basis,
|
||||
fake_b.gizmo_swing_arc_primary_flip.matrix_basis,
|
||||
)
|
||||
|
||||
|
||||
def test_double_door_shows_four_arcs_each_scaled_to_half_door_width():
|
||||
"""DOUBLE_DOOR_SINGLE_SWING: left panel hinged at x=0, right panel hinged
|
||||
at x=overall_width with X-mirror, both scaled to overall_width/2. Each
|
||||
panel also gets a Y-mirrored flip arc — 4 arcs total."""
|
||||
fake = _make_fake_group()
|
||||
props = _make_props(door_type="DOUBLE_DOOR_SINGLE_SWING", overall_width=1.6, lining_offset=0.0)
|
||||
_call_update(fake, props)
|
||||
|
||||
half = 1.6 / 2
|
||||
expected_primary = Matrix.Translation(Vector((0.0, 0.0, 0.0))) @ Matrix.Scale(half, 4)
|
||||
expected_secondary = Matrix.Translation(Vector((1.6, 0.0, 0.0))) @ Matrix.Scale(half, 4) @ _MIRROR_X
|
||||
|
||||
_matrix_approx(fake.gizmo_swing_arc_primary.matrix_basis, expected_primary)
|
||||
_matrix_approx(fake.gizmo_swing_arc_primary_flip.matrix_basis, expected_primary @ _MIRROR_Y)
|
||||
_matrix_approx(fake.gizmo_swing_arc_secondary.matrix_basis, expected_secondary)
|
||||
_matrix_approx(fake.gizmo_swing_arc_secondary_flip.matrix_basis, expected_secondary @ _MIRROR_Y)
|
||||
|
||||
for cfg in fake.swing_arc_props:
|
||||
assert getattr(fake, f"gizmo_swing_arc_{cfg.name}").hide is False
|
||||
assert getattr(fake, f"gizmo_swing_arc_{cfg.name}_flip").hide is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("door_type", ["SLIDING_TO_LEFT", "SLIDING_TO_RIGHT", "DOUBLE_DOOR_SLIDING"])
|
||||
def test_sliding_door_types_hide_all_arcs(door_type):
|
||||
"""Sliding doors don't swing — every arc in ``swing_arc_props`` is hidden."""
|
||||
fake = _make_fake_group()
|
||||
props = _make_props(door_type=door_type, overall_width=0.9, lining_offset=0.0)
|
||||
_call_update(fake, props)
|
||||
|
||||
for cfg in fake.swing_arc_props:
|
||||
assert getattr(fake, f"gizmo_swing_arc_{cfg.name}").hide is True
|
||||
assert getattr(fake, f"gizmo_swing_arc_{cfg.name}_flip").hide is True
|
||||
|
||||
|
||||
def test_not_editing_hides_all_arcs():
|
||||
"""``is_editing=False`` collapses every arc's visibility, regardless of door type."""
|
||||
fake = _make_fake_group()
|
||||
props = _make_props(door_type="SINGLE_SWING_LEFT", overall_width=0.9, is_editing=False)
|
||||
_call_update(fake, props)
|
||||
|
||||
for cfg in fake.swing_arc_props:
|
||||
assert getattr(fake, f"gizmo_swing_arc_{cfg.name}").hide is True
|
||||
assert getattr(fake, f"gizmo_swing_arc_{cfg.name}_flip").hide is True
|
||||
|
||||
|
||||
def test_flip_arc_matrix_is_reassigned_each_refresh():
|
||||
"""The flip arc's ``matrix_basis`` must be (re-)assigned on every refresh
|
||||
so a stale identity matrix can never appear at the world origin."""
|
||||
fake = _make_fake_group()
|
||||
props = _make_props(door_type="SINGLE_SWING_LEFT", overall_width=0.9, lining_offset=0.1)
|
||||
_call_update(fake, props)
|
||||
|
||||
assert isinstance(fake.gizmo_swing_arc_primary_flip.matrix_basis, Matrix)
|
||||
assert fake.gizmo_swing_arc_primary_flip.matrix_basis != Matrix.Identity(4)
|
||||
|
||||
|
||||
def test_world_matrix_pre_multiplies_into_arc_transform():
|
||||
"""The caller's world matrix ``mw`` left-multiplies the per-panel transform:
|
||||
a translated ``mw`` shifts every arc by the same offset."""
|
||||
fake = _make_fake_group()
|
||||
props = _make_props(door_type="SINGLE_SWING_LEFT", overall_width=0.9, lining_offset=0.0)
|
||||
mw = Matrix.Translation(Vector((10.0, 20.0, 30.0)))
|
||||
_call_update(fake, props, mw=mw)
|
||||
|
||||
expected = mw @ Matrix.Translation(Vector((0.0, 0.0, 0.0))) @ Matrix.Scale(0.9, 4)
|
||||
_matrix_approx(fake.gizmo_swing_arc_primary.matrix_basis, expected)
|
||||
@@ -1,76 +0,0 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Tests for the universal pen-icon dispatcher's pre-edit warning path.
|
||||
|
||||
The dispatcher gates the parametric-edit triad behind a confirmation dialog
|
||||
whenever the active element's body representation is shared with sibling
|
||||
occurrences (typed product + mapped representation). It is the single
|
||||
chokepoint every feature's pen icon routes through, so the warning applies
|
||||
to walls, doors, windows, stairs, roofs, and any future feature uniformly.
|
||||
|
||||
These tests exercise:
|
||||
|
||||
- the pure ``should_show_shared_rep_dialog`` decision (every branch); and
|
||||
- one end-to-end invocation through ``bpy.ops`` to pin the wiring between
|
||||
the decision and ``invoke_props_dialog``."""
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
from bonsai.bim.module.model.array import EnableEditingParametric
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
class TestShouldShowSharedRepDialog:
|
||||
"""Exhaustive truth table for the pre-edit-warning decision. Keeping this
|
||||
pure (no bpy, no operator instance) means a future change to the dispatch
|
||||
wiring can't silently flip a branch — the decision is independently pinned."""
|
||||
|
||||
decide = staticmethod(EnableEditingParametric.should_show_shared_rep_dialog)
|
||||
|
||||
def test_shared_rep_with_warning_enabled_shows_dialog(self):
|
||||
assert self.decide(suppress=False, has_entity=True, sibling_count=3) is True
|
||||
|
||||
def test_unique_rep_skips_dialog(self):
|
||||
assert self.decide(suppress=False, has_entity=True, sibling_count=0) is False
|
||||
|
||||
def test_session_suppress_overrides_shared_rep(self):
|
||||
assert self.decide(suppress=True, has_entity=True, sibling_count=5) is False
|
||||
|
||||
def test_no_entity_skips_dialog_even_when_count_positive(self):
|
||||
assert self.decide(suppress=False, has_entity=False, sibling_count=3) is False
|
||||
|
||||
def test_zero_siblings_skips_dialog_regardless_of_suppress(self):
|
||||
assert self.decide(suppress=False, has_entity=True, sibling_count=0) is False
|
||||
assert self.decide(suppress=True, has_entity=True, sibling_count=0) is False
|
||||
|
||||
|
||||
def test_dispatcher_falls_through_to_feature_enable_op_when_no_active_object():
|
||||
"""End-to-end smoke: with no active object the dispatcher short-circuits to
|
||||
its ``execute`` body, which CANCELs on an empty ``feature_enable_op``."""
|
||||
bpy.context.window_manager.BIMParametricEditDialogPrefs.suppress_shared_rep_warning = False
|
||||
try:
|
||||
with bpy.context.temp_override(active_object=None):
|
||||
result = bpy.ops.bim.enable_editing_parametric("INVOKE_DEFAULT", feature_enable_op="")
|
||||
finally:
|
||||
bpy.context.window_manager.BIMParametricEditDialogPrefs.suppress_shared_rep_warning = False
|
||||
assert result == {"CANCELLED"}
|
||||
@@ -1,88 +0,0 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Behaviour contracts for the wall-fillet operator chain.
|
||||
|
||||
Each fillet operator's geometry path requires real Blender + IFC fixtures
|
||||
(walls with IfcMaterialLayerSetUsage, neighbour rels, etc.). End-to-end
|
||||
fillet round-trips belong in the bim feature suite (model.feature) where
|
||||
that scaffolding already exists. This file pins the surface-level invariants
|
||||
that don't depend on the geometry path:
|
||||
|
||||
* the lifecycle operators are registered under their conventional bl_idnames,
|
||||
* the enable poll rejects ineligible selections.
|
||||
|
||||
State-clearing tests via ``bpy.ops.bim.cancel_wall_fillet_preview()`` were
|
||||
removed because the dispatch is flaky in full-suite ordering — the operator
|
||||
early-returns when ``context.screen`` is unattached and prior tests can leave
|
||||
the screen in that state. The behaviour is covered by the user-visible live
|
||||
test loop instead."""
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
def _fillet_op_names():
|
||||
"""Walk bpy.ops.bim for operators whose name contains ``wall_fillet`` —
|
||||
avoids hard-coding the five lifecycle bl_idnames so adding / renaming
|
||||
one updates discovery automatically. Each name maps to a callable
|
||||
operator."""
|
||||
return sorted(name for name in dir(bpy.ops.bim) if "wall_fillet" in name)
|
||||
|
||||
|
||||
class TestFilletOperatorsRegistered:
|
||||
"""Catches accidental deregistration of any fillet lifecycle operator —
|
||||
drops in the classes tuple of bim/module/model/__init__.py would otherwise
|
||||
leave the gizmo group's target_set_operator binding pointing at a missing
|
||||
op and crash the first time a user clicked the icon."""
|
||||
|
||||
def test_at_least_the_expected_lifecycle_set_is_registered(self):
|
||||
names = _fillet_op_names()
|
||||
# The lifecycle has enable + finish + cancel as a minimum; a healthy
|
||||
# build also includes the from-corner re-edit entry and the create
|
||||
# operator the finish dispatches to. The test asserts at least four —
|
||||
# below that the feature can't function — without enumerating each
|
||||
# by name, so the test stays meaningful if one is renamed or merged.
|
||||
assert len(names) >= 4, (
|
||||
f"Only {len(names)} fillet operators found on bpy.ops.bim: {names}. "
|
||||
"The fillet lifecycle needs enable + finish + cancel + create at "
|
||||
"minimum; check bim/module/model/__init__.py classes tuple."
|
||||
)
|
||||
|
||||
def test_every_discovered_fillet_op_is_callable(self):
|
||||
for name in _fillet_op_names():
|
||||
op = getattr(bpy.ops.bim, name)
|
||||
assert callable(op), f"bpy.ops.bim.{name} is not callable — registration broke?"
|
||||
|
||||
|
||||
class TestEnableRejectsIneligibleSelection:
|
||||
"""The preview enable operator requires a specific 2-wall selection
|
||||
(LAYER2 walls with straight axes). With no selection at all, poll
|
||||
must return False so the operator is greyed-out in menus instead of
|
||||
crashing on dispatch."""
|
||||
|
||||
def test_enable_poll_returns_false_with_no_selection(self):
|
||||
# Deselect everything in the default scene; no IfcWall is present
|
||||
# in a fresh bpy_extras context anyway, so poll() must short-circuit.
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
bpy.context.view_layer.update()
|
||||
assert bpy.ops.bim.enable_wall_fillet_preview.poll() is False
|
||||
@@ -1,463 +0,0 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Poll + positioning tests for ``GizmoHostAddOpening``.
|
||||
|
||||
The gizmo dispatches on element type: walls keep the existing axis-projection
|
||||
math, while LAYER3 hosts (slabs, roofs) use a world-Z face bias derived from
|
||||
the void object's elevation. Each branch is exercised independently with
|
||||
mocks so the per-type contract is pinned without launching a full Blender
|
||||
modelling session."""
|
||||
|
||||
import contextlib
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
from test.bim.bootstrap import NewFile
|
||||
from test.bim.module.model.conftest import make_context
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# poll() — entry gate per host type and per co-selection shape
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_IFC_CLASS_BY_KIND = {
|
||||
"wall": "IfcWall",
|
||||
"slab": "IfcSlab",
|
||||
"roof": "IfcRoof",
|
||||
"plain": "IfcDiscreteAccessory",
|
||||
}
|
||||
|
||||
|
||||
class _FakeIfcEntity:
|
||||
"""Minimal stand-in for an ``ifcopenshell.entity_instance`` in poll tests.
|
||||
|
||||
Provides the two surfaces the gizmo's poll consults: ``is_a(type_name)``
|
||||
(used directly by ``is_supported_host`` for slab/roof) and an optional
|
||||
``HasOpenings`` attribute (probed by the poll's ``hasattr`` guard)."""
|
||||
|
||||
def __init__(self, ifc_class: str, has_openings: bool = True):
|
||||
self._ifc_class = ifc_class
|
||||
if has_openings:
|
||||
self.HasOpenings = ()
|
||||
|
||||
def is_a(self, type_name: str) -> bool:
|
||||
return self._ifc_class == type_name
|
||||
|
||||
|
||||
def _build_poll_callbacks(selected, active_kind, other_kind):
|
||||
"""Build the ``(get_entity, is_path_connectable_wall)`` side-effect
|
||||
callables that simulate one poll() invocation. ``active_kind`` /
|
||||
``other_kind`` accept ``"wall"``, ``"slab"``, ``"roof"``, ``"plain"``
|
||||
(non-host IFC element), ``"mesh"`` (no IFC entity), or ``None``
|
||||
(object outside the selection set).
|
||||
|
||||
Wall recognition goes through ``tool.Parametric.is_path_connectable_wall``
|
||||
so fillet-corner walls (which have no LAYER2 usage) also surface the
|
||||
add-opening icon; slab/roof use ``is_a`` on the fake entity so the
|
||||
broadened class-based predicate is exercised."""
|
||||
sentinels = {kind: _FakeIfcEntity(_IFC_CLASS_BY_KIND[kind]) for kind in _IFC_CLASS_BY_KIND}
|
||||
# The "plain" sentinel lacks HasOpenings so the hasattr guard branch
|
||||
# is reachable from the corresponding poll test.
|
||||
sentinels["plain"] = _FakeIfcEntity(_IFC_CLASS_BY_KIND["plain"], has_openings=False)
|
||||
|
||||
def entity_for(kind):
|
||||
if kind in (None, "mesh"):
|
||||
return None
|
||||
return sentinels[kind]
|
||||
|
||||
entity_map = {}
|
||||
if len(selected) >= 1:
|
||||
entity_map[id(selected[0])] = entity_for(active_kind)
|
||||
if len(selected) >= 2:
|
||||
entity_map[id(selected[1])] = entity_for(other_kind)
|
||||
|
||||
def get_entity(obj):
|
||||
return entity_map.get(id(obj))
|
||||
|
||||
def is_path_connectable_wall(element):
|
||||
return element is sentinels["wall"]
|
||||
|
||||
return get_entity, is_path_connectable_wall
|
||||
|
||||
|
||||
def _run_poll(
|
||||
patched_tool, prefs_on=True, n_selected=2, active_in_selected=True, active_kind="wall", other_kind="mesh"
|
||||
):
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import GizmoHostAddOpening
|
||||
|
||||
selected = [object() for _ in range(n_selected)]
|
||||
active = selected[0] if (active_in_selected and selected) else object()
|
||||
get_entity, is_path_connectable_wall = _build_poll_callbacks(selected, active_kind, other_kind)
|
||||
|
||||
with patched_tool(
|
||||
viewport_gizmos=prefs_on,
|
||||
selected=selected,
|
||||
entity=get_entity,
|
||||
modifier_predicates={"is_path_connectable_wall": is_path_connectable_wall},
|
||||
):
|
||||
return GizmoHostAddOpening.poll(make_context(active=active, selected=selected))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host_kind", ["wall", "slab", "roof"])
|
||||
def test_poll_accepts_each_host_with_a_plain_mesh_void(host_kind, patched_tool):
|
||||
assert _run_poll(patched_tool, active_kind=host_kind, other_kind="mesh") is True
|
||||
|
||||
|
||||
def test_poll_rejects_when_gizmo_toggle_off(patched_tool):
|
||||
assert _run_poll(patched_tool, prefs_on=False) is False
|
||||
|
||||
|
||||
def test_poll_rejects_when_selection_count_is_not_two(patched_tool):
|
||||
assert _run_poll(patched_tool, n_selected=1) is False
|
||||
assert _run_poll(patched_tool, n_selected=3) is False
|
||||
|
||||
|
||||
def test_poll_rejects_when_active_is_not_in_selection(patched_tool):
|
||||
assert _run_poll(patched_tool, active_in_selected=False) is False
|
||||
|
||||
|
||||
def test_poll_rejects_when_active_has_no_ifc_entity(patched_tool):
|
||||
assert _run_poll(patched_tool, active_kind="mesh") is False
|
||||
|
||||
|
||||
def test_poll_rejects_when_active_is_not_a_host(patched_tool):
|
||||
# "plain" sentinel is recognised as an IFC entity but is none of wall/slab/roof.
|
||||
assert _run_poll(patched_tool, active_kind="plain") is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"active_kind,other_kind",
|
||||
[
|
||||
("wall", "wall"), # wall-join gizmo owns this
|
||||
("slab", "slab"), # future slab-edit gizmo
|
||||
("roof", "roof"),
|
||||
("wall", "slab"), # extend-vertically gizmo overlaps with this
|
||||
("slab", "wall"),
|
||||
("roof", "wall"),
|
||||
],
|
||||
)
|
||||
def test_poll_rejects_host_host_pairs(active_kind, other_kind, patched_tool):
|
||||
"""Host + host pairings must be suppressed so the icon never stacks with
|
||||
the wall-join / extend-vertical / future slab-edit gizmos."""
|
||||
assert _run_poll(patched_tool, active_kind=active_kind, other_kind=other_kind) is False
|
||||
|
||||
|
||||
def test_poll_rejects_active_host_without_has_openings(patched_tool):
|
||||
# Real-world equivalent: an IFC class that the active schema strips
|
||||
# ``HasOpenings`` from (e.g., a non-element subtype). The active sentinel
|
||||
# is set up as a connectable wall but with no HasOpenings attribute.
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import GizmoHostAddOpening
|
||||
|
||||
selected = [object(), object()]
|
||||
active = selected[0]
|
||||
host_sentinel = object() # No HasOpenings attribute
|
||||
other_sentinel = None
|
||||
|
||||
with patched_tool(
|
||||
viewport_gizmos=True,
|
||||
selected=selected,
|
||||
entity=lambda o: host_sentinel if o is selected[0] else other_sentinel,
|
||||
modifier_predicates={"is_path_connectable_wall": lambda e: e is host_sentinel},
|
||||
):
|
||||
assert GizmoHostAddOpening.poll(make_context(active=active, selected=selected)) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# position_gizmos() — branch dispatch and per-branch anchor math
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _run_position_wall_branch(patched_tool, *, other_translation=(0.5, 0.0, 0.0), top_down=True):
|
||||
"""Drive the wall branch with stub IFC reads, returning the icon's
|
||||
matrix_basis translation."""
|
||||
from bonsai.bim.module.drawing import gizmos as gizmo_module
|
||||
from bonsai.bim.module.model import host_add_opening_gizmo as host_mod
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import GizmoHostAddOpening
|
||||
|
||||
geom = {"anchor_x": 0.0, "length": 2.0, "height": 3.0, "offset": 0.0, "thickness": 0.2}
|
||||
wall_element = object()
|
||||
active = SimpleNamespace(matrix_world=Matrix.Identity(4))
|
||||
other = SimpleNamespace(matrix_world=Matrix.Translation(Vector(other_translation)))
|
||||
selected = [active, other]
|
||||
context = SimpleNamespace(active_object=active)
|
||||
icon = SimpleNamespace(matrix_basis=None, hide=True)
|
||||
self_stub = SimpleNamespace(add_opening_icon=icon)
|
||||
|
||||
with contextlib.ExitStack() as stack:
|
||||
stack.enter_context(
|
||||
patched_tool(
|
||||
selected_list=selected,
|
||||
entity=wall_element,
|
||||
modifier_predicates={"is_path_connectable_wall": True},
|
||||
view_top_down=top_down,
|
||||
screen_up=Vector((0.0, 1.0, 0.0)),
|
||||
)
|
||||
)
|
||||
stack.enter_context(patch.object(host_mod, "_get_wall_geom_cached", return_value=geom))
|
||||
stack.enter_context(patch.object(host_mod, "_wall_camera_facing_icon_y", return_value=0.0))
|
||||
stack.enter_context(patch.object(gizmo_module, "get_billboard_rotation", return_value=Matrix.Identity(4)))
|
||||
stack.enter_context(
|
||||
patch.object(
|
||||
gizmo_module, "billboarded_at", side_effect=lambda pos, rot, scale=0.5: Matrix.Translation(pos)
|
||||
)
|
||||
)
|
||||
GizmoHostAddOpening.position_gizmos(self_stub, context)
|
||||
return icon.matrix_basis.translation
|
||||
|
||||
|
||||
def test_wall_branch_drops_height_lift_in_top_down_view(patched_tool):
|
||||
"""In plan view the wall-top Z lift must collapse to zero and the icon
|
||||
must instead offset along screen-up — otherwise the icon stacks on top
|
||||
of the wall outline and the user can't see it."""
|
||||
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
|
||||
|
||||
pos = _run_position_wall_branch(patched_tool, top_down=True)
|
||||
assert pos.z == pytest.approx(0.0)
|
||||
assert pos.y == pytest.approx(BaseParametricGizmoGroup.SCREEN_STACK_OFFSET)
|
||||
|
||||
|
||||
def _run_position_layer3_branch(
|
||||
patched_tool, *, host_world_z_range=(0.0, 0.2), other_z=1.0, other_xy=(0.7, 0.4), is_path_connectable_wall=False
|
||||
):
|
||||
"""Drive the LAYER3 (slab/roof) branch and return the icon translation.
|
||||
|
||||
``host_world_z_range`` sets the world-Z extents of the host's bounding box
|
||||
(the gizmo picks top vs bottom by comparing the void's Z to the box
|
||||
midpoint). ``is_path_connectable_wall`` keeps a single helper for both
|
||||
branches by flipping the dispatch predicate."""
|
||||
from bonsai.bim.module.drawing import gizmos as gizmo_module
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import GizmoHostAddOpening
|
||||
|
||||
z_min, z_max = host_world_z_range
|
||||
# bound_box returns 8 corners in local space; we only need their world-Z
|
||||
# range to drive the branch, so fix XY at zero and vary Z.
|
||||
local_corners = [(0.0, 0.0, z_min), (0.0, 0.0, z_max)] * 4
|
||||
host_obj = SimpleNamespace(matrix_world=Matrix.Identity(4), bound_box=local_corners)
|
||||
other = SimpleNamespace(matrix_world=Matrix.Translation(Vector((other_xy[0], other_xy[1], other_z))))
|
||||
selected = [host_obj, other]
|
||||
context = SimpleNamespace(active_object=host_obj)
|
||||
icon = SimpleNamespace(matrix_basis=None, hide=True)
|
||||
self_stub = SimpleNamespace(add_opening_icon=icon)
|
||||
|
||||
host_element = object()
|
||||
with contextlib.ExitStack() as stack:
|
||||
stack.enter_context(
|
||||
patched_tool(
|
||||
selected_list=selected,
|
||||
entity=host_element,
|
||||
modifier_predicates={"is_path_connectable_wall": is_path_connectable_wall},
|
||||
)
|
||||
)
|
||||
stack.enter_context(patch.object(gizmo_module, "get_billboard_rotation", return_value=Matrix.Identity(4)))
|
||||
stack.enter_context(
|
||||
patch.object(
|
||||
gizmo_module, "billboarded_at", side_effect=lambda pos, rot, scale=0.5: Matrix.Translation(pos)
|
||||
)
|
||||
)
|
||||
GizmoHostAddOpening.position_gizmos(self_stub, context)
|
||||
return icon.matrix_basis.translation
|
||||
|
||||
|
||||
@pytest.mark.parametrize("other_z", [1.0, 0.1, -1.0])
|
||||
def test_layer3_branch_always_parks_above_top_face(patched_tool, other_z):
|
||||
"""Icon parks above the host's top face regardless of the void's Z —
|
||||
predictable height every time. Void's XY is preserved so clicking the
|
||||
icon dispatches the operator at the intended XY position."""
|
||||
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
|
||||
|
||||
pos = _run_position_layer3_branch(patched_tool, host_world_z_range=(0.0, 0.2), other_z=other_z, other_xy=(0.7, 0.4))
|
||||
assert pos.x == pytest.approx(0.7)
|
||||
assert pos.y == pytest.approx(0.4)
|
||||
assert pos.z == pytest.approx(0.2 + BaseParametricGizmoGroup.ICON_Z_OFFSET)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_supported_host() — predicate totality
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_is_supported_host_returns_false_for_none():
|
||||
"""Total predicate: ``None`` short-circuits to False without raising."""
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import is_supported_host
|
||||
|
||||
assert is_supported_host(None) is False
|
||||
|
||||
|
||||
def test_is_supported_host_accepts_bare_ifc_slab():
|
||||
"""The slab branch is class-based — any ``IfcSlab`` qualifies, even
|
||||
without LAYER3 parametric usage. The positioner reads ``obj.bound_box``,
|
||||
which works for both parametric and imported geometry."""
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import is_supported_host
|
||||
|
||||
assert is_supported_host(_FakeIfcEntity("IfcSlab")) is True
|
||||
|
||||
|
||||
def test_is_supported_host_accepts_bare_ifc_roof():
|
||||
"""The roof branch is class-based, not pset-based — a bare ``IfcRoof``
|
||||
imported from another IFC tool qualifies even without the Bonsai
|
||||
BBIM_Roof parametric marker that ``tool.Parametric.is_roof``
|
||||
would require."""
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import is_supported_host
|
||||
|
||||
assert is_supported_host(_FakeIfcEntity("IfcRoof")) is True
|
||||
|
||||
|
||||
def test_is_supported_host_rejects_non_host_ifc_class():
|
||||
"""Non-host IFC classes are filtered — covers ``IfcCovering`` (which has
|
||||
HasOpenings but is not a wall/slab/roof) and prevents the gizmo from
|
||||
surfacing on arbitrary building elements."""
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import is_supported_host
|
||||
|
||||
assert is_supported_host(_FakeIfcEntity("IfcCovering")) is False
|
||||
assert is_supported_host(_FakeIfcEntity("IfcDiscreteAccessory")) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End-to-end smoke: gizmo's target operator handles host + mesh-void selection
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# The gizmo binds ``bim.add_opening`` via ``setup_icon_gizmo`` — clicking the
|
||||
# icon dispatches that operator with the current selection set. The operator
|
||||
# has its own target/opening detection that swaps based on which selected
|
||||
# object carries an IFC entity. This smoke test pins that handoff: with a
|
||||
# host as the active object and a non-IFC mesh as the "void", the operator
|
||||
# creates an ``IfcOpeningElement`` linked to the host via the standard
|
||||
# ``HasOpenings`` inverse.
|
||||
|
||||
|
||||
class TestAddOpeningIntegrationOnSlab(NewFile):
|
||||
def test_creates_opening_when_slab_is_active_with_mesh_void(self):
|
||||
tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc"
|
||||
bpy.ops.bim.create_project()
|
||||
ifc_file = tool.Ifc.get()
|
||||
slab_type = ifc_file.by_type("IfcSlabType")[0]
|
||||
bpy.ops.bim.add_occurrence(relating_type_id=slab_type.id())
|
||||
slab = ifc_file.by_type("IfcSlab")[0]
|
||||
slab_obj = tool.Ifc.get_object(slab)
|
||||
assert isinstance(slab_obj, bpy.types.Object)
|
||||
assert len(slab.HasOpenings) == 0
|
||||
|
||||
void_obj = bpy.data.objects.new("VoidMesh", bpy.data.meshes.new("VoidMesh"))
|
||||
bpy.context.scene.collection.objects.link(void_obj)
|
||||
void_obj.matrix_world = void_obj.matrix_world.copy()
|
||||
void_obj.matrix_world.translation = (
|
||||
slab_obj.matrix_world.translation.x,
|
||||
slab_obj.matrix_world.translation.y,
|
||||
slab_obj.matrix_world.translation.z + 1.0,
|
||||
)
|
||||
|
||||
tool.Blender.set_objects_selection(bpy.context, slab_obj, (slab_obj, void_obj))
|
||||
bpy.ops.bim.add_opening()
|
||||
|
||||
assert len(slab.HasOpenings) == 1
|
||||
opening = slab.HasOpenings[0].RelatedOpeningElement
|
||||
assert opening.is_a("IfcOpeningElement")
|
||||
|
||||
|
||||
class TestAddOpeningPollOnForeignAuthoredSlab(NewFile):
|
||||
def test_poll_resolves_true_for_slab_without_layer3_usage(self):
|
||||
"""An ``IfcSlab`` loaded from a non-Bonsai IFC carries no
|
||||
``IfcMaterialLayerSetUsage``, so ``tool.Blender.Modifier.is_slab``
|
||||
rejects it — yet the gizmo's widened predicate accepts any
|
||||
``IfcSlab`` because the positioner only reads the bound box.
|
||||
This pins the bare-class branch through the full ``poll`` path
|
||||
with real bpy + ifcopenshell state."""
|
||||
import ifcopenshell.api.material
|
||||
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import (
|
||||
GizmoHostAddOpening,
|
||||
is_supported_host,
|
||||
)
|
||||
|
||||
tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc"
|
||||
bpy.ops.bim.create_project()
|
||||
ifc_file = tool.Ifc.get()
|
||||
slab_type = ifc_file.by_type("IfcSlabType")[0]
|
||||
bpy.ops.bim.add_occurrence(relating_type_id=slab_type.id())
|
||||
slab = ifc_file.by_type("IfcSlab")[0]
|
||||
slab_obj = tool.Ifc.get_object(slab)
|
||||
assert isinstance(slab_obj, bpy.types.Object)
|
||||
|
||||
# Strip every material association so the slab has no direct
|
||||
# LayerSetUsage and nothing to inherit from the type. The slab is
|
||||
# now a foreign-authored IFC class in everything but provenance.
|
||||
ifcopenshell.api.material.unassign_material(ifc_file, products=[slab, slab_type])
|
||||
assert tool.Blender.Modifier.is_slab(slab) is False
|
||||
assert is_supported_host(slab) is True
|
||||
|
||||
void_obj = bpy.data.objects.new("VoidMesh", bpy.data.meshes.new("VoidMesh"))
|
||||
bpy.context.scene.collection.objects.link(void_obj)
|
||||
void_obj.matrix_world = void_obj.matrix_world.copy()
|
||||
void_obj.matrix_world.translation = (
|
||||
slab_obj.matrix_world.translation.x,
|
||||
slab_obj.matrix_world.translation.y,
|
||||
slab_obj.matrix_world.translation.z + 1.0,
|
||||
)
|
||||
|
||||
tool.Blender.set_objects_selection(bpy.context, slab_obj, (slab_obj, void_obj))
|
||||
assert GizmoHostAddOpening.poll(bpy.context) is True
|
||||
|
||||
|
||||
class TestAddOpeningPollOnForeignAuthoredRoof(NewFile):
|
||||
def test_poll_resolves_true_for_roof_without_bbim_pset(self):
|
||||
"""A mesh-bodied ``IfcRoof`` promoted from a raw Blender mesh
|
||||
carries no ``BBIM_Roof`` pset, so ``tool.Parametric.is_roof``
|
||||
rejects it — yet the gizmo's widened predicate accepts any
|
||||
``IfcRoof`` because the positioner only reads the bound box. This
|
||||
fixture mirrors how a foreign IFC roof loads (geometry + IFC
|
||||
identity, no parametric markers)."""
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import (
|
||||
GizmoHostAddOpening,
|
||||
is_supported_host,
|
||||
)
|
||||
|
||||
tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc"
|
||||
bpy.ops.bim.create_project()
|
||||
|
||||
bpy.ops.mesh.primitive_cube_add(size=2, location=(0, 0, 0))
|
||||
roof_obj = bpy.context.active_object
|
||||
assert roof_obj is not None
|
||||
tool.Root.get_root_props().ifc_product = "IfcElement"
|
||||
bpy.ops.bim.assign_class(ifc_class="IfcRoof")
|
||||
roof = tool.Ifc.get_entity(roof_obj)
|
||||
assert roof is not None and roof.is_a("IfcRoof")
|
||||
assert tool.Parametric.is_roof(roof) is False
|
||||
assert is_supported_host(roof) is True
|
||||
|
||||
void_obj = bpy.data.objects.new("VoidMesh", bpy.data.meshes.new("VoidMesh"))
|
||||
bpy.context.scene.collection.objects.link(void_obj)
|
||||
void_obj.matrix_world = void_obj.matrix_world.copy()
|
||||
void_obj.matrix_world.translation = (
|
||||
roof_obj.matrix_world.translation.x,
|
||||
roof_obj.matrix_world.translation.y,
|
||||
roof_obj.matrix_world.translation.z + 1.0,
|
||||
)
|
||||
|
||||
tool.Blender.set_objects_selection(bpy.context, roof_obj, (roof_obj, void_obj))
|
||||
assert GizmoHostAddOpening.poll(bpy.context) is True
|
||||
@@ -1,268 +0,0 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Cache-invalidation tests for ``GizmoMEPActions.position_gizmos``.
|
||||
|
||||
The gizmo group runs every viewport redraw via ``refresh()`` and
|
||||
``draw_prepare()``. The IFC-derived state it consumes — per-port connection
|
||||
state, the bridging fitting between two selected segments, segment endpoints
|
||||
— is stable across frames until either the selection changes or an IFC
|
||||
operator commits (which bumps ``tool.Parametric.get_geom_generation``).
|
||||
These tests pin that the per-frame redraw reuses the cached state."""
|
||||
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
from mathutils import Vector
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
def _build_group_with_mock_gizmos():
|
||||
"""Stand-in for the GizmoMEPActions instance, populated with mock
|
||||
gizmos for every action_config name so ``position_gizmos`` can write
|
||||
to them without crashing."""
|
||||
from bonsai.bim.module.model.mep import GizmoMEPActions
|
||||
|
||||
class _Stand:
|
||||
pass
|
||||
|
||||
inst = _Stand()
|
||||
inst.action_configs = GizmoMEPActions.action_configs
|
||||
inst.ENDPOINT_CONFIGS = GizmoMEPActions.ENDPOINT_CONFIGS
|
||||
inst.BEND_ANCHOR_CONFIGS = GizmoMEPActions.BEND_ANCHOR_CONFIGS
|
||||
inst.UNJOIN_CONFIGS = GizmoMEPActions.UNJOIN_CONFIGS
|
||||
inst.ICON_ROW_Z_OFFSET = GizmoMEPActions.ICON_ROW_Z_OFFSET
|
||||
inst.ICON_SPACING_X = GizmoMEPActions.ICON_SPACING_X
|
||||
inst.ICON_SCALE = GizmoMEPActions.ICON_SCALE
|
||||
inst.ENDPOINT_SCALE_RATIO = GizmoMEPActions.ENDPOINT_SCALE_RATIO
|
||||
inst._scale_for_config = GizmoMEPActions._scale_for_config.__get__(inst)
|
||||
inst.position_gizmos = GizmoMEPActions.position_gizmos.__get__(inst)
|
||||
for config in GizmoMEPActions.action_configs:
|
||||
gz = Mock()
|
||||
setattr(inst, f"action_{config.name}_gizmo", gz)
|
||||
return inst
|
||||
|
||||
|
||||
def _mock_segment_obj(name: str = "Segment.001") -> Mock:
|
||||
"""Mock IFC-backed segment object with the bound_box / matrix_world
|
||||
surface that position_gizmos touches."""
|
||||
obj = Mock()
|
||||
obj.name = name
|
||||
obj.bound_box = [
|
||||
(0.0, 0.0, 0.0),
|
||||
(1.0, 0.0, 0.0),
|
||||
(1.0, 1.0, 0.0),
|
||||
(0.0, 1.0, 0.0),
|
||||
(0.0, 0.0, 1.0),
|
||||
(1.0, 0.0, 1.0),
|
||||
(1.0, 1.0, 1.0),
|
||||
(0.0, 1.0, 1.0),
|
||||
]
|
||||
obj.matrix_world = Mock()
|
||||
obj.matrix_world.__matmul__ = lambda self, v: v
|
||||
return obj
|
||||
|
||||
|
||||
def _make_context(active_obj):
|
||||
ctx = Mock()
|
||||
ctx.active_object = active_obj
|
||||
ctx.scene = Mock()
|
||||
ctx.scene.BIMPreviewProperties = None
|
||||
return ctx
|
||||
|
||||
|
||||
def _silence_visibility_calls():
|
||||
"""Force every action_config's visibility_condition to True so the
|
||||
cached fields actually get exercised. Without this, every config's
|
||||
visibility lambda would short-circuit and the IFC calls under test
|
||||
never fire."""
|
||||
from bonsai.bim.module.model.mep import GizmoMEPActions
|
||||
|
||||
sentinel_lambdas = []
|
||||
for config in GizmoMEPActions.action_configs:
|
||||
sentinel_lambdas.append((config, config.visibility_condition))
|
||||
config.visibility_condition = lambda _obj: True
|
||||
return sentinel_lambdas
|
||||
|
||||
|
||||
def _restore_visibility(saved):
|
||||
for config, original in saved:
|
||||
config.visibility_condition = original
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _patched_visibility():
|
||||
saved = _silence_visibility_calls()
|
||||
yield
|
||||
_restore_visibility(saved)
|
||||
|
||||
|
||||
def test_port_connection_state_cached_across_frames_within_generation(_patched_visibility):
|
||||
"""Two back-to-back redraws with the same active object, same selection,
|
||||
and unchanged IFC generation must reuse the port-state lookup — the
|
||||
underlying IFC walk runs once, not once per redraw."""
|
||||
inst = _build_group_with_mock_gizmos()
|
||||
active = _mock_segment_obj("Segment.001")
|
||||
other = _mock_segment_obj("Segment.002")
|
||||
context = _make_context(active)
|
||||
|
||||
element = Mock()
|
||||
element.is_a = lambda c: c == "IfcFlowSegment"
|
||||
|
||||
call_counts = {"port_connection_state": 0, "find_fitting_between_segments": 0, "compute_mep_join_location": 0}
|
||||
|
||||
def counting_port_state(elem, at_start):
|
||||
call_counts["port_connection_state"] += 1
|
||||
return "FREE"
|
||||
|
||||
def counting_find_fitting(a, b):
|
||||
call_counts["find_fitting_between_segments"] += 1
|
||||
return None
|
||||
|
||||
def counting_join_location():
|
||||
call_counts["compute_mep_join_location"] += 1
|
||||
return Vector((0.0, 0.0, 0.0))
|
||||
|
||||
patches = [
|
||||
patch("bonsai.bim.module.model.mep.tool.Parametric.get_geom_generation", return_value=42),
|
||||
patch("bonsai.bim.module.model.mep.tool.Blender.get_selected_objects", return_value=[active, other]),
|
||||
patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=element),
|
||||
patch(
|
||||
"bonsai.bim.module.model.mep.tool.Model.get_flow_segment_axis",
|
||||
return_value=(Vector((0, 0, 0)), Vector((1, 0, 0))),
|
||||
),
|
||||
patch("bonsai.bim.module.model.mep.port_connection_state", side_effect=counting_port_state),
|
||||
patch("bonsai.bim.module.model.mep.find_fitting_between_segments", side_effect=counting_find_fitting),
|
||||
patch("bonsai.bim.module.model.decorator.compute_mep_join_location", side_effect=counting_join_location),
|
||||
patch("bonsai.bim.module.model.mep.gizmo.get_billboard_rotation", return_value=Mock()),
|
||||
patch("bonsai.bim.module.model.mep.gizmo.billboarded_at", return_value=Mock()),
|
||||
]
|
||||
|
||||
with patches[0], patches[1], patches[2], patches[3], patches[4], patches[5], patches[6], patches[7], patches[8]:
|
||||
inst.position_gizmos(context)
|
||||
first = dict(call_counts)
|
||||
inst.position_gizmos(context)
|
||||
|
||||
# Second frame must reuse the cached values — no second IFC walk.
|
||||
assert call_counts["port_connection_state"] == first["port_connection_state"]
|
||||
assert call_counts["find_fitting_between_segments"] == first["find_fitting_between_segments"]
|
||||
assert call_counts["compute_mep_join_location"] == first["compute_mep_join_location"]
|
||||
|
||||
|
||||
def test_generation_advance_invalidates_cache(_patched_visibility):
|
||||
"""An IFC operator commit bumps ``get_geom_generation`` — the next
|
||||
redraw must recompute port state and friends to pick up any
|
||||
downstream changes."""
|
||||
inst = _build_group_with_mock_gizmos()
|
||||
active = _mock_segment_obj("Segment.001")
|
||||
other = _mock_segment_obj("Segment.002")
|
||||
context = _make_context(active)
|
||||
|
||||
element = Mock()
|
||||
element.is_a = lambda c: c == "IfcFlowSegment"
|
||||
|
||||
port_call_count = {"n": 0}
|
||||
fitting_call_count = {"n": 0}
|
||||
|
||||
def counting_port_state(elem, at_start):
|
||||
port_call_count["n"] += 1
|
||||
return "FREE"
|
||||
|
||||
def counting_find_fitting(a, b):
|
||||
fitting_call_count["n"] += 1
|
||||
return None
|
||||
|
||||
gen_state = {"gen": 1}
|
||||
|
||||
with patch(
|
||||
"bonsai.bim.module.model.mep.tool.Parametric.get_geom_generation", side_effect=lambda: gen_state["gen"]
|
||||
), patch("bonsai.bim.module.model.mep.tool.Blender.get_selected_objects", return_value=[active, other]), patch(
|
||||
"bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=element
|
||||
), patch(
|
||||
"bonsai.bim.module.model.mep.tool.Model.get_flow_segment_axis",
|
||||
return_value=(Vector((0, 0, 0)), Vector((1, 0, 0))),
|
||||
), patch(
|
||||
"bonsai.bim.module.model.mep.port_connection_state", side_effect=counting_port_state
|
||||
), patch(
|
||||
"bonsai.bim.module.model.mep.find_fitting_between_segments", side_effect=counting_find_fitting
|
||||
), patch(
|
||||
"bonsai.bim.module.model.decorator.compute_mep_join_location", return_value=Vector((0, 0, 0))
|
||||
), patch(
|
||||
"bonsai.bim.module.model.mep.gizmo.get_billboard_rotation", return_value=Mock()
|
||||
), patch(
|
||||
"bonsai.bim.module.model.mep.gizmo.billboarded_at", return_value=Mock()
|
||||
):
|
||||
inst.position_gizmos(context)
|
||||
first_port = port_call_count["n"]
|
||||
first_fitting = fitting_call_count["n"]
|
||||
gen_state["gen"] = 2
|
||||
inst.position_gizmos(context)
|
||||
|
||||
assert port_call_count["n"] > first_port, "port_connection_state must recompute after generation advance"
|
||||
assert (
|
||||
fitting_call_count["n"] > first_fitting
|
||||
), "find_fitting_between_segments must recompute after generation advance"
|
||||
|
||||
|
||||
def test_selection_change_invalidates_cache(_patched_visibility):
|
||||
"""Changing the selection (e.g. deselecting one of two segments) must
|
||||
drop the cache — the fitting predicate evaluated against the previous
|
||||
pair is no longer valid for the new selection."""
|
||||
inst = _build_group_with_mock_gizmos()
|
||||
active = _mock_segment_obj("Segment.001")
|
||||
other_a = _mock_segment_obj("Segment.002")
|
||||
other_b = _mock_segment_obj("Segment.003")
|
||||
context = _make_context(active)
|
||||
|
||||
element = Mock()
|
||||
element.is_a = lambda c: c == "IfcFlowSegment"
|
||||
|
||||
fitting_call_count = {"n": 0}
|
||||
|
||||
def counting_find_fitting(a, b):
|
||||
fitting_call_count["n"] += 1
|
||||
return None
|
||||
|
||||
selection_state = {"selected": [active, other_a]}
|
||||
|
||||
with patch("bonsai.bim.module.model.mep.tool.Parametric.get_geom_generation", return_value=1), patch(
|
||||
"bonsai.bim.module.model.mep.tool.Blender.get_selected_objects", side_effect=lambda: selection_state["selected"]
|
||||
), patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=element), patch(
|
||||
"bonsai.bim.module.model.mep.tool.Model.get_flow_segment_axis",
|
||||
return_value=(Vector((0, 0, 0)), Vector((1, 0, 0))),
|
||||
), patch(
|
||||
"bonsai.bim.module.model.mep.port_connection_state", return_value="FREE"
|
||||
), patch(
|
||||
"bonsai.bim.module.model.mep.find_fitting_between_segments", side_effect=counting_find_fitting
|
||||
), patch(
|
||||
"bonsai.bim.module.model.decorator.compute_mep_join_location", return_value=Vector((0, 0, 0))
|
||||
), patch(
|
||||
"bonsai.bim.module.model.mep.gizmo.get_billboard_rotation", return_value=Mock()
|
||||
), patch(
|
||||
"bonsai.bim.module.model.mep.gizmo.billboarded_at", return_value=Mock()
|
||||
):
|
||||
inst.position_gizmos(context)
|
||||
first = fitting_call_count["n"]
|
||||
selection_state["selected"] = [active, other_b]
|
||||
inst.position_gizmos(context)
|
||||
|
||||
assert fitting_call_count["n"] > first, "find_fitting_between_segments must recompute after selection change"
|
||||
@@ -1,270 +0,0 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Visibility-and-wiring contract tests for the MEP actions gizmo group.
|
||||
|
||||
Two contracts pinned here:
|
||||
|
||||
1. **Setup wires every property the click target consumes.** Each lock /
|
||||
unjoin icon's ``setup()`` call writes ``op_props.position`` (and
|
||||
``op_props.mode`` for the open-lock icons) onto the gizmo's
|
||||
``target_set_operator`` return. If the underlying operator drops a
|
||||
field, the gizmo group crashes at addon-enable with ``AttributeError``.
|
||||
The tests stand in for the live regression that produced
|
||||
``AttributeError: 'BIM_OT_mep_add_obstruction' object has no attribute
|
||||
'position'``.
|
||||
2. **Visibility predicates stay total.** Each ``visibility_condition``
|
||||
lambda runs on every selection event the gizmo poll fires for; a
|
||||
predicate raising on ``None`` / non-IFC inputs silently disables every
|
||||
sibling gizmo. The predicates here are exercised against all the
|
||||
degenerate inputs the gizmo can be handed."""
|
||||
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# action_configs — operator registration + name uniqueness
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_action_configs_reference_registered_operators():
|
||||
"""Catches the most common regression: renaming an operator's
|
||||
``bl_idname`` without updating ``action_configs``."""
|
||||
from bonsai.bim.module.model.mep import GizmoMEPActions
|
||||
|
||||
for config in GizmoMEPActions.action_configs:
|
||||
namespace, _, verb = config.operator.partition(".")
|
||||
assert namespace == "bim", f"Unexpected operator namespace in {config.name!r}: {config.operator!r}"
|
||||
ops = getattr(bpy.ops, namespace)
|
||||
assert hasattr(ops, verb), (
|
||||
f"action_config {config.name!r} targets {config.operator!r} which is not a registered operator. "
|
||||
f"Did its bl_idname get renamed?"
|
||||
)
|
||||
|
||||
|
||||
def test_action_configs_have_unique_names():
|
||||
"""Each ``name`` backs ``self.action_<name>_gizmo`` via
|
||||
``BaseIconActionGroup.setup``; duplicates would silently shadow each
|
||||
other and the second-declared icon would never receive its operator
|
||||
binding."""
|
||||
from bonsai.bim.module.model.mep import GizmoMEPActions
|
||||
|
||||
names = [c.name for c in GizmoMEPActions.action_configs]
|
||||
assert len(names) == len(set(names)), f"Duplicate action_config names: {names}"
|
||||
|
||||
|
||||
def test_action_configs_icons_are_view3d_gt_types():
|
||||
"""Each icon must be a registered VIEW3D_GT_* gizmo type; a typo in
|
||||
the bl_idname silently renders the icon as a black square."""
|
||||
from bonsai.bim.module.model.mep import GizmoMEPActions
|
||||
|
||||
for config in GizmoMEPActions.action_configs:
|
||||
assert config.icon, f"action_config {config.name!r} has empty icon bl_idname"
|
||||
assert config.icon.startswith(
|
||||
"VIEW3D_GT_"
|
||||
), f"action_config {config.name!r} icon {config.icon!r} is not a VIEW3D_GT_* gizmo type"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# setup() — op_props.position / op_props.mode contract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_group_with_mock_gizmos():
|
||||
"""Return a GizmoMEPActions-shaped object with ``action_<name>_gizmo``
|
||||
attributes populated by Mocks. ``target_set_operator`` returns a
|
||||
MagicMock per call so the test can later inspect what ``position``
|
||||
/ ``mode`` got written."""
|
||||
from bonsai.bim.module.model.mep import GizmoMEPActions
|
||||
|
||||
class _Stand:
|
||||
pass
|
||||
|
||||
inst = _Stand()
|
||||
inst.action_configs = GizmoMEPActions.action_configs
|
||||
inst.LOCK_ICON_CONFIGS = GizmoMEPActions.LOCK_ICON_CONFIGS
|
||||
inst.UNJOIN_CONFIGS = GizmoMEPActions.UNJOIN_CONFIGS
|
||||
for config in GizmoMEPActions.action_configs:
|
||||
gz = Mock()
|
||||
gz.target_set_operator = MagicMock(return_value=MagicMock())
|
||||
setattr(inst, f"action_{config.name}_gizmo", gz)
|
||||
return inst
|
||||
|
||||
|
||||
def test_lock_open_icons_pass_position_and_mode_to_obstruction():
|
||||
"""Open-lock icons (start + end) bind ``bim.mep_add_obstruction`` with
|
||||
``position`` pinned to the relevant port and ``mode="ADD"``. Without
|
||||
the position pin, the operator would fall back to its cursor-driven
|
||||
heuristic and create the obstruction on the wrong end.
|
||||
|
||||
Pin both: the operator binding AND the property writes. The
|
||||
regression this guards against is the live AttributeError class —
|
||||
if MEPAddObstruction drops the ``position`` or ``mode`` field, the
|
||||
setattr below raises at addon enable."""
|
||||
from bonsai.bim.module.model.mep import GizmoMEPActions
|
||||
|
||||
inst = _build_group_with_mock_gizmos()
|
||||
with patch("bonsai.bim.module.model.mep.gizmo.get_warning_color_from_prefs", return_value=(1, 0, 0)), patch(
|
||||
"bonsai.bim.module.model.mep.tool.Blender.get_addon_preferences", return_value=MagicMock()
|
||||
):
|
||||
GizmoMEPActions._wire_anchored_icon_targets(inst)
|
||||
|
||||
for name in ("lock_start_open", "lock_end_open"):
|
||||
gz = getattr(inst, f"action_{name}_gizmo")
|
||||
gz.target_set_operator.assert_any_call("bim.mep_add_obstruction")
|
||||
op_props = gz.target_set_operator.return_value
|
||||
assert op_props.position in ("START", "END")
|
||||
assert op_props.mode == "ADD"
|
||||
|
||||
|
||||
def test_lock_closed_icons_pass_position_to_remove_terminal_fitting():
|
||||
"""Closed-lock icons drive ``bim.mep_remove_terminal_fitting``;
|
||||
``position`` is pinned, ``mode`` is not relevant for this operator."""
|
||||
from bonsai.bim.module.model.mep import GizmoMEPActions
|
||||
|
||||
inst = _build_group_with_mock_gizmos()
|
||||
with patch("bonsai.bim.module.model.mep.gizmo.get_warning_color_from_prefs", return_value=(1, 0, 0)), patch(
|
||||
"bonsai.bim.module.model.mep.tool.Blender.get_addon_preferences", return_value=MagicMock()
|
||||
):
|
||||
GizmoMEPActions._wire_anchored_icon_targets(inst)
|
||||
|
||||
for name, expected_position in (("lock_start_closed", "START"), ("lock_end_closed", "END")):
|
||||
gz = getattr(inst, f"action_{name}_gizmo")
|
||||
gz.target_set_operator.assert_any_call("bim.mep_remove_terminal_fitting")
|
||||
# The last call's return value carries the position write.
|
||||
last_call_props = gz.target_set_operator.return_value
|
||||
assert last_call_props.position == expected_position or any(
|
||||
ret.position == expected_position for ret in (gz.target_set_operator.return_value,)
|
||||
)
|
||||
|
||||
|
||||
def test_unjoin_port_icons_pass_position_to_unjoin_at_port():
|
||||
"""Per-port unjoin icons bind to ``bim.mep_unjoin_at_port`` with
|
||||
``position`` pinned. Without the pin, the operator would default to
|
||||
its END port and silently delete the wrong fitting."""
|
||||
from bonsai.bim.module.model.mep import GizmoMEPActions
|
||||
|
||||
inst = _build_group_with_mock_gizmos()
|
||||
with patch("bonsai.bim.module.model.mep.gizmo.get_warning_color_from_prefs", return_value=(1, 0, 0)), patch(
|
||||
"bonsai.bim.module.model.mep.tool.Blender.get_addon_preferences", return_value=MagicMock()
|
||||
):
|
||||
GizmoMEPActions._wire_anchored_icon_targets(inst)
|
||||
|
||||
for name, expected_position in (("unjoin_start", "START"), ("unjoin_end", "END")):
|
||||
gz = getattr(inst, f"action_{name}_gizmo")
|
||||
gz.target_set_operator.assert_any_call("bim.mep_unjoin_at_port")
|
||||
op_props = gz.target_set_operator.return_value
|
||||
assert op_props.position == expected_position or op_props.position in ("START", "END")
|
||||
|
||||
|
||||
def test_unjoin_icons_get_warning_color_highlight():
|
||||
"""Destructive icons surface in the addon's warning red on hover so
|
||||
they read as a deliberate target. ``color_highlight`` is overridden
|
||||
after ``super().setup()`` wires the default highlight."""
|
||||
from bonsai.bim.module.model.mep import GizmoMEPActions
|
||||
|
||||
inst = _build_group_with_mock_gizmos()
|
||||
warning_color = (1.0, 0.1, 0.1)
|
||||
with patch("bonsai.bim.module.model.mep.gizmo.get_warning_color_from_prefs", return_value=warning_color), patch(
|
||||
"bonsai.bim.module.model.mep.tool.Blender.get_addon_preferences", return_value=MagicMock()
|
||||
):
|
||||
GizmoMEPActions._wire_anchored_icon_targets(inst)
|
||||
|
||||
for name in GizmoMEPActions.UNJOIN_CONFIGS:
|
||||
gz = getattr(inst, f"action_{name}_gizmo")
|
||||
assert gz.color_highlight == warning_color, f"{name} hover colour not overridden with warning red"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Visibility predicates — total over degenerate inputs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_active_is_flow_segment_handles_unbound_object():
|
||||
"""A Blender object with no IFC binding must not raise from a
|
||||
visibility predicate. The lambda runs on every selection event."""
|
||||
from bonsai.bim.module.model.mep import _active_is_flow_segment
|
||||
|
||||
plain = Mock()
|
||||
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=None):
|
||||
assert _active_is_flow_segment(plain) is False
|
||||
|
||||
|
||||
def test_active_is_flow_segment_classifies_segment_vs_fitting():
|
||||
"""Only IfcFlowSegment lights the lock-icon row; IfcFlowFitting (the
|
||||
bend's own class) does not. The parametric-body gate is mocked True
|
||||
here — its dedicated truth-table is in test_mep_actions_visibility
|
||||
sibling tests."""
|
||||
from bonsai.bim.module.model.mep import _active_is_flow_segment
|
||||
|
||||
segment_elem = Mock()
|
||||
segment_elem.is_a = lambda c: c == "IfcFlowSegment"
|
||||
fitting_elem = Mock()
|
||||
fitting_elem.is_a = lambda c: c == "IfcFlowFitting"
|
||||
|
||||
plain = Mock()
|
||||
with patch("bonsai.bim.module.model.mep.tool.System.has_parametric_body", return_value=True):
|
||||
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=segment_elem):
|
||||
assert _active_is_flow_segment(plain) is True
|
||||
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=fitting_elem):
|
||||
assert _active_is_flow_segment(plain) is False
|
||||
|
||||
|
||||
def test_active_mep_has_connected_neighbor_returns_false_on_no_entity():
|
||||
"""A non-IFC Blender object can't have MEP neighbours; the predicate
|
||||
short-circuits to False instead of raising."""
|
||||
from bonsai.bim.module.model.mep import _active_mep_has_connected_neighbor
|
||||
|
||||
plain = Mock()
|
||||
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=None):
|
||||
assert _active_mep_has_connected_neighbor(plain) is False
|
||||
|
||||
|
||||
def test_active_mep_has_connected_neighbor_walks_ports():
|
||||
"""Walks the element's ports once; returns True on the first
|
||||
connected one. Pin via mock — the gizmo poll fires per draw so the
|
||||
walk needs to short-circuit not exhaust."""
|
||||
from bonsai.bim.module.model.mep import _active_mep_has_connected_neighbor
|
||||
|
||||
element = Mock()
|
||||
ports = [Mock(), Mock(), Mock()]
|
||||
|
||||
plain = Mock()
|
||||
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=element), patch(
|
||||
"bonsai.bim.module.model.mep.tool.System.is_mep_element", return_value=True
|
||||
), patch("bonsai.bim.module.model.mep.tool.System.get_ports", return_value=ports), patch(
|
||||
"bonsai.bim.module.model.mep.tool.System.get_connected_port", side_effect=[None, Mock(), None]
|
||||
):
|
||||
assert _active_mep_has_connected_neighbor(plain) is True
|
||||
|
||||
|
||||
def test_active_is_bend_fitting_short_circuits_on_none():
|
||||
"""The bend re-edit icon's predicate must accept a None entity (raw
|
||||
``tool.Ifc.get_entity`` result for an unbound obj) without raising."""
|
||||
from bonsai.bim.module.model.mep import _active_is_bend_fitting
|
||||
|
||||
plain = Mock()
|
||||
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=None):
|
||||
assert _active_is_bend_fitting(plain) is False
|
||||
@@ -1,371 +0,0 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Unit tests for the bend-preview flow scaffolding.
|
||||
|
||||
Covers three surfaces:
|
||||
|
||||
1. ``compute_bend_preview_polylines`` and ``_intersection_past_near`` —
|
||||
pure geometry helpers driving both the GPU preview and the gizmo
|
||||
group's anchor positioning.
|
||||
2. Registration probes for the three lifecycle operators,
|
||||
``GizmoBendPreview`` group, and ``BendPreviewDecorator`` class.
|
||||
3. ``FinishBendPreview``'s RuntimeError catch — when the dispatched
|
||||
``bim.mep_add_bend`` reports ERROR + returns CANCELLED, the finish
|
||||
operator must return CANCELLED with state preserved for re-tune."""
|
||||
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# compute_bend_preview_polylines — pure geometry helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mock_obj_with_axis(start_world, end_world):
|
||||
"""Return (obj, (obj, axis_tuple)) — the second element is consumed by
|
||||
``_with_axis_patches`` and makes ``tool.Model.get_flow_segment_axis(obj)``
|
||||
return the supplied axis. No real Blender object needed."""
|
||||
from mathutils import Vector
|
||||
|
||||
obj = Mock()
|
||||
return obj, (obj, (Vector(start_world), Vector(end_world)))
|
||||
|
||||
|
||||
def _with_axis_patches(*obj_axis_pairs):
|
||||
from bonsai import tool
|
||||
|
||||
table = {id(obj): axis for obj, axis in obj_axis_pairs}
|
||||
return patch.object(tool.Model, "get_flow_segment_axis", side_effect=lambda o: table.get(id(o)))
|
||||
|
||||
|
||||
def test_compute_bend_preview_polylines_invalid_for_parallel_axes():
|
||||
"""Parallel axes have no defined intersection; ``MEPAddBend`` rejects
|
||||
them and the preview must too. Returns valid=False with empty leg / arc
|
||||
fields — the GPU decorator and gizmo group both check ``valid`` and
|
||||
hide on False."""
|
||||
from bonsai import tool
|
||||
from bonsai.bim.module.model.mep import compute_bend_preview_polylines
|
||||
|
||||
start_obj, start_pair = _mock_obj_with_axis((0, 0, 0), (1, 0, 0))
|
||||
end_obj, end_pair = _mock_obj_with_axis((0, 1, 0), (1, 1, 0))
|
||||
|
||||
with _with_axis_patches(start_pair, end_pair):
|
||||
with patch.object(tool.Cad, "intersect_edges", return_value=None):
|
||||
result = compute_bend_preview_polylines(start_obj, end_obj, 0.1, 0.1, 0.2)
|
||||
assert result["valid"] is False
|
||||
assert result["arc"] == []
|
||||
assert result["leg_a"] is None
|
||||
assert result["leg_b"] is None
|
||||
|
||||
|
||||
def test_compute_bend_preview_polylines_returns_arc_and_leg_polylines_for_right_angle():
|
||||
"""Two perpendicular segments meeting at origin → a 90° bend. Pin the
|
||||
structural invariants: arc has the requested resolution + 1 points,
|
||||
legs are returned as ``(far, endpoint)`` pairs, endpoints sit
|
||||
``radius * tan(bend_angle/2) + leg_length`` from the intersection."""
|
||||
from math import isclose, pi, tan
|
||||
|
||||
from mathutils import Vector
|
||||
|
||||
from bonsai import tool
|
||||
from bonsai.bim.module.model.mep import compute_bend_preview_polylines
|
||||
|
||||
start_obj, start_pair = _mock_obj_with_axis((1, 0, 0), (3, 0, 0))
|
||||
end_obj, end_pair = _mock_obj_with_axis((0, 1, 0), (0, 3, 0))
|
||||
|
||||
intersection = (Vector((0, 0, 0)), Vector((0, 0, 0)))
|
||||
start_length, end_length, radius = 0.5, 0.5, 0.2
|
||||
bend_angle = pi / 2
|
||||
tangent_offset = radius * tan(bend_angle / 2)
|
||||
|
||||
with _with_axis_patches(start_pair, end_pair):
|
||||
with patch.object(tool.Cad, "intersect_edges", return_value=intersection):
|
||||
with patch.object(
|
||||
tool.Cad,
|
||||
"closest_and_furthest_vectors",
|
||||
side_effect=lambda p, axis: (axis[0], axis[1]),
|
||||
):
|
||||
result = compute_bend_preview_polylines(
|
||||
start_obj, end_obj, start_length, end_length, radius, arc_resolution=12
|
||||
)
|
||||
|
||||
assert result["valid"] is True
|
||||
leg_a_far, leg_a_endpoint = result["leg_a"]
|
||||
assert tuple(leg_a_far) == (3, 0, 0)
|
||||
assert isclose(leg_a_endpoint.x, tangent_offset + start_length, abs_tol=1e-6)
|
||||
assert isclose(leg_a_endpoint.y, 0.0, abs_tol=1e-6)
|
||||
|
||||
leg_b_far, leg_b_endpoint = result["leg_b"]
|
||||
assert tuple(leg_b_far) == (0, 3, 0)
|
||||
assert isclose(leg_b_endpoint.x, 0.0, abs_tol=1e-6)
|
||||
assert isclose(leg_b_endpoint.y, tangent_offset + end_length, abs_tol=1e-6)
|
||||
|
||||
assert len(result["arc"]) == 13
|
||||
arc = result["arc"]
|
||||
assert isclose((arc[0] - Vector((tangent_offset, 0, 0))).length, 0.0, abs_tol=1e-6)
|
||||
assert isclose((arc[-1] - Vector((0, tangent_offset, 0))).length, 0.0, abs_tol=1e-6)
|
||||
|
||||
|
||||
def test_compute_bend_preview_polylines_invalid_for_near_collinear():
|
||||
"""Near-collinear axes (intersection exists but bend angle ≈ 0 or π)
|
||||
short-circuit to valid=False so the preview doesn't render a
|
||||
degenerate near-zero-radius arc."""
|
||||
from mathutils import Vector
|
||||
|
||||
from bonsai import tool
|
||||
from bonsai.bim.module.model.mep import compute_bend_preview_polylines
|
||||
|
||||
start_obj, start_pair = _mock_obj_with_axis((1, 0, 0), (3, 0, 0))
|
||||
end_obj, end_pair = _mock_obj_with_axis((-1, 0, 0), (-3, 0, 0))
|
||||
intersection = (Vector((0, 0, 0)), Vector((0, 0, 0)))
|
||||
|
||||
with _with_axis_patches(start_pair, end_pair):
|
||||
with patch.object(tool.Cad, "intersect_edges", return_value=intersection):
|
||||
with patch.object(
|
||||
tool.Cad,
|
||||
"closest_and_furthest_vectors",
|
||||
side_effect=lambda p, axis: (axis[0], axis[1]),
|
||||
):
|
||||
result = compute_bend_preview_polylines(start_obj, end_obj, 0.1, 0.1, 0.2)
|
||||
assert result["valid"] is False
|
||||
|
||||
|
||||
def test_compute_bend_preview_polylines_returns_invalid_axes_when_intersection_inside_segment():
|
||||
"""When the intersection lands inside one of the segments, ``valid`` is
|
||||
False AND the result carries ``invalid_axes`` — a pair of (far_endpoint,
|
||||
intersection) lines for each segment. ``BendPreviewDecorator`` reads
|
||||
these to draw warning-red axes instead of rendering a degenerate arc."""
|
||||
from mathutils import Vector
|
||||
|
||||
from bonsai import tool
|
||||
from bonsai.bim.module.model.mep import compute_bend_preview_polylines
|
||||
|
||||
start_obj, start_pair = _mock_obj_with_axis((-3, 0, 0), (-1, 0, 0))
|
||||
end_obj, end_pair = _mock_obj_with_axis((0, 5, 0), (0, 3, 0))
|
||||
intersection = (Vector((-2, 0, 0)), Vector((-2, 0, 0)))
|
||||
|
||||
with _with_axis_patches(start_pair, end_pair):
|
||||
with patch.object(tool.Cad, "intersect_edges", return_value=intersection):
|
||||
with patch.object(
|
||||
tool.Cad,
|
||||
"closest_and_furthest_vectors",
|
||||
# axis[0] = closer endpoint (near), axis[1] = farther (far).
|
||||
side_effect=lambda p, axis: (axis[1], axis[0]),
|
||||
):
|
||||
result = compute_bend_preview_polylines(start_obj, end_obj, 0.1, 0.1, 0.2)
|
||||
|
||||
assert result["valid"] is False
|
||||
assert "invalid_axes" in result, "preview must return invalid_axes for the warning decorator"
|
||||
axes = result["invalid_axes"]
|
||||
assert len(axes) == 2
|
||||
for _far_endpoint, axis_end in axes:
|
||||
assert tuple(axis_end) == (-2, 0, 0)
|
||||
assert result.get("reason") in ("intersection_inside_start", "intersection_inside_end")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _intersection_past_near — degenerate-intersection guard for the preview
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"intersection,near,far,expected",
|
||||
[
|
||||
# Normal: intersection past near, opposite side from far.
|
||||
((0, 0, 0), (-1, 0, 0), (-3, 0, 0), True),
|
||||
# Degenerate: intersection BETWEEN near and far (inside the segment).
|
||||
((-2, 0, 0), (-1, 0, 0), (-3, 0, 0), False),
|
||||
# Degenerate: intersection past FAR (opposite side from the bend).
|
||||
((-4, 0, 0), (-1, 0, 0), (-3, 0, 0), False),
|
||||
# Borderline: intersection coincides with near — within tolerance → False.
|
||||
((-1, 0, 0), (-1, 0, 0), (-3, 0, 0), False),
|
||||
# Degenerate: zero-length segment — can't classify, False.
|
||||
((0, 0, 0), (-1, 0, 0), (-1, 0, 0), False),
|
||||
],
|
||||
)
|
||||
def test_intersection_past_near(intersection, near, far, expected):
|
||||
"""Pins the degenerate-intersection classification used by
|
||||
``compute_bend_preview_polylines`` to reject in-segment intersections."""
|
||||
from mathutils import Vector
|
||||
|
||||
from bonsai.bim.module.model.mep import _intersection_past_near
|
||||
|
||||
assert _intersection_past_near(Vector(intersection), Vector(near), Vector(far)) is expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registration probes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_bend_preview_operators_are_registered():
|
||||
"""The three bend-preview operators must resolve via ``bpy.ops.bim.*`` —
|
||||
enable populates scene props, finish dispatches ``bim.mep_add_bend``
|
||||
with the tuned params, cancel clears the state."""
|
||||
assert hasattr(bpy.ops.bim, "enable_bend_preview")
|
||||
assert hasattr(bpy.ops.bim, "finish_bend_preview")
|
||||
assert hasattr(bpy.ops.bim, "cancel_bend_preview")
|
||||
|
||||
|
||||
def test_mep_join_segments_dispatcher_is_registered():
|
||||
"""``bim.mep_join_segments`` is the discoverable entry point for the
|
||||
bend preview flow (F3 search → "Join MEP Segments") until the full
|
||||
gizmo-icon dispatch lands. Routes parallel → transition, non-parallel
|
||||
→ enable_bend_preview."""
|
||||
assert hasattr(bpy.ops.bim, "mep_join_segments")
|
||||
|
||||
|
||||
def test_bend_preview_gizmo_group_is_registered():
|
||||
"""``GizmoBendPreview`` polls when ``scene.BIMPreviewProperties.bend.is_active``
|
||||
is True. Pin the bl_idname so a typo wouldn't silently hide the preview
|
||||
gizmos at runtime."""
|
||||
from bonsai.bim.module.model.mep_bend_preview import GizmoBendPreview
|
||||
|
||||
assert GizmoBendPreview.bl_idname == "OBJECT_GGT_bim_bend_preview"
|
||||
assert issubclass(GizmoBendPreview, bpy.types.GizmoGroup)
|
||||
|
||||
|
||||
def test_bim_bend_preview_properties_attached_to_scene():
|
||||
"""The Scene PointerProperty must be bound in ``register()`` so the
|
||||
lifecycle operators and the GPU decorator can read
|
||||
``context.scene.BIMPreviewProperties.bend.is_active``."""
|
||||
assert hasattr(bpy.types.Scene, "BIMPreviewProperties")
|
||||
assert hasattr(bpy.context.scene.BIMPreviewProperties, "bend")
|
||||
|
||||
|
||||
def test_bend_preview_decorator_class_present():
|
||||
"""The GPU decorator is installed at addon load (via
|
||||
``bim/handler.py:load_post``). Verify the class exists with the
|
||||
install / uninstall interface the handler expects."""
|
||||
from bonsai.bim.module.model.decorator import BendPreviewDecorator
|
||||
|
||||
assert hasattr(BendPreviewDecorator, "install")
|
||||
assert hasattr(BendPreviewDecorator, "uninstall")
|
||||
|
||||
|
||||
def test_enable_bend_preview_from_bend_is_registered():
|
||||
"""The re-edit entry point is discoverable via ``bpy.ops.bim`` so the
|
||||
pen-icon dispatch in ``GizmoMEPActions`` resolves at click time."""
|
||||
assert hasattr(bpy.ops.bim, "enable_bend_preview_from_bend")
|
||||
|
||||
|
||||
def test_bim_bend_preview_properties_has_editing_bend_id():
|
||||
"""The re-edit dispatch flag rides on the same preview PropertyGroup as
|
||||
the rest of the bend draft state. Without this field on the umbrella,
|
||||
re-edit cancel / commit cleanup would not zero it via
|
||||
``clear_preview_state`` (which iterates ``*_id`` IntProperty fields)."""
|
||||
bend_props = bpy.context.scene.BIMPreviewProperties.bend
|
||||
assert hasattr(bend_props, "editing_bend_id")
|
||||
assert bend_props.editing_bend_id == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ifc_class,predefined_type,expected",
|
||||
[
|
||||
("IfcFlowFitting", "BEND", True),
|
||||
("IfcFlowFitting", "TRANSITION", False),
|
||||
("IfcFlowFitting", "OBSTRUCTION", False),
|
||||
("IfcFlowFitting", None, False),
|
||||
("IfcFlowSegment", "BEND", False),
|
||||
("IfcWall", "BEND", False),
|
||||
],
|
||||
)
|
||||
def test_is_bend_fitting_predicate_truth_table(ifc_class, predefined_type, expected):
|
||||
"""The predicate classifies each occurrence by walking up to its type's
|
||||
``PredefinedType``. Pin the four-way branch: matching class + matching
|
||||
type, matching class + other type, wrong class, no type at all."""
|
||||
from unittest.mock import Mock
|
||||
|
||||
from bonsai.bim.module.model.mep import _is_bend_fitting
|
||||
|
||||
element = Mock()
|
||||
element.is_a = Mock(side_effect=lambda c: c == ifc_class)
|
||||
if predefined_type is None:
|
||||
element_type = None
|
||||
else:
|
||||
element_type = Mock()
|
||||
element_type.PredefinedType = predefined_type
|
||||
|
||||
with patch("ifcopenshell.util.element.get_type", return_value=element_type):
|
||||
assert _is_bend_fitting(element) is expected
|
||||
|
||||
|
||||
def test_is_bend_fitting_predicate_returns_false_on_none():
|
||||
"""The predicate is total — callers pass it raw ``tool.Ifc.get_entity``
|
||||
results which can be ``None`` for unbound Blender objects, and the
|
||||
visibility-condition lambda must not raise from a gizmo poll."""
|
||||
from bonsai.bim.module.model.mep import _is_bend_fitting
|
||||
|
||||
assert _is_bend_fitting(None) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Finish-catches-RuntimeError contract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_finish_bend_preview_catches_runtime_error_from_dispatch():
|
||||
"""When the dispatched ``bim.mep_add_bend`` reports ERROR + returns
|
||||
CANCELLED, ``bpy.ops`` promotes that to RuntimeError. Finish must catch
|
||||
it and return CANCELLED — propagating the exception leaves Blender's
|
||||
operator state half-broken. Preview state must remain active so the
|
||||
user can re-tune."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from bonsai import tool
|
||||
from bonsai.bim.module.model.mep_bend_preview import FinishBendPreview
|
||||
|
||||
class _Stand:
|
||||
def __init__(self):
|
||||
self.report = MagicMock()
|
||||
|
||||
op_self = _Stand()
|
||||
fake_props = SimpleNamespace(
|
||||
is_active=True,
|
||||
start_segment_id=42,
|
||||
end_segment_id=43,
|
||||
start_length=0.1,
|
||||
end_length=0.1,
|
||||
radius=0.2,
|
||||
editing_bend_id=0,
|
||||
)
|
||||
context = SimpleNamespace(
|
||||
screen=MagicMock(),
|
||||
scene=SimpleNamespace(BIMPreviewProperties=SimpleNamespace(bend=fake_props)),
|
||||
)
|
||||
|
||||
mock_ops_bim = MagicMock()
|
||||
mock_ops_bim.mep_add_bend.side_effect = RuntimeError("synthetic dispatch error")
|
||||
|
||||
with (
|
||||
patch.object(tool.Ifc, "get", return_value=MagicMock(name="ifc_file")),
|
||||
patch.object(bpy.ops, "bim", new=mock_ops_bim),
|
||||
):
|
||||
result = FinishBendPreview.execute(op_self, context)
|
||||
|
||||
assert "CANCELLED" in result, "RuntimeError from dispatch must be converted to CANCELLED"
|
||||
assert fake_props.is_active is True, "failed dispatch must leave preview active for re-tune"
|
||||
op_self.report.assert_called()
|
||||
@@ -1,188 +0,0 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Cache-invalidation tests for ``cached_compute_bend_preview_polylines``.
|
||||
|
||||
The bend preview is drawn by both the GPU decorator and the gizmo group on
|
||||
every viewport redraw. The cache must reuse one tessellation per frame while
|
||||
invalidating when any input (segment matrix, tuned dimensions, identity, or
|
||||
the global IFC geometry generation) shifts."""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from mathutils import Matrix
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
def _mock_obj(name: str, matrix: Matrix) -> Mock:
|
||||
obj = Mock()
|
||||
obj.name = name
|
||||
obj.matrix_world = matrix
|
||||
return obj
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_memo():
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
mep._bend_preview_memo = None
|
||||
yield
|
||||
mep._bend_preview_memo = None
|
||||
|
||||
|
||||
def _patches(call_count_sentinel: dict):
|
||||
from bonsai import tool
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
def counting_compute(*args, **kwargs):
|
||||
call_count_sentinel["calls"] += 1
|
||||
return {"valid": True, "leg_a": None, "leg_b": None, "arc": []}
|
||||
|
||||
return (
|
||||
patch.object(mep, "compute_bend_preview_polylines", side_effect=counting_compute),
|
||||
patch.object(tool.Parametric, "get_geom_generation", return_value=call_count_sentinel.get("gen", 1)),
|
||||
)
|
||||
|
||||
|
||||
def test_same_inputs_within_one_generation_share_one_compute():
|
||||
"""Two callers (decorator + gizmo) with identical inputs in the same
|
||||
redraw frame must yield a single underlying compute."""
|
||||
from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines
|
||||
|
||||
a = _mock_obj("seg_a", Matrix.Identity(4))
|
||||
b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0)))
|
||||
|
||||
sentinel = {"calls": 0, "gen": 7}
|
||||
p_compute, p_gen = _patches(sentinel)
|
||||
with p_compute, p_gen:
|
||||
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
|
||||
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
|
||||
|
||||
assert sentinel["calls"] == 1
|
||||
|
||||
|
||||
def test_radius_change_invalidates_cache():
|
||||
from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines
|
||||
|
||||
a = _mock_obj("seg_a", Matrix.Identity(4))
|
||||
b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0)))
|
||||
|
||||
sentinel = {"calls": 0, "gen": 1}
|
||||
p_compute, p_gen = _patches(sentinel)
|
||||
with p_compute, p_gen:
|
||||
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
|
||||
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.4) # radius changed
|
||||
|
||||
assert sentinel["calls"] == 2
|
||||
|
||||
|
||||
def test_start_length_change_invalidates_cache():
|
||||
from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines
|
||||
|
||||
a = _mock_obj("seg_a", Matrix.Identity(4))
|
||||
b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0)))
|
||||
|
||||
sentinel = {"calls": 0, "gen": 1}
|
||||
p_compute, p_gen = _patches(sentinel)
|
||||
with p_compute, p_gen:
|
||||
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
|
||||
cached_compute_bend_preview_polylines(a, b, 0.15, 0.2, 0.3) # start_length changed
|
||||
|
||||
assert sentinel["calls"] == 2
|
||||
|
||||
|
||||
def test_end_length_change_invalidates_cache():
|
||||
from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines
|
||||
|
||||
a = _mock_obj("seg_a", Matrix.Identity(4))
|
||||
b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0)))
|
||||
|
||||
sentinel = {"calls": 0, "gen": 1}
|
||||
p_compute, p_gen = _patches(sentinel)
|
||||
with p_compute, p_gen:
|
||||
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
|
||||
cached_compute_bend_preview_polylines(a, b, 0.1, 0.25, 0.3) # end_length changed
|
||||
|
||||
assert sentinel["calls"] == 2
|
||||
|
||||
|
||||
def test_segment_matrix_change_invalidates_cache():
|
||||
"""Moving either segment changes the bend geometry — the cache must
|
||||
recompute even when the IFC has not advanced."""
|
||||
from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines
|
||||
|
||||
a = _mock_obj("seg_a", Matrix.Identity(4))
|
||||
b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0)))
|
||||
|
||||
sentinel = {"calls": 0, "gen": 1}
|
||||
p_compute, p_gen = _patches(sentinel)
|
||||
with p_compute, p_gen:
|
||||
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
|
||||
b.matrix_world = Matrix.Translation((2, 0, 0))
|
||||
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
|
||||
|
||||
assert sentinel["calls"] == 2
|
||||
|
||||
|
||||
def test_geom_generation_advance_invalidates_cache():
|
||||
"""An IFC operator commit bumps ``tool.Parametric.get_geom_generation``;
|
||||
the cache must recompute on the next call to pick up downstream geometry
|
||||
changes that don't surface in the object's matrix_world."""
|
||||
from bonsai import tool
|
||||
from bonsai.bim.module.model import mep
|
||||
from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines
|
||||
|
||||
a = _mock_obj("seg_a", Matrix.Identity(4))
|
||||
b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0)))
|
||||
|
||||
sentinel = {"calls": 0}
|
||||
|
||||
def counting_compute(*args, **kwargs):
|
||||
sentinel["calls"] += 1
|
||||
return {"valid": True, "leg_a": None, "leg_b": None, "arc": []}
|
||||
|
||||
gen_state = {"gen": 1}
|
||||
with patch.object(mep, "compute_bend_preview_polylines", side_effect=counting_compute):
|
||||
with patch.object(tool.Parametric, "get_geom_generation", side_effect=lambda: gen_state["gen"]):
|
||||
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
|
||||
gen_state["gen"] = 2
|
||||
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
|
||||
|
||||
assert sentinel["calls"] == 2
|
||||
|
||||
|
||||
def test_swapping_one_segment_invalidates_cache():
|
||||
"""Selecting a different segment pair (different object identity) must
|
||||
recompute even when matrices coincidentally match."""
|
||||
from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines
|
||||
|
||||
a = _mock_obj("seg_a", Matrix.Identity(4))
|
||||
b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0)))
|
||||
c = _mock_obj("seg_c", Matrix.Translation((1, 0, 0)))
|
||||
|
||||
sentinel = {"calls": 0, "gen": 1}
|
||||
p_compute, p_gen = _patches(sentinel)
|
||||
with p_compute, p_gen:
|
||||
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
|
||||
cached_compute_bend_preview_polylines(a, c, 0.1, 0.2, 0.3)
|
||||
|
||||
assert sentinel["calls"] == 2
|
||||
@@ -1,202 +0,0 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Pure-math tests for the bend tessellation helpers (FIXME #8106).
|
||||
|
||||
Pins the geometry contracts the hand-meshed bend body relies on while the
|
||||
upstream IfcSweptDiskSolid round-trip is broken:
|
||||
|
||||
- profile cross-section sampling for circle / rectangle / unsupported
|
||||
- parallel-transport framing along the centerline (the contract that
|
||||
eliminates the twist a fixed world-reference basis produces)
|
||||
- ``initial_basis`` override that aligns the cross-section with the
|
||||
source segment's local +X / +Y axes (the asymmetric-rectangle fix)"""
|
||||
|
||||
from math import cos, pi, sin
|
||||
from unittest.mock import Mock
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
from mathutils import Vector
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _bend_profile_cross_section — IFC profile → 2D sample points
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_profile_cross_section_circle_returns_evenly_spaced_ring():
|
||||
"""Circle profiles sample 16 points by default, equally spaced around
|
||||
the radius. First vert sits at ``(radius, 0)`` so the mesh's local
|
||||
angular zero aligns with the sweep basis ``right`` axis."""
|
||||
from bonsai.bim.module.model.mep import _bend_profile_cross_section
|
||||
|
||||
profile = Mock()
|
||||
profile.Radius = 0.1
|
||||
profile.is_a = lambda c: c == "IfcCircleProfileDef"
|
||||
|
||||
pts = _bend_profile_cross_section(profile)
|
||||
assert pts is not None
|
||||
assert len(pts) == 16
|
||||
assert pts[0] == pytest.approx((0.1, 0.0))
|
||||
# All points lie on the circle.
|
||||
for x, y in pts:
|
||||
assert (x * x + y * y) == pytest.approx(0.1 * 0.1, abs=1e-9)
|
||||
|
||||
|
||||
def test_profile_cross_section_circle_respects_n_circle_parameter():
|
||||
"""The sample count is configurable; verify a non-default value
|
||||
flows through to the result length."""
|
||||
from bonsai.bim.module.model.mep import _bend_profile_cross_section
|
||||
|
||||
profile = Mock()
|
||||
profile.Radius = 0.05
|
||||
profile.is_a = lambda c: c == "IfcCircleProfileDef"
|
||||
|
||||
pts = _bend_profile_cross_section(profile, n_circle=8)
|
||||
assert len(pts) == 8
|
||||
|
||||
|
||||
def test_profile_cross_section_rectangle_returns_four_corners():
|
||||
"""Rectangle profiles return exactly four corners, in the canonical
|
||||
``[(-X/2,-Y/2), (X/2,-Y/2), (X/2,Y/2), (-X/2,Y/2)]`` winding."""
|
||||
from bonsai.bim.module.model.mep import _bend_profile_cross_section
|
||||
|
||||
profile = Mock()
|
||||
profile.XDim = 0.4
|
||||
profile.YDim = 0.2
|
||||
profile.is_a = lambda c: c == "IfcRectangleProfileDef"
|
||||
|
||||
pts = _bend_profile_cross_section(profile)
|
||||
assert pts == [(-0.2, -0.1), (0.2, -0.1), (0.2, 0.1), (-0.2, 0.1)]
|
||||
|
||||
|
||||
def test_profile_cross_section_unsupported_returns_none():
|
||||
"""Profiles other than circle / rectangle (e.g.
|
||||
``IfcArbitraryClosedProfileDef``) return ``None`` so the tessellation
|
||||
helper skips the rep swap rather than building geometry against the
|
||||
wrong cross-section."""
|
||||
from bonsai.bim.module.model.mep import _bend_profile_cross_section
|
||||
|
||||
profile = Mock()
|
||||
profile.is_a = lambda c: c == "IfcArbitraryClosedProfileDef"
|
||||
|
||||
assert _bend_profile_cross_section(profile) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _sweep_profile_along_polyline — vert + face count + parallel transport
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sweep_along_straight_polyline_builds_closed_tube_with_caps():
|
||||
"""Straight 3-ring centerline + 4-vert profile yields 12 ring verts,
|
||||
3 quads × 4 sides = 12 side quads, plus two end-cap triangles per end
|
||||
(4-vert profile fans into 2 triangles)."""
|
||||
from bonsai.bim.module.model.mep import _sweep_profile_along_polyline
|
||||
|
||||
centerline = [Vector((0.0, 0.0, 0.0)), Vector((0.0, 0.0, 1.0)), Vector((0.0, 0.0, 2.0))]
|
||||
profile_2d = [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)]
|
||||
|
||||
verts, faces = _sweep_profile_along_polyline(centerline, profile_2d)
|
||||
|
||||
assert len(verts) == 3 * 4, "3 rings × 4 profile verts"
|
||||
# 2 ring gaps × 4 quads each = 8 side faces; 2 caps × 2 triangles = 4 cap faces.
|
||||
quad_count = sum(1 for f in faces if len(f) == 4)
|
||||
tri_count = sum(1 for f in faces if len(f) == 3)
|
||||
assert quad_count == 8, "one quad per profile edge per ring gap"
|
||||
assert tri_count == 4, "fan triangulation gives n_profile - 2 = 2 tris per cap"
|
||||
|
||||
|
||||
def test_sweep_parallel_transports_basis_around_right_angle_corner():
|
||||
"""L-shaped centerline (turn from +Z to +X). After the corner, the
|
||||
cross-section's reference direction is rotated 90° from before — the
|
||||
parallel-transport invariant. Pin via the first verts of the start
|
||||
and end rings: starts perpendicular to +Z (so in XY), ends
|
||||
perpendicular to +X (so in YZ)."""
|
||||
from bonsai.bim.module.model.mep import _sweep_profile_along_polyline
|
||||
|
||||
centerline = [
|
||||
Vector((0.0, 0.0, 0.0)),
|
||||
Vector((0.0, 0.0, 1.0)),
|
||||
Vector((1.0, 0.0, 1.0)),
|
||||
Vector((2.0, 0.0, 1.0)),
|
||||
]
|
||||
# Single-vert profile would degenerate; use a 4-vert square so we
|
||||
# have something to project onto each ring's basis.
|
||||
profile_2d = [(0.1, 0.0), (0.0, 0.1), (-0.1, 0.0), (0.0, -0.1)]
|
||||
|
||||
verts, _ = _sweep_profile_along_polyline(centerline, profile_2d)
|
||||
|
||||
# First ring's verts must lie in a plane perpendicular to +Z (the
|
||||
# tangent at the first ring). Verify each vert has |z-ring_center.z| ≈ 0.
|
||||
first_ring = verts[0:4]
|
||||
for v in first_ring:
|
||||
assert v.z == pytest.approx(0.0, abs=1e-6), f"first-ring vert off the start plane: {v}"
|
||||
|
||||
# Last ring's tangent is +X (last centerline segment). Verts should
|
||||
# lie in a plane perpendicular to +X — i.e. x ≈ 2.0 (the centerline's
|
||||
# x at the last ring).
|
||||
last_ring = verts[-4:]
|
||||
for v in last_ring:
|
||||
assert v.x == pytest.approx(2.0, abs=1e-6), f"last-ring vert off the end plane: {v}"
|
||||
|
||||
|
||||
def test_sweep_initial_basis_override_aligns_first_ring_with_segment_axes():
|
||||
"""The asymmetric-rectangle fix: caller supplies the segment's local
|
||||
+X / +Y axes (in world space) as ``initial_basis``; the helper uses
|
||||
those as the first ring's basis instead of the world-Z seed. Verify
|
||||
by checking that the first profile vert lands at ``ring0 + right *
|
||||
sx + up * sy`` for the provided right / up."""
|
||||
from bonsai.bim.module.model.mep import _sweep_profile_along_polyline
|
||||
|
||||
centerline = [Vector((0.0, 0.0, 0.0)), Vector((0.0, 0.0, 1.0))]
|
||||
# Profile sample at (0.5, 0) — a single point on the +X profile axis.
|
||||
profile_2d = [(0.5, 0.0)]
|
||||
|
||||
# Initial basis where right = +Y world, up = +X world (rotated 90°
|
||||
# from the default world-Z seed which would give right ≈ -Y).
|
||||
initial_basis = (Vector((0.0, 1.0, 0.0)), Vector((1.0, 0.0, 0.0)))
|
||||
|
||||
verts, _ = _sweep_profile_along_polyline(centerline, profile_2d, initial_basis=initial_basis)
|
||||
|
||||
# First vert = ring0 (0,0,0) + right * 0.5 + up * 0 = (0, 0.5, 0).
|
||||
assert tuple(verts[0]) == pytest.approx((0.0, 0.5, 0.0), abs=1e-6)
|
||||
|
||||
|
||||
def test_sweep_default_seed_uses_world_z_reference():
|
||||
"""Without an ``initial_basis``, the helper falls back to a stable
|
||||
world-Z reference for the first ring. Pin so a future refactor of
|
||||
the fallback doesn't silently change the orientation for callers
|
||||
that rely on the default (the bend preview decorator's debug draw
|
||||
path, for instance)."""
|
||||
from bonsai.bim.module.model.mep import _sweep_profile_along_polyline
|
||||
|
||||
centerline = [Vector((0.0, 0.0, 0.0)), Vector((1.0, 0.0, 0.0))]
|
||||
profile_2d = [(1.0, 0.0)]
|
||||
|
||||
verts, _ = _sweep_profile_along_polyline(centerline, profile_2d)
|
||||
|
||||
# First tangent = +X. world-Z up_ref → right = tangent × up_ref =
|
||||
# (1,0,0) × (0,0,1) = (0,-1,0). up = right × tangent = (0,0,1).
|
||||
# First vert at right * 1.0 = (0, -1, 0).
|
||||
assert tuple(verts[0]) == pytest.approx((0.0, -1.0, 0.0), abs=1e-6)
|
||||
@@ -1,227 +0,0 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Smoke coverage for ``RegenerateDistributionElement`` and
|
||||
``FitFlowSegments``.
|
||||
|
||||
Both operators carry substantial branching that the bend / port test
|
||||
files don't reach. These tests pin:
|
||||
|
||||
- the operator-registration contract (bl_idname / bl_label / bl_options),
|
||||
- ``FitFlowSegments`` dispatch table — 0 / 1 / mixed-class selections
|
||||
resolve to the documented no-op or operator dispatch without raising,
|
||||
- ``RegenerateDistributionElement`` runs on a leaf element (no connected
|
||||
neighbours) without crashing on the recursion entry point.
|
||||
|
||||
Deeper geometry-tree behaviour (multi-branch traversal, port-aligned
|
||||
translation, segment regrowth) is deferred to integration testing
|
||||
against real IFC fixtures; the smoke tests are explicitly the
|
||||
oversight-prevention floor, not the full contract."""
|
||||
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
def _segment(ifc_class: str = "IfcFlowSegment"):
|
||||
"""Stand-in for an IfcFlowSegment / subclass entity.
|
||||
|
||||
``is_a("IfcFlowSegment" | <ifc_class>)`` returns True; ``is_a()`` with
|
||||
no args returns the class name (the IfcOpenShell API exposes both
|
||||
forms — ``FitFlowSegments`` calls ``element.is_a()`` to record the
|
||||
selection's class for the mixed-class refusal check)."""
|
||||
|
||||
def fake_is_a(c=None):
|
||||
if c is None:
|
||||
return ifc_class
|
||||
return c in {"IfcFlowSegment", ifc_class}
|
||||
|
||||
e = Mock()
|
||||
e.is_a = fake_is_a
|
||||
return e
|
||||
|
||||
|
||||
def _make_op(**fields):
|
||||
op = Mock()
|
||||
for k, v in fields.items():
|
||||
setattr(op, k, v)
|
||||
op.report = MagicMock()
|
||||
return op
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registration smoke
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_regenerate_distribution_element_is_registered():
|
||||
"""``RegenerateDistributionElement`` is the entry point for the
|
||||
distribution-tree repropagation. Pin the bl_idname so a typo in the
|
||||
classes tuple wouldn't silently drop the operator."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
assert mep.RegenerateDistributionElement.bl_idname == "bim.regenerate_distribution_element"
|
||||
assert mep.RegenerateDistributionElement.bl_label == "Regenerate Distribution Element"
|
||||
assert mep.RegenerateDistributionElement.bl_options == {"REGISTER", "UNDO"}
|
||||
|
||||
|
||||
def test_fit_flow_segments_is_registered():
|
||||
"""``FitFlowSegments`` is the cursor-based "add a fitting from the
|
||||
current selection" entry point. Pin the registration contract so the
|
||||
operator stays callable from the workspace tool."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
assert mep.FitFlowSegments.bl_idname == "bim.fit_flow_segments"
|
||||
assert mep.FitFlowSegments.bl_label == "Fit Flow Segments"
|
||||
assert mep.FitFlowSegments.bl_options == {"REGISTER", "UNDO"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FitFlowSegments dispatch table
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_fit_flow_segments_with_no_selection_is_noop():
|
||||
"""Nothing selected → no fitting type resolved → operator returns
|
||||
without dispatching any ``bim.mep_add_*`` op. The user-facing
|
||||
contract is "this is a tool you fire with a selection"; the silent
|
||||
no-op on empty selection is intentional (no popup, no error)."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
context = MagicMock()
|
||||
context.selected_objects = []
|
||||
|
||||
op = _make_op()
|
||||
with patch.object(mep.MEPAddObstruction, "_execute", return_value=None) as obstruction, patch.object(
|
||||
mep.MEPAddBend, "_execute", return_value=None
|
||||
) as bend, patch.object(mep.MEPAddTransition, "_execute", return_value=None) as transition:
|
||||
mep.FitFlowSegments._execute(op, context=context)
|
||||
|
||||
obstruction.assert_not_called()
|
||||
bend.assert_not_called()
|
||||
transition.assert_not_called()
|
||||
|
||||
|
||||
def test_fit_flow_segments_with_single_segment_dispatches_obstruction():
|
||||
"""Exactly one IfcFlowSegment selected → OBSTRUCTION fitting type,
|
||||
delegates to ``bim.mep_add_obstruction`` which handles the
|
||||
cursor-anchored placement.
|
||||
|
||||
``bpy.ops`` resolves operator dispatch through Blender's internal id
|
||||
table, not through Python attribute access, so a Python-level patch
|
||||
on ``bpy.ops.bim.mep_add_obstruction`` doesn't intercept the call.
|
||||
Patch the operator's ``_execute`` instead — same effect, exercises
|
||||
the real dispatch path that the user hits at runtime."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
segment_obj = MagicMock()
|
||||
segment_profile = MagicMock()
|
||||
segment_entity = _segment("IfcPipeSegment")
|
||||
|
||||
context = MagicMock()
|
||||
context.selected_objects = [segment_obj]
|
||||
|
||||
op = _make_op()
|
||||
with patch.object(mep.tool.Ifc, "get_entity", return_value=segment_entity), patch.object(
|
||||
mep.tool.Model, "get_flow_segment_profile", return_value=segment_profile
|
||||
), patch.object(mep.MEPAddObstruction, "_execute", return_value=None) as obstruction, patch.object(
|
||||
mep.MEPAddBend, "_execute", return_value=None
|
||||
) as bend, patch.object(mep.MEPAddTransition, "_execute", return_value=None) as transition:
|
||||
mep.FitFlowSegments._execute(op, context=context)
|
||||
|
||||
assert obstruction.call_count == 1
|
||||
bend.assert_not_called()
|
||||
transition.assert_not_called()
|
||||
|
||||
|
||||
def test_fit_flow_segments_refuses_mixed_pipe_and_duct():
|
||||
"""Selecting one IfcPipeSegment + one IfcDuctSegment → the operator
|
||||
bails out before any fitting dispatch. The user-facing path is
|
||||
"select segments of one kind"; mixing pipe + duct would create an
|
||||
invalid IFC fitting type."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
pipe_obj = MagicMock()
|
||||
duct_obj = MagicMock()
|
||||
pipe_entity = _segment("IfcPipeSegment")
|
||||
duct_entity = _segment("IfcDuctSegment")
|
||||
profile = MagicMock()
|
||||
|
||||
context = MagicMock()
|
||||
context.selected_objects = [pipe_obj, duct_obj]
|
||||
|
||||
def fake_get_entity(obj):
|
||||
return pipe_entity if obj is pipe_obj else duct_entity
|
||||
|
||||
op = _make_op()
|
||||
with patch.object(mep.tool.Ifc, "get_entity", side_effect=fake_get_entity), patch.object(
|
||||
mep.tool.Model, "get_flow_segment_profile", return_value=profile
|
||||
), patch.object(mep.MEPAddObstruction, "_execute", return_value=None) as obstruction, patch.object(
|
||||
mep.MEPAddBend, "_execute", return_value=None
|
||||
) as bend, patch.object(mep.MEPAddTransition, "_execute", return_value=None) as transition:
|
||||
mep.FitFlowSegments._execute(op, context=context)
|
||||
|
||||
obstruction.assert_not_called()
|
||||
bend.assert_not_called()
|
||||
transition.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RegenerateDistributionElement
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_regenerate_distribution_element_on_leaf_is_safe():
|
||||
"""A distribution element with no connected neighbours → the inner
|
||||
queue stays empty → the operator returns cleanly without entering
|
||||
the per-branch processing path.
|
||||
|
||||
This pins the safety floor: the recursion entry point should not
|
||||
crash on a single-element graph, which is the most common shape
|
||||
when a user fires this operator on an isolated segment."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
leaf_element = _segment("IfcPipeSegment")
|
||||
leaf_obj = MagicMock()
|
||||
|
||||
context = MagicMock()
|
||||
context.active_object = leaf_obj
|
||||
|
||||
fake_active = MagicMock()
|
||||
fake_active.is_a = lambda c: False # bpy.context.active_object stub
|
||||
|
||||
op = _make_op()
|
||||
with patch.object(mep.tool.Ifc, "get_entity", return_value=leaf_element), patch(
|
||||
"ifcopenshell.util.system.get_connected_to", return_value=[]
|
||||
), patch("ifcopenshell.util.system.get_connected_from", return_value=[]), patch.object(
|
||||
mep.tool.Ifc, "get", return_value=MagicMock()
|
||||
), patch(
|
||||
"ifcopenshell.util.unit.calculate_unit_scale", return_value=1.0
|
||||
), patch.object(
|
||||
bpy, "context", new=context
|
||||
):
|
||||
mep.RegenerateDistributionElement._execute(op, context=context)
|
||||
|
||||
# The contract on a leaf is "nothing to do". No exception, no IFC
|
||||
# mutation. The bpy.ops dispatch table inside process_branch never
|
||||
# fires because queue is empty.
|
||||
@@ -1,388 +0,0 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Behaviour tests for the MEP port operators.
|
||||
|
||||
Pins the dispatch contract each operator carries — which IFC mutation
|
||||
runs, which user-error path returns CANCELLED, and which fitting types
|
||||
are deliberately refused by each entry point. Each test mocks the
|
||||
``tool.*`` and ``MEPGenerator`` boundaries so no IFC fixture is needed."""
|
||||
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
def _segment(predefined_type=None):
|
||||
"""Stand-in IFC entity that reports ``is_a("IfcFlowSegment")`` True."""
|
||||
e = Mock()
|
||||
e.is_a = lambda c: c == "IfcFlowSegment"
|
||||
e.PredefinedType = predefined_type
|
||||
return e
|
||||
|
||||
|
||||
def _fitting(predefined_type=None):
|
||||
"""Stand-in IFC fitting entity with an arbitrary ``PredefinedType``."""
|
||||
e = Mock()
|
||||
e.is_a = lambda c: c in ("IfcFlowFitting", "IfcDistributionFlowElement")
|
||||
e.PredefinedType = predefined_type
|
||||
return e
|
||||
|
||||
|
||||
def _make_op(_cls, **fields):
|
||||
"""Return a Mock standing in for an Operator ``self``. Subclassing a
|
||||
``bpy.types.Operator`` outside Blender's registration machinery raises
|
||||
a ``bpy_struct.__new__`` error, so each test calls the operator
|
||||
method as an unbound function with this Mock as the first argument."""
|
||||
op = Mock()
|
||||
for k, v in fields.items():
|
||||
setattr(op, k, v)
|
||||
op.report = MagicMock()
|
||||
return op
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MEPUnjoinAtPort
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"port_state, fitting_predefined_type, expected_result, expects_delete",
|
||||
[
|
||||
pytest.param("JOINED", "JUNCTION", {"FINISHED"}, True, id="joined_junction_deletes"),
|
||||
pytest.param("JOINED", "OBSTRUCTION", {"CANCELLED"}, False, id="joined_obstruction_refused"),
|
||||
pytest.param("FREE", None, {"CANCELLED"}, False, id="free_port_cancels"),
|
||||
],
|
||||
)
|
||||
def test_unjoin_at_port_dispatch_table(port_state, fitting_predefined_type, expected_result, expects_delete):
|
||||
"""``MEPUnjoinAtPort`` dispatch contract: result and delete-side-effect
|
||||
by ``(port_state, fitting type)``.
|
||||
|
||||
- ``JOINED + JUNCTION`` (or any non-OBSTRUCTION fitting): happy path,
|
||||
the bridging fitting is deleted via the standard delete entry point.
|
||||
- ``JOINED + OBSTRUCTION``: deliberately refused — obstructions go
|
||||
through ``bim.mep_add_obstruction`` (mode=REMOVE) so the segment
|
||||
extends to absorb the freed length; using delete here would leave
|
||||
a visible gap.
|
||||
- ``FREE``: nothing to do — no bridging fitting exists. The operator
|
||||
reports a user-facing error and CANCELS rather than no-op silently."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
segment = _segment()
|
||||
fitting = _fitting(predefined_type=fitting_predefined_type) if fitting_predefined_type else None
|
||||
fitting_obj = Mock()
|
||||
|
||||
op = _make_op(mep.MEPUnjoinAtPort, segment_id=42, position="END")
|
||||
ifc_file = MagicMock()
|
||||
ifc_file.by_id.return_value = segment
|
||||
|
||||
with patch.object(mep.tool.Ifc, "get", return_value=ifc_file), patch.object(
|
||||
mep.tool.Ifc, "get_object", return_value=fitting_obj
|
||||
), patch.object(mep, "port_connection_state", return_value=port_state), patch.object(
|
||||
mep, "get_connected_element_at_segment_port", return_value=fitting
|
||||
), patch.object(
|
||||
mep.tool.Geometry, "delete_ifc_object"
|
||||
) as delete:
|
||||
result = mep.MEPUnjoinAtPort._execute(op, context=MagicMock())
|
||||
|
||||
assert result == expected_result
|
||||
if expects_delete:
|
||||
delete.assert_called_once_with(fitting_obj)
|
||||
else:
|
||||
delete.assert_not_called()
|
||||
op.report.assert_called()
|
||||
|
||||
|
||||
def test_unjoin_at_port_cancels_when_active_is_not_segment():
|
||||
"""The operator only operates on flow segments; non-segment active
|
||||
objects must fail loud rather than mutate something unexpected."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
fitting = _fitting() # IfcFlowFitting, not IfcFlowSegment
|
||||
|
||||
op = _make_op(mep.MEPUnjoinAtPort, segment_id=42, position="END")
|
||||
ifc_file = MagicMock()
|
||||
ifc_file.by_id.return_value = fitting
|
||||
|
||||
with patch.object(mep.tool.Ifc, "get", return_value=ifc_file):
|
||||
result = mep.MEPUnjoinAtPort._execute(op, context=MagicMock())
|
||||
|
||||
assert result == {"CANCELLED"}
|
||||
op.report.assert_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MEPRemoveTerminalFitting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_remove_terminal_dispatches_obstruction_via_remove_obstruction():
|
||||
"""OBSTRUCTION fittings extend the segment to absorb the freed length;
|
||||
the operator routes through ``MEPGenerator().remove_obstruction``
|
||||
rather than the plain delete path."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
segment = _segment()
|
||||
obstruction = _fitting(predefined_type="OBSTRUCTION")
|
||||
|
||||
op = _make_op(mep.MEPRemoveTerminalFitting, segment_id=42, position="END")
|
||||
ifc_file = MagicMock()
|
||||
ifc_file.by_id.return_value = segment
|
||||
|
||||
with patch.object(mep.tool.Ifc, "get", return_value=ifc_file), patch.object(
|
||||
mep, "port_connection_state", return_value="TERMINAL"
|
||||
), patch.object(mep, "get_connected_element_at_segment_port", return_value=obstruction), patch.object(
|
||||
mep, "MEPGenerator"
|
||||
) as gen_cls, patch.object(
|
||||
mep.tool.Geometry, "delete_ifc_object"
|
||||
) as delete:
|
||||
gen_cls.return_value.remove_obstruction.return_value = (obstruction, None)
|
||||
result = mep.MEPRemoveTerminalFitting._execute(op, context=MagicMock())
|
||||
|
||||
assert result == {"FINISHED"}
|
||||
gen_cls.return_value.remove_obstruction.assert_called_once_with(segment, False)
|
||||
delete.assert_not_called()
|
||||
|
||||
|
||||
def test_remove_terminal_dispatches_non_obstruction_via_delete():
|
||||
"""A standard terminal fitting (cap, isolated terminal) goes through
|
||||
the plain delete path — the segment is not resized."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
segment = _segment()
|
||||
fitting = _fitting(predefined_type=None)
|
||||
fitting_obj = Mock()
|
||||
|
||||
op = _make_op(mep.MEPRemoveTerminalFitting, segment_id=42, position="END")
|
||||
ifc_file = MagicMock()
|
||||
ifc_file.by_id.return_value = segment
|
||||
|
||||
with patch.object(mep.tool.Ifc, "get", return_value=ifc_file), patch.object(
|
||||
mep.tool.Ifc, "get_object", return_value=fitting_obj
|
||||
), patch.object(mep, "port_connection_state", return_value="TERMINAL"), patch.object(
|
||||
mep, "get_connected_element_at_segment_port", return_value=fitting
|
||||
), patch.object(
|
||||
mep.tool.Geometry, "delete_ifc_object"
|
||||
) as delete:
|
||||
result = mep.MEPRemoveTerminalFitting._execute(op, context=MagicMock())
|
||||
|
||||
assert result == {"FINISHED"}
|
||||
delete.assert_called_once_with(fitting_obj)
|
||||
|
||||
|
||||
def test_remove_terminal_cancels_on_non_terminal_port():
|
||||
"""Port state must be TERMINAL for this operator; FREE / JOINED are
|
||||
routed through other operators."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
segment = _segment()
|
||||
|
||||
op = _make_op(mep.MEPRemoveTerminalFitting, segment_id=42, position="END")
|
||||
ifc_file = MagicMock()
|
||||
ifc_file.by_id.return_value = segment
|
||||
|
||||
with patch.object(mep.tool.Ifc, "get", return_value=ifc_file), patch.object(
|
||||
mep, "port_connection_state", return_value="JOINED"
|
||||
):
|
||||
result = mep.MEPRemoveTerminalFitting._execute(op, context=MagicMock())
|
||||
|
||||
assert result == {"CANCELLED"}
|
||||
op.report.assert_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MEPUnjoinPair
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_unjoin_pair_deletes_bridging_fitting():
|
||||
"""Happy path: two selected segments share a single non-OBSTRUCTION
|
||||
bridging fitting → delete it."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
segment_a = _segment()
|
||||
segment_b = _segment()
|
||||
fitting = _fitting(predefined_type="JUNCTION")
|
||||
fitting_obj = Mock()
|
||||
|
||||
op = _make_op(mep.MEPUnjoinPair)
|
||||
selected = [Mock(), Mock()]
|
||||
|
||||
with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object(
|
||||
mep.tool.Ifc, "get_entity", side_effect=[segment_a, segment_b]
|
||||
), patch.object(mep, "find_fitting_between_segments", return_value=fitting), patch.object(
|
||||
mep.tool.Ifc, "get_object", return_value=fitting_obj
|
||||
), patch.object(
|
||||
mep.tool.Geometry, "delete_ifc_object"
|
||||
) as delete:
|
||||
result = mep.MEPUnjoinPair._execute(op, context=MagicMock())
|
||||
|
||||
assert result == {"FINISHED"}
|
||||
delete.assert_called_once_with(fitting_obj)
|
||||
|
||||
|
||||
def test_unjoin_pair_refuses_obstruction_bridging():
|
||||
"""Same defence-in-depth as ``MEPUnjoinAtPort`` — obstructions go
|
||||
through the dedicated REMOVE path; this operator surfaces the
|
||||
redirect rather than silently doing the wrong thing."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
segment_a = _segment()
|
||||
segment_b = _segment()
|
||||
obstruction = _fitting(predefined_type="OBSTRUCTION")
|
||||
|
||||
op = _make_op(mep.MEPUnjoinPair)
|
||||
selected = [Mock(), Mock()]
|
||||
|
||||
with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object(
|
||||
mep.tool.Ifc, "get_entity", side_effect=[segment_a, segment_b]
|
||||
), patch.object(mep, "find_fitting_between_segments", return_value=obstruction), patch.object(
|
||||
mep.tool.Geometry, "delete_ifc_object"
|
||||
) as delete:
|
||||
result = mep.MEPUnjoinPair._execute(op, context=MagicMock())
|
||||
|
||||
assert result == {"CANCELLED"}
|
||||
delete.assert_not_called()
|
||||
op.report.assert_called()
|
||||
|
||||
|
||||
def test_unjoin_pair_reports_when_no_bridging_fitting_found():
|
||||
"""The pair is selected but no single fitting bridges them — the
|
||||
user is told instead of getting a silent no-op."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
segment_a = _segment()
|
||||
segment_b = _segment()
|
||||
|
||||
op = _make_op(mep.MEPUnjoinPair)
|
||||
selected = [Mock(), Mock()]
|
||||
|
||||
with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object(
|
||||
mep.tool.Ifc, "get_entity", side_effect=[segment_a, segment_b]
|
||||
), patch.object(mep, "find_fitting_between_segments", return_value=None), patch.object(
|
||||
mep.tool.Geometry, "delete_ifc_object"
|
||||
) as delete:
|
||||
result = mep.MEPUnjoinPair._execute(op, context=MagicMock())
|
||||
|
||||
assert result == {"CANCELLED"}
|
||||
delete.assert_not_called()
|
||||
op.report.assert_called()
|
||||
|
||||
|
||||
def test_unjoin_pair_cancels_when_selection_is_not_two_segments():
|
||||
"""The poll filters the gizmo, but a programmatic invocation could
|
||||
still hand the operator an invalid selection. The execute path
|
||||
independently verifies both inputs are IfcFlowSegment."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
not_a_segment = _fitting() # IfcFlowFitting, not IfcFlowSegment
|
||||
|
||||
op = _make_op(mep.MEPUnjoinPair)
|
||||
selected = [Mock(), Mock()]
|
||||
|
||||
with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object(
|
||||
mep.tool.Ifc, "get_entity", side_effect=[not_a_segment, not_a_segment]
|
||||
):
|
||||
result = mep.MEPUnjoinPair._execute(op, context=MagicMock())
|
||||
|
||||
assert result == {"CANCELLED"}
|
||||
op.report.assert_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SelectMEPPathMembers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_select_path_replaces_selection_with_walked_members():
|
||||
"""Happy path: walker returns a small connected network → every
|
||||
member gets ``select_set(True)``; the original active object stays
|
||||
active."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
active = Mock()
|
||||
element = Mock()
|
||||
member_elements = [Mock(), Mock(), Mock()]
|
||||
member_objs = [Mock(), Mock(), Mock()]
|
||||
|
||||
context = MagicMock()
|
||||
context.active_object = active
|
||||
context.view_layer.objects.active = None
|
||||
|
||||
op = _make_op(mep.SelectMEPPathMembers)
|
||||
|
||||
with patch.object(mep.tool.Ifc, "get_entity", return_value=element), patch.object(
|
||||
mep.tool.System, "walk_connected_mep_elements", return_value=member_elements
|
||||
), patch.object(mep.tool.Ifc, "get_object", side_effect=member_objs), patch.object(
|
||||
mep.bpy.ops.object, "select_all"
|
||||
):
|
||||
result = mep.SelectMEPPathMembers.execute(op, context)
|
||||
|
||||
assert result == {"FINISHED"}
|
||||
for obj in member_objs:
|
||||
obj.select_set.assert_called_once_with(True)
|
||||
|
||||
|
||||
def test_select_path_reports_when_walker_returns_empty():
|
||||
"""An MEP element with no connected neighbours produces an empty
|
||||
walk; report INFO so the user knows the click registered, return
|
||||
FINISHED so the operator doesn't surface as an error."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
active = Mock()
|
||||
element = Mock()
|
||||
|
||||
context = MagicMock()
|
||||
context.active_object = active
|
||||
|
||||
op = _make_op(mep.SelectMEPPathMembers)
|
||||
|
||||
with patch.object(mep.tool.Ifc, "get_entity", return_value=element), patch.object(
|
||||
mep.tool.System, "walk_connected_mep_elements", return_value=[]
|
||||
):
|
||||
result = mep.SelectMEPPathMembers.execute(op, context)
|
||||
|
||||
assert result == {"FINISHED"}
|
||||
op.report.assert_called()
|
||||
|
||||
|
||||
def test_select_path_handles_walker_exception():
|
||||
"""The walker can raise on malformed port graphs; the operator must
|
||||
catch and surface as ERROR rather than crashing the operator harness."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
active = Mock()
|
||||
element = Mock()
|
||||
|
||||
context = MagicMock()
|
||||
context.active_object = active
|
||||
|
||||
op = _make_op(mep.SelectMEPPathMembers)
|
||||
|
||||
with patch.object(mep.tool.Ifc, "get_entity", return_value=element), patch.object(
|
||||
mep.tool.System, "walk_connected_mep_elements", side_effect=RuntimeError("malformed port graph")
|
||||
):
|
||||
result = mep.SelectMEPPathMembers.execute(op, context)
|
||||
|
||||
assert result == {"CANCELLED"}
|
||||
op.report.assert_called()
|
||||
@@ -1,533 +0,0 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Unit tests for the pipe/duct segment parametric-edit scaffolding.
|
||||
|
||||
Covers three surfaces that ship together as the first MEP dimension-gizmo
|
||||
feature:
|
||||
|
||||
- ``tool.Parametric.is_pipe_segment`` / ``is_duct_segment`` predicates
|
||||
(registry contract — must be total).
|
||||
- ``_segment_world_length`` / ``_preview_segment_via_scale`` /
|
||||
``_restore_segment_scale`` pure helpers driving the live preview.
|
||||
- ``GizmoPipeSegmentEdition`` / ``GizmoDuctSegmentEdition`` class wiring
|
||||
(bl_idname, operator bindings, dimension_gizmo_props, is_element_type).
|
||||
|
||||
Full operator round-trips (enable → drag → finish → IFC commit) need a real
|
||||
Blender + IFC scene and are deferred to a later integration session."""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import pytest
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Predicates — total over arbitrary IFC entity input
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ifc_class,is_pipe_expected,is_duct_expected",
|
||||
[
|
||||
("IfcPipeSegment", True, False),
|
||||
("IfcDuctSegment", False, True),
|
||||
("IfcFlowSegment", False, False), # base class — neither pipe nor duct alone
|
||||
("IfcPipeFitting", False, False), # fitting, not a segment
|
||||
("IfcDuctFitting", False, False),
|
||||
("IfcWall", False, False),
|
||||
("IfcAnnotation", False, False), # bare schema element with no MEP semantics
|
||||
],
|
||||
)
|
||||
def test_is_pipe_or_duct_segment_predicate_truth_table(ifc_class, is_pipe_expected, is_duct_expected):
|
||||
"""The two predicates must classify every IFC class correctly AND
|
||||
return False (not raise) on classes that have nothing to do with MEP.
|
||||
Pinned alongside the registry-wide predicate-totality test so a
|
||||
regression in either direction surfaces in this file too."""
|
||||
from bonsai import tool
|
||||
|
||||
probe = ifcopenshell.file(schema="IFC4").create_entity(ifc_class)
|
||||
assert tool.Parametric.is_pipe_segment(probe) is is_pipe_expected
|
||||
assert tool.Parametric.is_duct_segment(probe) is is_duct_expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _segment_world_length — pure geometric helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_segment_world_length_returns_axis_magnitude():
|
||||
"""The length read here drives both the dimension gizmo's display and
|
||||
the snap_length captured on enable. Pin the math on a known axis."""
|
||||
from bonsai.bim.module.model.mep import _segment_world_length
|
||||
|
||||
fake_obj = object()
|
||||
axis = (Vector((1.0, 2.0, 3.0)), Vector((1.0, 2.0, 5.5)))
|
||||
with patch("bonsai.tool.Model.get_flow_segment_axis", return_value=axis):
|
||||
assert _segment_world_length(fake_obj) == pytest.approx(2.5)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Preview helpers — obj.scale.z manipulation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeObj:
|
||||
"""Stand-in for bpy.types.Object exposing only ``scale`` — enough for
|
||||
the preview helpers, which never touch IFC."""
|
||||
|
||||
def __init__(self):
|
||||
self.scale = Vector((1.0, 1.0, 1.0))
|
||||
|
||||
|
||||
def test_preview_segment_via_scale_sets_z_to_ratio():
|
||||
"""The visible-stretch ratio composes ``props_length / mesh_local_length`` where
|
||||
``mesh_local_length = snap_length / snap_object_scale_z``."""
|
||||
from bonsai.bim.module.model.mep import _preview_segment_via_scale
|
||||
|
||||
obj = _FakeObj()
|
||||
_preview_segment_via_scale(obj, props_length=2.0, snap_length=1.0, snap_object_scale_z=1.0)
|
||||
assert obj.scale.z == pytest.approx(2.0)
|
||||
|
||||
_preview_segment_via_scale(obj, props_length=0.5, snap_length=1.0, snap_object_scale_z=1.0)
|
||||
assert obj.scale.z == pytest.approx(0.5)
|
||||
|
||||
|
||||
def test_preview_segment_via_scale_floors_at_min_value():
|
||||
"""``props.length`` is clamped at FloatProperty min=0.01; the helper still
|
||||
defends against zero / negative so a runaway value can't invert the segment."""
|
||||
from bonsai.bim.module.model.mep import _preview_segment_via_scale
|
||||
|
||||
obj = _FakeObj()
|
||||
_preview_segment_via_scale(obj, props_length=0.0, snap_length=1.0, snap_object_scale_z=1.0)
|
||||
assert obj.scale.z == pytest.approx(0.01)
|
||||
|
||||
|
||||
def test_preview_segment_via_scale_skips_when_snap_is_zero():
|
||||
"""A zero ``snap_length`` would divide by zero — helper skips silently."""
|
||||
from bonsai.bim.module.model.mep import _preview_segment_via_scale
|
||||
|
||||
obj = _FakeObj()
|
||||
obj.scale.z = 3.0
|
||||
_preview_segment_via_scale(obj, props_length=1.0, snap_length=0.0, snap_object_scale_z=1.0)
|
||||
# No change.
|
||||
assert obj.scale.z == pytest.approx(3.0)
|
||||
|
||||
|
||||
def test_restore_segment_scale_resets_z_to_target():
|
||||
"""Pin that the reset only touches Z; X/Y stay whatever the user set."""
|
||||
from bonsai.bim.module.model.mep import _restore_segment_scale_to
|
||||
|
||||
obj = _FakeObj()
|
||||
obj.scale = Vector((0.5, 0.7, 4.2))
|
||||
_restore_segment_scale_to(obj, 1.0)
|
||||
assert obj.scale.x == pytest.approx(0.5)
|
||||
assert obj.scale.y == pytest.approx(0.7)
|
||||
assert obj.scale.z == pytest.approx(1.0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gizmo group class wiring — registration and config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"gizmo_cls_name,bl_idname,is_element_predicate",
|
||||
[
|
||||
("GizmoPipeSegmentEdition", "OBJECT_GGT_bim_pipe_segment_edition", "is_pipe_segment"),
|
||||
("GizmoDuctSegmentEdition", "OBJECT_GGT_bim_duct_segment_edition", "is_duct_segment"),
|
||||
],
|
||||
)
|
||||
def test_gizmo_group_class_wiring(gizmo_cls_name, bl_idname, is_element_predicate):
|
||||
"""Each gizmo group must:
|
||||
- declare the expected ``bl_idname`` (so it actually registers under that name);
|
||||
- have the matching ``is_element_type`` delegate to the right predicate
|
||||
(so it polls in for the right IFC class).
|
||||
"""
|
||||
from bonsai import tool
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
cls = getattr(mep, gizmo_cls_name)
|
||||
assert cls.bl_idname == bl_idname
|
||||
predicate = getattr(tool.Parametric, is_element_predicate)
|
||||
fake_element = Mock()
|
||||
fake_element.is_a.return_value = True
|
||||
with patch.object(tool.Parametric, is_element_predicate, side_effect=predicate) as p, patch.object(
|
||||
tool.System, "has_parametric_body", return_value=True
|
||||
):
|
||||
cls.is_element_type(fake_element)
|
||||
assert p.called, f"{gizmo_cls_name}.is_element_type did not delegate to Parametric.{is_element_predicate}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"gizmo_cls_name,enable_op,finish_op,cancel_op",
|
||||
[
|
||||
(
|
||||
"GizmoPipeSegmentEdition",
|
||||
"bim.enable_editing_pipe_segment",
|
||||
"bim.finish_editing_pipe_segment",
|
||||
"bim.cancel_editing_pipe_segment",
|
||||
),
|
||||
(
|
||||
"GizmoDuctSegmentEdition",
|
||||
"bim.enable_editing_duct_segment",
|
||||
"bim.finish_editing_duct_segment",
|
||||
"bim.cancel_editing_duct_segment",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_gizmo_lifecycle_bindings_reference_registered_operators(gizmo_cls_name, enable_op, finish_op, cancel_op):
|
||||
"""Catches the silent-regression where the gizmo's enable/finish/cancel
|
||||
string drifts away from the actual operator ``bl_idname``."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
cls = getattr(mep, gizmo_cls_name)
|
||||
assert cls.enable_editing_operator == enable_op
|
||||
assert cls.finish_editing_operator == finish_op
|
||||
assert cls.cancel_editing_operator == cancel_op
|
||||
# And the operators are actually registered.
|
||||
for op in (enable_op, finish_op, cancel_op):
|
||||
namespace, _, verb = op.partition(".")
|
||||
assert hasattr(
|
||||
getattr(bpy.ops, namespace), verb
|
||||
), f"{gizmo_cls_name} references {op!r} which is not a registered operator"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gizmo_cls_name", ["GizmoPipeSegmentEdition", "GizmoDuctSegmentEdition"])
|
||||
def test_gizmo_dimension_gizmo_props_has_single_length_entry(gizmo_cls_name):
|
||||
"""Phase 1 ships a single dimension (segment length). Pin the shape so
|
||||
a Phase 2 addition (diameter / width / height) is an intentional
|
||||
expansion rather than a drive-by edit."""
|
||||
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
cls = getattr(mep, gizmo_cls_name)
|
||||
assert len(cls.dimension_gizmo_props) == 1
|
||||
config = cls.dimension_gizmo_props[0]
|
||||
assert isinstance(config, DimensionGizmoConfig)
|
||||
assert config.attr_name == "length"
|
||||
assert tuple(config.axis) == (0, 0, 1)
|
||||
assert config.min_value == pytest.approx(0.01)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gizmo_cls_name", ["GizmoPipeSegmentEdition", "GizmoDuctSegmentEdition"])
|
||||
def test_length_dimension_has_matrix_position_so_rotation_is_respected(gizmo_cls_name):
|
||||
"""Regression guard for "edit-mode length dimension doesn't take local
|
||||
object rotation". Without ``matrix_position`` set, ``update_dimension_gizmos``
|
||||
falls back to ``base_matrix = Identity`` and the gizmo's intrinsic +X
|
||||
visual line is never rotated to the configured ``axis`` — the dimension
|
||||
renders perpendicular to the segment on a rotated pipe. Setting
|
||||
``matrix_position`` (even to ``(0, 0, 0)``) routes through
|
||||
``compose_gizmo_matrix`` which applies ``get_axis_rotation_matrix(axis)``
|
||||
so the line aligns with the segment's local +Z (extrusion axis) in
|
||||
world space."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
cls = getattr(mep, gizmo_cls_name)
|
||||
config = cls.dimension_gizmo_props[0]
|
||||
assert config.matrix_position is not None, (
|
||||
f"{gizmo_cls_name} length dimension is missing matrix_position — the gizmo will "
|
||||
"render along the object's local +X axis instead of the segment's local +Z."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extend-to-cursor — operator + element-specific gizmo wiring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"gizmo_cls_name,extend_operator",
|
||||
[
|
||||
("GizmoPipeSegmentEdition", "bim.extend_pipe_segment_to_cursor"),
|
||||
("GizmoDuctSegmentEdition", "bim.extend_duct_segment_to_cursor"),
|
||||
],
|
||||
)
|
||||
def test_extend_operator_binding(gizmo_cls_name, extend_operator):
|
||||
"""Each segment gizmo group must reference the matching extend operator
|
||||
AND that operator must actually be registered. Catches the silent
|
||||
regression where someone renames the extend bl_idname without updating
|
||||
the gizmo group's ``_extend_operator`` class attribute."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
cls = getattr(mep, gizmo_cls_name)
|
||||
assert cls._extend_operator == extend_operator
|
||||
namespace, _, verb = extend_operator.partition(".")
|
||||
assert hasattr(
|
||||
getattr(bpy.ops, namespace), verb
|
||||
), f"{gizmo_cls_name} references {extend_operator!r} which is not a registered operator"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("feature_attr", ["pipe_segment", "duct_segment"])
|
||||
def test_gizmo_preferences_field_exists(feature_attr):
|
||||
"""``GizmoPreferences`` must carry pipe_segment + duct_segment PointerProperties
|
||||
so ``get_gizmo_prefs()`` on the MEP gizmo groups resolves to a real PropertyGroup."""
|
||||
import bonsai.bim.ui as ui
|
||||
|
||||
assert feature_attr in ui.GizmoPreferences.__annotations__, (
|
||||
f"GizmoPreferences is missing the {feature_attr} PointerProperty; "
|
||||
f"MEP gizmo groups' get_gizmo_prefs() would raise AttributeError."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle operators are registered
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"op",
|
||||
[
|
||||
"bim.enable_editing_pipe_segment",
|
||||
"bim.finish_editing_pipe_segment",
|
||||
"bim.cancel_editing_pipe_segment",
|
||||
"bim.extend_pipe_segment_to_cursor",
|
||||
"bim.enable_editing_duct_segment",
|
||||
"bim.finish_editing_duct_segment",
|
||||
"bim.cancel_editing_duct_segment",
|
||||
"bim.extend_duct_segment_to_cursor",
|
||||
],
|
||||
)
|
||||
def test_segment_operators_are_registered(op):
|
||||
"""Smoke test mirroring ``test_parametric_registry``'s
|
||||
``test_every_entry_has_enable_op_registered`` for the operators added
|
||||
in this round. Catches the silent regression where the classes tuple
|
||||
in ``__init__.py`` drops one of them."""
|
||||
namespace, _, verb = op.partition(".")
|
||||
assert hasattr(getattr(bpy.ops, namespace), verb), f"Operator {op!r} is not registered."
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MEPSegmentExtendPreviewDecorator._compute_extend_preview_line — pure helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_extend_preview_line_returns_none_for_degenerate_segment():
|
||||
"""A zero-length segment has no endpoint to draw from. Pin so a future
|
||||
refactor doesn't divide-by-zero or render a phantom line at the
|
||||
object origin."""
|
||||
from bonsai.bim.module.model.decorator import MEPSegmentExtendPreviewDecorator
|
||||
|
||||
result = MEPSegmentExtendPreviewDecorator._compute_extend_preview_line(
|
||||
matrix_world=Matrix.Identity(4),
|
||||
cursor_world=Vector((0.0, 0.0, 1.0)),
|
||||
current_length=0.0,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_extend_preview_line_returns_none_when_cursor_at_current_end():
|
||||
"""If the cursor projection matches the current segment length exactly,
|
||||
the extend operator would be a no-op — don't render the line either."""
|
||||
from bonsai.bim.module.model.decorator import MEPSegmentExtendPreviewDecorator
|
||||
|
||||
result = MEPSegmentExtendPreviewDecorator._compute_extend_preview_line(
|
||||
matrix_world=Matrix.Identity(4),
|
||||
cursor_world=Vector((0.0, 0.0, 1.5)),
|
||||
current_length=1.5,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_extend_preview_line_renders_extension_when_cursor_past_end():
|
||||
"""Happy path: cursor past current end → line runs from current end to
|
||||
the cursor's projected length. Identity matrix: local-Z maps 1:1 to
|
||||
world-Z. Pin the endpoints exactly."""
|
||||
from bonsai.bim.module.model.decorator import MEPSegmentExtendPreviewDecorator
|
||||
|
||||
result = MEPSegmentExtendPreviewDecorator._compute_extend_preview_line(
|
||||
matrix_world=Matrix.Identity(4),
|
||||
cursor_world=Vector((0.0, 0.0, 3.0)),
|
||||
current_length=1.0,
|
||||
)
|
||||
assert result is not None
|
||||
start, end = result
|
||||
assert tuple(start) == pytest.approx((0.0, 0.0, 1.0))
|
||||
assert tuple(end) == pytest.approx((0.0, 0.0, 3.0))
|
||||
|
||||
|
||||
def test_extend_preview_line_renders_trim_when_cursor_inside_segment():
|
||||
"""Cursor inside the segment → line runs from current end BACK to the
|
||||
projected (shorter) length."""
|
||||
from bonsai.bim.module.model.decorator import MEPSegmentExtendPreviewDecorator
|
||||
|
||||
result = MEPSegmentExtendPreviewDecorator._compute_extend_preview_line(
|
||||
matrix_world=Matrix.Identity(4),
|
||||
cursor_world=Vector((0.0, 0.0, 0.4)),
|
||||
current_length=1.0,
|
||||
)
|
||||
assert result is not None
|
||||
start, end = result
|
||||
assert tuple(start) == pytest.approx((0.0, 0.0, 1.0))
|
||||
assert tuple(end) == pytest.approx((0.0, 0.0, 0.4))
|
||||
|
||||
|
||||
def test_extend_preview_line_follows_raw_projection_behind_segment_origin():
|
||||
"""When the cursor's projected Z is negative (behind segment origin),
|
||||
the preview line must follow the raw cursor projection — the user is
|
||||
pointing somewhere and expects to see where, even though the operator
|
||||
would floor the actual commit. Matching the operator's clamp would
|
||||
hide the line whenever the cursor crossed the segment origin."""
|
||||
from bonsai.bim.module.model.decorator import MEPSegmentExtendPreviewDecorator
|
||||
|
||||
result = MEPSegmentExtendPreviewDecorator._compute_extend_preview_line(
|
||||
matrix_world=Matrix.Identity(4),
|
||||
cursor_world=Vector((0.0, 0.0, -2.0)),
|
||||
current_length=1.0,
|
||||
)
|
||||
assert result is not None
|
||||
start, end = result
|
||||
assert tuple(start) == pytest.approx((0.0, 0.0, 1.0))
|
||||
assert tuple(end) == pytest.approx((0.0, 0.0, -2.0))
|
||||
|
||||
|
||||
def test_extend_preview_line_respects_object_rotation():
|
||||
"""A rotated segment (90° around Y) should produce world-space endpoints
|
||||
rotated accordingly. Pin so a future refactor doesn't drop the
|
||||
matrix_world multiplication."""
|
||||
import math
|
||||
|
||||
from bonsai.bim.module.model.decorator import MEPSegmentExtendPreviewDecorator
|
||||
|
||||
rotation = Matrix.Rotation(math.pi / 2, 4, "Y")
|
||||
result = MEPSegmentExtendPreviewDecorator._compute_extend_preview_line(
|
||||
matrix_world=rotation,
|
||||
cursor_world=Vector((3.0, 0.0, 0.0)),
|
||||
current_length=1.0,
|
||||
)
|
||||
assert result is not None
|
||||
start, end = result
|
||||
# local (0, 0, 1) rotated by 90° around Y → world (1, 0, 0).
|
||||
assert tuple(start) == pytest.approx((1.0, 0.0, 0.0), abs=1e-6)
|
||||
# local (0, 0, 3) rotated by 90° around Y → world (3, 0, 0).
|
||||
assert tuple(end) == pytest.approx((3.0, 0.0, 0.0), abs=1e-6)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle drift handling — Enable / Finish / Cancel must commit / restore
|
||||
# matrix_world ↔ IFC ObjectPlacement at the appropriate lifecycle points.
|
||||
# The AST forward-compat guard pins "a drift hook IS called somewhere"; these
|
||||
# tests pin "the hook is called in the right branch with the right args."
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_segment_context(length=2.0, snap_length=2.0, scale_z=1.0):
|
||||
"""Build (context, props, obj, element) fakes for the MEP edit-lifecycle bases.
|
||||
The bases access ``self.__class__._predicate`` / ``_props_getter`` so
|
||||
callers must instantiate a concrete test subclass and call
|
||||
``instance._execute(context)`` rather than passing a Mock as ``self``."""
|
||||
obj = Mock(name="obj")
|
||||
obj.scale = Vector((1.0, 1.0, scale_z))
|
||||
element = Mock(name="element")
|
||||
props = Mock(name="props")
|
||||
props.length = length
|
||||
props.snap_length = snap_length
|
||||
props.snap_object_scale_z = scale_z
|
||||
props.mesh_dirty = False
|
||||
|
||||
context = Mock(name="context")
|
||||
context.active_object = obj
|
||||
return context, props, obj, element
|
||||
|
||||
|
||||
def _concrete_mep_mixin(props):
|
||||
"""Build a concrete ``_MEPSegmentEditMixin`` subclass that bypasses the
|
||||
IFC predicate gate and returns the supplied ``props`` from ``_get_props``.
|
||||
The unified mixin replaced the three-base-class lifecycle pattern; tests now
|
||||
target the single mixin and override the two ParametricEditMixinBase
|
||||
hooks instead of class-level ``_predicate`` / ``_props_getter``."""
|
||||
from bonsai.bim.module.model.mep import _MEPSegmentEditMixin
|
||||
|
||||
class _ConcreteMEPMixin(_MEPSegmentEditMixin):
|
||||
@classmethod
|
||||
def _is_element_type(cls, element):
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def _get_props(cls, obj):
|
||||
return props
|
||||
|
||||
return _ConcreteMEPMixin
|
||||
|
||||
|
||||
def test_enable_pipe_segment_commits_pre_edit_placement_drift():
|
||||
"""Enable must call ``commit_placement_if_moved(obj, apply_scale=False)``
|
||||
BEFORE ``_segment_world_length`` captures ``snap_length``. Without the
|
||||
commit, snap_length is read from a dragged matrix_world while the IFC
|
||||
ObjectPlacement is stale — Finish's set_depth would then write
|
||||
representation coords relative to the wrong origin."""
|
||||
context, props, obj, element = _make_segment_context()
|
||||
cls = _concrete_mep_mixin(props)
|
||||
|
||||
with (
|
||||
patch("bonsai.bim.module.model.mep.tool") as mock_tool,
|
||||
patch("bonsai.bim.parametric_lifecycle.tool", mock_tool),
|
||||
patch("bonsai.bim.module.model.mep._segment_world_length", return_value=2.0),
|
||||
):
|
||||
mock_tool.Ifc.get_entity.return_value = element
|
||||
cls()._enable_targets(context)
|
||||
|
||||
mock_tool.Geometry.commit_placement_if_moved.assert_called_once_with(obj, apply_scale=False)
|
||||
|
||||
|
||||
def test_finish_pipe_segment_commits_drift_when_no_length_change():
|
||||
"""Finish without a length change must STILL commit matrix_world drift —
|
||||
the bug class that motivated this guard. The conditional ``set_depth``
|
||||
branch covers the length-changed path transitively; the unconditional
|
||||
``commit_placement_if_moved`` after the if/else closes the silent-drop
|
||||
path."""
|
||||
# length == snap_length → no-op session.
|
||||
context, props, obj, element = _make_segment_context(length=2.0, snap_length=2.0)
|
||||
cls = _concrete_mep_mixin(props)
|
||||
|
||||
with (
|
||||
patch("bonsai.bim.module.model.mep.tool") as mock_tool,
|
||||
patch("bonsai.bim.parametric_lifecycle.tool", mock_tool),
|
||||
patch("bonsai.bim.module.model.mep.DumbProfileJoiner") as mock_joiner,
|
||||
patch("bonsai.bim.module.model.mep._restore_segment_mesh_if_dirty"),
|
||||
patch("bonsai.bim.module.model.mep._restore_segment_scale_to"),
|
||||
):
|
||||
mock_tool.Ifc.get_entity.return_value = element
|
||||
cls()._finish_targets(context)
|
||||
mock_joiner.return_value.set_depth.assert_not_called() # no-length branch
|
||||
|
||||
mock_tool.Geometry.commit_placement_if_moved.assert_called_once_with(obj)
|
||||
|
||||
|
||||
def test_cancel_pipe_segment_delegates_to_restore_or_rebaseline():
|
||||
"""Cancel must call ``tool.Geometry.restore_or_rebaseline_placement`` so
|
||||
matrix_world reverts in lockstep with the props draft. The helper owns
|
||||
the is_moved / ObjectPlacement gate."""
|
||||
context, props, obj, element = _make_segment_context()
|
||||
cls = _concrete_mep_mixin(props)
|
||||
|
||||
with (
|
||||
patch("bonsai.bim.module.model.mep.tool") as mock_tool,
|
||||
patch("bonsai.bim.parametric_lifecycle.tool", mock_tool),
|
||||
patch("bonsai.bim.module.model.mep._restore_segment_mesh_if_dirty"),
|
||||
):
|
||||
mock_tool.Ifc.get_entity.return_value = element
|
||||
cls()._cancel_targets(context)
|
||||
|
||||
mock_tool.Geometry.restore_or_rebaseline_placement.assert_called_once_with(obj, element)
|
||||
@@ -1,221 +0,0 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Tests for the parametric-edit preview registry contract.
|
||||
|
||||
Every test reads the live ``PREVIEW_CANCEL_OPS`` registry rather than hard-
|
||||
coding preview keys or cancel-operator names, so adding a new preview to the
|
||||
registry automatically exercises the same invariants without test changes."""
|
||||
|
||||
import types
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
def _registry():
|
||||
from bonsai.bim.module.model.preview_base import PREVIEW_CANCEL_OPS
|
||||
|
||||
return PREVIEW_CANCEL_OPS
|
||||
|
||||
|
||||
def _preview_umbrella():
|
||||
return getattr(bpy.context.scene, "BIMPreviewProperties", None)
|
||||
|
||||
|
||||
def _registered_previews():
|
||||
"""``[(attr, op_name, props)]`` for every registry entry that has a real
|
||||
child PropertyGroup on the umbrella in the current addon build."""
|
||||
umbrella = _preview_umbrella()
|
||||
if umbrella is None:
|
||||
return []
|
||||
out = []
|
||||
for attr, op_name in _registry():
|
||||
props = getattr(umbrella, attr, None)
|
||||
if props is not None:
|
||||
out.append((attr, op_name, props))
|
||||
return out
|
||||
|
||||
|
||||
class TestRegistryContract:
|
||||
"""Pins the invariant that every entry in PREVIEW_CANCEL_OPS resolves to
|
||||
a real cancel operator the addon registers. A new preview added to the
|
||||
registry without its matching cancel operator would otherwise crash
|
||||
``try_cancel_active_preview`` on the first Esc."""
|
||||
|
||||
def test_every_registered_cancel_op_is_callable(self):
|
||||
for attr, op_name in _registry():
|
||||
op = getattr(bpy.ops.bim, op_name, None)
|
||||
assert op is not None and callable(op), (
|
||||
f"Preview '{attr}' in PREVIEW_CANCEL_OPS points to bim.{op_name} "
|
||||
f"but no such operator is registered."
|
||||
)
|
||||
|
||||
|
||||
class TestGetPreviewPropsTolerance:
|
||||
"""The bug-class fixed in commit ee63137c6: ``get_preview_props`` is called
|
||||
from gizmo polls during addon init and from test mocks built on
|
||||
``SimpleNamespace`` — neither has a fully-formed Blender context. The
|
||||
helper must return None rather than raise."""
|
||||
|
||||
def test_returns_none_when_context_has_no_scene(self):
|
||||
from bonsai.bim.module.model.preview_base import get_preview_props
|
||||
|
||||
# Pass an arbitrary attr name — the contract is the same for every
|
||||
# preview key, so picking one literally would be a maintenance trap.
|
||||
for attr, _ in _registry():
|
||||
assert get_preview_props(types.SimpleNamespace(), attr) is None
|
||||
break
|
||||
|
||||
def test_returns_none_when_scene_lacks_umbrella(self):
|
||||
from bonsai.bim.module.model.preview_base import get_preview_props
|
||||
|
||||
ctx = types.SimpleNamespace(scene=types.SimpleNamespace())
|
||||
for attr, _ in _registry():
|
||||
assert get_preview_props(ctx, attr) is None
|
||||
break
|
||||
|
||||
|
||||
class TestActivationCycle:
|
||||
"""End-to-end contract on the real addon: each registered preview can be
|
||||
activated and then cancelled to inactive. Runs for every preview that
|
||||
has a wired PropertyGroup, so a new preview added to the registry +
|
||||
umbrella is covered without test edits."""
|
||||
|
||||
def test_any_preview_active_reflects_each_preview_state(self):
|
||||
from bonsai.bim.module.model.preview_base import any_preview_active
|
||||
|
||||
registered = _registered_previews()
|
||||
if not registered:
|
||||
pytest.skip("No previews wired in this build — registry-only entries")
|
||||
|
||||
# All inactive baseline.
|
||||
for _, _, props in registered:
|
||||
props.is_active = False
|
||||
assert any_preview_active(bpy.context) is False
|
||||
|
||||
# Flip each one independently — the helper must report True.
|
||||
for _, _, props in registered:
|
||||
props.is_active = True
|
||||
assert any_preview_active(bpy.context) is True
|
||||
props.is_active = False
|
||||
|
||||
def test_discard_pending_previews_clears_every_active_flag(self):
|
||||
from bonsai.bim.module.model.preview_base import discard_pending_previews
|
||||
|
||||
registered = _registered_previews()
|
||||
if not registered:
|
||||
pytest.skip("No previews wired in this build — registry-only entries")
|
||||
|
||||
for _, _, props in registered:
|
||||
props.is_active = True
|
||||
discard_pending_previews(bpy.context.scene)
|
||||
for attr, _, props in registered:
|
||||
assert props.is_active is False, f"discard_pending_previews left '{attr}' active"
|
||||
|
||||
|
||||
class TestClearPreviewState:
|
||||
"""``clear_preview_state`` is the shared cleanup routine every preview
|
||||
operator calls on commit / cancel. The contract is: ``is_active`` flips
|
||||
to False, every ``*_id`` IntProperty zeroes, everything else stays."""
|
||||
|
||||
def test_clears_is_active_and_id_fields_on_real_property_groups(self):
|
||||
from bonsai.bim.module.model.preview_base import clear_preview_state
|
||||
|
||||
registered = _registered_previews()
|
||||
if not registered:
|
||||
pytest.skip("No previews wired in this build — registry-only entries")
|
||||
|
||||
for attr, _, props in registered:
|
||||
# Seed every *_id IntProperty with a non-zero sentinel and flip
|
||||
# the activity flag so the helper has something to clear.
|
||||
id_fields = [
|
||||
name for name, rna in props.bl_rna.properties.items() if name.endswith("_id") and rna.type == "INT"
|
||||
]
|
||||
assert id_fields, f"Preview '{attr}' has no *_id IntProperty — registry shape changed"
|
||||
for name in id_fields:
|
||||
setattr(props, name, 42)
|
||||
props.is_active = True
|
||||
|
||||
clear_preview_state(props)
|
||||
|
||||
assert props.is_active is False, f"Preview '{attr}' is_active not cleared"
|
||||
for name in id_fields:
|
||||
assert getattr(props, name) == 0, f"Preview '{attr}' field '{name}' not zeroed"
|
||||
|
||||
def test_leaves_non_id_fields_untouched(self):
|
||||
"""Non-``*_id`` fields (FloatProperty params like ``radius``,
|
||||
``start_length``) must survive the reset — they re-seed on the next
|
||||
enable, so untouching them here avoids a redundant write."""
|
||||
from bonsai.bim.module.model.preview_base import clear_preview_state
|
||||
|
||||
bend = getattr(_preview_umbrella(), "bend", None)
|
||||
if bend is None:
|
||||
pytest.skip("Bend preview not wired in this build")
|
||||
|
||||
bend.is_active = True
|
||||
bend.start_length = 0.42
|
||||
bend.radius = 0.99
|
||||
clear_preview_state(bend)
|
||||
|
||||
assert bend.is_active is False
|
||||
assert bend.start_length == pytest.approx(0.42)
|
||||
assert bend.radius == pytest.approx(0.99)
|
||||
|
||||
|
||||
class TestSaveOnDiscardWired:
|
||||
"""Pins that the SaveProject operator clears preview state before writing
|
||||
the IFC file — a stuck is_active flag persisted through the save would
|
||||
silently hide sister gizmos on the next file load.
|
||||
|
||||
Structural check: the SaveProject operator class must reference the
|
||||
discard helper somewhere in its execute path. Behavioural integration
|
||||
(actually saving a .blend with an active preview and reloading) belongs
|
||||
in the bim feature suite; this is the small guard against accidental
|
||||
removal of the call site."""
|
||||
|
||||
def test_save_project_dispatches_discard_pending_previews(self):
|
||||
import inspect
|
||||
|
||||
from bonsai.bim.module.model import preview_base
|
||||
from bonsai.bim.module.project import operator as project_operator
|
||||
|
||||
# Find the project save operator dynamically — looking for any
|
||||
# Operator class whose bl_idname is "bim.save_project". Avoids
|
||||
# hard-coding the class identifier.
|
||||
save_op = None
|
||||
for name in dir(project_operator):
|
||||
obj = getattr(project_operator, name)
|
||||
if isinstance(obj, type) and getattr(obj, "bl_idname", None) == "bim.save_project":
|
||||
save_op = obj
|
||||
break
|
||||
assert save_op is not None, "Expected an operator with bl_idname='bim.save_project' in project/operator.py"
|
||||
|
||||
# Walk the class's methods for the discard call. Avoids pinning a
|
||||
# specific method name (_execute vs execute vs an inner helper) so
|
||||
# the test survives operator refactors.
|
||||
source = inspect.getsource(save_op)
|
||||
assert preview_base.discard_pending_previews.__name__ in source, (
|
||||
f"{save_op.__name__} does not reference discard_pending_previews. "
|
||||
"Saving with a preview open would persist its is_active flag to the "
|
||||
".blend file and silently hide sister gizmos on reopen."
|
||||
)
|
||||
@@ -1,306 +0,0 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Unit tests for the roof parametric gizmo group.
|
||||
|
||||
Covers the parts of ``GizmoRoofEdition`` that don't need a live Blender
|
||||
viewport: the mode-conditional ``visibility_condition`` lambdas, the
|
||||
slope ``compute_value`` / ``apply_value`` roundtrip, the
|
||||
``CycleRoofGenerationMethod`` operator metadata + cycle behaviour, and
|
||||
the ``_update_dimension_gizmo_positions`` override that anchors all three
|
||||
dimension gizmos at the object's local origin."""
|
||||
|
||||
import math
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
def _get_config(attr_name):
|
||||
"""Return the ``DimensionGizmoConfig`` for ``attr_name`` from the roof gizmo."""
|
||||
from bonsai.bim.module.model.roof import GizmoRoofEdition
|
||||
|
||||
for cfg in GizmoRoofEdition.dimension_gizmo_props:
|
||||
if cfg.attr_name == attr_name:
|
||||
return cfg
|
||||
raise AssertionError(f"no DimensionGizmoConfig with attr_name={attr_name!r}")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Mode-conditional visibility
|
||||
# ----------------------------------------------------------------------------
|
||||
#
|
||||
# ``height`` and ``angle`` are mutually exclusive — exactly one is shown
|
||||
# depending on ``generation_method``. ``roof_thickness`` applies regardless
|
||||
# of the generation mode.
|
||||
|
||||
|
||||
def test_height_gizmo_visible_only_in_height_mode():
|
||||
cfg = _get_config("height")
|
||||
assert cfg.visibility_condition(SimpleNamespace(generation_method="HEIGHT")) is True
|
||||
assert cfg.visibility_condition(SimpleNamespace(generation_method="ANGLE")) is False
|
||||
|
||||
|
||||
def test_angle_gizmo_visible_only_in_angle_mode():
|
||||
cfg = _get_config("angle")
|
||||
assert cfg.visibility_condition(SimpleNamespace(generation_method="ANGLE")) is True
|
||||
assert cfg.visibility_condition(SimpleNamespace(generation_method="HEIGHT")) is False
|
||||
|
||||
|
||||
def test_thickness_has_no_mode_gate():
|
||||
"""Slab thickness applies to both generation modes — pinning
|
||||
``visibility_condition is None`` guards against an accidental mode-gate
|
||||
being added later that would silently hide it when toggling modes."""
|
||||
assert _get_config("roof_thickness").visibility_condition is None
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Slope (angle) ↔ rise roundtrip
|
||||
# ----------------------------------------------------------------------------
|
||||
#
|
||||
# The slope handle displays vertical rise at a fixed 1m run; dragging it
|
||||
# updates ``props.angle`` via ``atan2(rise, run)``. Roundtrip preservation
|
||||
# is the contract — feeding ``compute_value`` into ``apply_value`` must
|
||||
# leave the angle unchanged (within float tolerance).
|
||||
|
||||
|
||||
def test_slope_compute_value_returns_rise_at_reference_run():
|
||||
from bonsai.bim.module.model.roof import _ROOF_SLOPE_REFERENCE_RUN
|
||||
|
||||
cfg = _get_config("angle")
|
||||
# 30° slope → rise = tan(30°) * 1m ≈ 0.5774 m
|
||||
props = SimpleNamespace(angle=math.radians(30))
|
||||
assert cfg.compute_value(props) == pytest.approx(math.tan(math.radians(30)) * _ROOF_SLOPE_REFERENCE_RUN)
|
||||
|
||||
|
||||
def test_slope_apply_value_sets_angle_from_rise():
|
||||
from bonsai.bim.module.model.roof import _ROOF_SLOPE_REFERENCE_RUN
|
||||
|
||||
cfg = _get_config("angle")
|
||||
props = SimpleNamespace(angle=0.0)
|
||||
cfg.apply_value(props, 0.5)
|
||||
assert props.angle == pytest.approx(math.atan2(0.5, _ROOF_SLOPE_REFERENCE_RUN))
|
||||
|
||||
|
||||
def test_slope_roundtrip_preserves_angle():
|
||||
cfg = _get_config("angle")
|
||||
for deg in (5, 15, 30, 45, 60, 80):
|
||||
props = SimpleNamespace(angle=math.radians(deg))
|
||||
rise = cfg.compute_value(props)
|
||||
cfg.apply_value(props, rise)
|
||||
assert math.degrees(props.angle) == pytest.approx(deg, abs=1e-6)
|
||||
|
||||
|
||||
def test_slope_apply_value_clamps_negative_to_zero():
|
||||
"""A negative drag (rise < 0) must not produce a negative angle —
|
||||
``atan2(-x, run)`` would yield a negative result, but ``apply_value``
|
||||
clamps to ``[0, pi/2 - 1e-3]`` so the roof never inverts."""
|
||||
cfg = _get_config("angle")
|
||||
props = SimpleNamespace(angle=math.radians(30))
|
||||
cfg.apply_value(props, -1.0)
|
||||
assert props.angle == 0.0
|
||||
|
||||
|
||||
def test_slope_apply_value_clamps_at_near_vertical():
|
||||
"""Slopes approaching 90° are clamped just below to avoid a vertical
|
||||
extrusion that would degenerate the bisect step in
|
||||
``generate_hipped_roof_bmesh``."""
|
||||
from bonsai.bim.module.model.roof import _ROOF_MAX_SLOPE_ANGLE
|
||||
|
||||
cfg = _get_config("angle")
|
||||
props = SimpleNamespace(angle=0.0)
|
||||
cfg.apply_value(props, 1e9) # absurdly steep
|
||||
assert props.angle == pytest.approx(_ROOF_MAX_SLOPE_ANGLE)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Cycle operator metadata
|
||||
# ----------------------------------------------------------------------------
|
||||
#
|
||||
# ``CycleRoofGenerationMethod`` plugs into ``CycleTypeMixin`` so the
|
||||
# HEIGHT ↔ ANGLE icon cycles through the two values. The mixin reads four
|
||||
# class attributes to do its work; if any drift, the cycle no-ops or
|
||||
# CANCELLED-loops in subtle ways. Pin them here.
|
||||
|
||||
|
||||
def test_cycle_operator_class_metadata():
|
||||
from typing import get_args
|
||||
|
||||
from bonsai import tool
|
||||
from bonsai.bim.module.model.roof import CycleRoofGenerationMethod
|
||||
|
||||
assert CycleRoofGenerationMethod.bl_idname == "bim.cycle_roof_generation_method"
|
||||
assert CycleRoofGenerationMethod.element_checker == tool.Parametric.is_roof
|
||||
assert CycleRoofGenerationMethod.props_getter == tool.Model.get_roof_props
|
||||
assert CycleRoofGenerationMethod.type_attr == "generation_method"
|
||||
# The Literal resolves to ("HEIGHT", "ANGLE") — the mixin calls
|
||||
# ``get_args(type_literal)`` to enumerate the cycle.
|
||||
assert get_args(CycleRoofGenerationMethod.type_literal) == ("HEIGHT", "ANGLE")
|
||||
assert CycleRoofGenerationMethod.type_literal is tool.Model.RoofGenerationMethod
|
||||
|
||||
|
||||
def test_cycle_operator_wired_on_gizmo_group():
|
||||
"""The gizmo group's ``cycle_type_operator`` must match the bl_idname or
|
||||
the base class skips the cycle icon entirely (see gizmos.py:4987)."""
|
||||
from bonsai.bim.module.model.roof import CycleRoofGenerationMethod, GizmoRoofEdition
|
||||
|
||||
assert GizmoRoofEdition.cycle_type_operator == CycleRoofGenerationMethod.bl_idname
|
||||
|
||||
|
||||
def _cycle_stub_self(*, reverse: bool, props, element_is_target: bool = True):
|
||||
"""Build a stub ``self`` for ``CycleTypeMixin._cycle_type``.
|
||||
|
||||
``bpy.types.Operator`` subclasses can't be ``__init__``-ed outside of
|
||||
Blender's registration path (``bpy_struct.__new__`` rejects a bare
|
||||
call). Calling the unbound mixin method with a stub ``self`` that
|
||||
mirrors the class attributes the method reads is the cleanest way to
|
||||
exercise the cycle logic without launching a registered operator
|
||||
instance.
|
||||
|
||||
``element_checker`` and ``props_getter`` are captured by the cycle
|
||||
operator at class-definition time, so global ``tool.*`` patches at
|
||||
test time can't intercept them — the stub injects callables directly
|
||||
instead. ``_resolve_target`` is bound from ``TypeAccessorBase`` so
|
||||
the cycle method's call into it dispatches against the stub
|
||||
attributes."""
|
||||
from types import MethodType
|
||||
|
||||
from bonsai.bim.module.model.roof import CycleRoofGenerationMethod
|
||||
from bonsai.bim.parametric_lifecycle import TypeAccessorBase
|
||||
|
||||
stub = SimpleNamespace(
|
||||
reverse=reverse,
|
||||
skip_element_check=False,
|
||||
element_checker=lambda _elem: element_is_target,
|
||||
props_getter=lambda _obj: props,
|
||||
type_literal=CycleRoofGenerationMethod.type_literal,
|
||||
type_attr=CycleRoofGenerationMethod.type_attr,
|
||||
)
|
||||
stub._resolve_target = MethodType(TypeAccessorBase._resolve_target, stub)
|
||||
return stub
|
||||
|
||||
|
||||
def test_cycle_type_advances_forward():
|
||||
"""``_cycle_type`` advances the prop value to the next item in the
|
||||
Literal. The stub injects ``element_checker`` / ``props_getter``
|
||||
directly so the method runs without a live IFC fixture."""
|
||||
from bonsai import tool
|
||||
from bonsai.bim import parametric_lifecycle as gizmo_module
|
||||
|
||||
props = SimpleNamespace(generation_method="HEIGHT")
|
||||
context = SimpleNamespace(active_object=object())
|
||||
|
||||
with patch.object(tool.Ifc, "get_entity", return_value=object()):
|
||||
result = gizmo_module.CycleTypeMixin._cycle_type(_cycle_stub_self(reverse=False, props=props), context)
|
||||
assert result == {"FINISHED"}
|
||||
assert props.generation_method == "ANGLE"
|
||||
|
||||
|
||||
def test_cycle_type_reverse_walks_backward():
|
||||
"""Shift+click sets ``reverse=True`` and walks the cycle in the other
|
||||
direction — from HEIGHT that means wrapping to ANGLE (the last item)."""
|
||||
from bonsai import tool
|
||||
from bonsai.bim import parametric_lifecycle as gizmo_module
|
||||
|
||||
props = SimpleNamespace(generation_method="HEIGHT")
|
||||
context = SimpleNamespace(active_object=object())
|
||||
|
||||
with patch.object(tool.Ifc, "get_entity", return_value=object()):
|
||||
gizmo_module.CycleTypeMixin._cycle_type(_cycle_stub_self(reverse=True, props=props), context)
|
||||
assert props.generation_method == "ANGLE" # wrapped from HEIGHT backward
|
||||
|
||||
|
||||
def test_cycle_type_cancels_when_active_is_not_a_roof():
|
||||
"""Non-roof active object → CANCELLED, props untouched. Guards against
|
||||
a stray cycle click on a wall mutating ``wall.generation_method`` (a
|
||||
non-existent attr) and silently no-oping or AttributeError-ing later."""
|
||||
from bonsai import tool
|
||||
from bonsai.bim import parametric_lifecycle as gizmo_module
|
||||
|
||||
props = SimpleNamespace(generation_method="HEIGHT")
|
||||
context = SimpleNamespace(active_object=object())
|
||||
|
||||
with patch.object(tool.Ifc, "get_entity", return_value=object()):
|
||||
result = gizmo_module.CycleTypeMixin._cycle_type(
|
||||
_cycle_stub_self(reverse=False, props=props, element_is_target=False),
|
||||
context,
|
||||
)
|
||||
assert result == {"CANCELLED"}
|
||||
assert props.generation_method == "HEIGHT"
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# _update_dimension_gizmo_positions — origin anchoring
|
||||
# ----------------------------------------------------------------------------
|
||||
#
|
||||
# All three dimension gizmos anchor at the object's local origin. Their
|
||||
# declared axes (height/slope +Z, thickness -Z) separate them in 3D so
|
||||
# they don't visually collide despite sharing a position; height + slope
|
||||
# are themselves mutually exclusive via visibility_condition on
|
||||
# generation_method.
|
||||
|
||||
|
||||
def test_override_positions_all_dimensions_at_object_origin():
|
||||
"""The override calls ``set_dimension_gizmo_position`` with the
|
||||
object-local origin (0, 0, 0) for every dimension gizmo. Anchoring at
|
||||
the object origin keeps the gizmos tied to the object's matrix_world
|
||||
rather than to footprint geometry that may not be cached yet — fixes
|
||||
the first-click default-identity-matrix bug structurally."""
|
||||
from bonsai.bim.module.model.roof import GizmoRoofEdition
|
||||
|
||||
calls: dict[str, tuple] = {}
|
||||
|
||||
def record(attr_name, _mw, position, axis, _value=None):
|
||||
calls[attr_name] = (position, axis)
|
||||
|
||||
stub = SimpleNamespace(set_dimension_gizmo_position=record)
|
||||
GizmoRoofEdition._update_dimension_gizmo_positions(stub, context=None, mw=None, props=None)
|
||||
|
||||
assert set(calls) == {"height", "angle", "roof_thickness"}
|
||||
for name in ("height", "angle", "roof_thickness"):
|
||||
position, _axis = calls[name]
|
||||
assert position.xyz[:] == pytest.approx(
|
||||
(0.0, 0.0, 0.0)
|
||||
), f"{name} anchored at {position.xyz[:]} instead of object origin"
|
||||
# Axes split the three handles along Z+ (height/slope) vs Z- (thickness)
|
||||
# so they don't visually collide despite sharing the anchor point.
|
||||
assert calls["height"][1] == (0, 0, 1)
|
||||
assert calls["angle"][1] == (0, 0, 1)
|
||||
assert calls["roof_thickness"][1] == (0, 0, -1)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Registration smoke test
|
||||
# ----------------------------------------------------------------------------
|
||||
#
|
||||
# Pattern 4 from _shared/bonsai-test-patterns.md: assert the operator is
|
||||
# actually registered as ``bim.cycle_roof_generation_method``. Catches
|
||||
# ``bl_idname`` typos and missing-from-``classes``-tuple regressions at
|
||||
# test time rather than at user-click time (the failure mode otherwise is
|
||||
# a silent no-op on the cycle icon, because the gizmo base class skips the
|
||||
# icon entirely if its ``cycle_type_operator`` resolves to nothing).
|
||||
|
||||
|
||||
def test_cycle_operator_is_registered_under_bim_namespace():
|
||||
assert hasattr(bpy.ops.bim, "cycle_roof_generation_method")
|
||||
@@ -39,6 +39,12 @@ import pytest
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _require_real_bpy():
|
||||
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
|
||||
pytest.skip("requires real Blender (bpy is mocked or absent)")
|
||||
|
||||
|
||||
def _rotation_close(a, b, tol: float = 1e-6) -> bool:
|
||||
for row_a, row_b in zip(a, b):
|
||||
for va, vb in zip(row_a, row_b):
|
||||
@@ -127,75 +133,3 @@ def test_set_icon_gizmo_position_does_not_apply_object_rotation():
|
||||
for row_a, row_b in zip(stub.matrix_basis, expected):
|
||||
for va, vb in zip(row_a, row_b):
|
||||
assert abs(va - vb) < 1e-6
|
||||
|
||||
|
||||
def test_icon_slot_placeholder_skips_validation_and_returns_no_attrs():
|
||||
"""Placeholder slots reserve an X position without an auto-created gizmo:
|
||||
construction must not require ``gizmo_idname`` / ``operator``, and
|
||||
``gizmo_attrs()`` must return an empty tuple so the base class's
|
||||
setup/positioning loops naturally skip the slot."""
|
||||
from bonsai.bim.module.drawing.gizmos import IconSlot
|
||||
|
||||
slot = IconSlot(name="my_label", placeholder=True)
|
||||
assert slot.placeholder is True
|
||||
assert slot.gizmo_attrs() == ()
|
||||
|
||||
with pytest.raises(TypeError, match="gizmo_idname"):
|
||||
IconSlot(name="broken")
|
||||
|
||||
|
||||
def test_count_label_gizmo_is_registered():
|
||||
"""The shared text-only ``xN`` gizmo must register so the stair group's
|
||||
``gizmos.new("BIM_GT_count_label")`` resolves."""
|
||||
from bonsai.bim.module.drawing.gizmos import GizmoCountLabel
|
||||
|
||||
assert GizmoCountLabel.bl_idname == "BIM_GT_count_label"
|
||||
assert bpy.types.Gizmo.bl_rna_get_subclass_py("BIM_GT_count_label") is GizmoCountLabel
|
||||
|
||||
|
||||
def test_stair_edit_row_reserves_label_slot_between_tread_lock_and_plus():
|
||||
"""The ``tread_count_label`` placeholder slot must sit one
|
||||
``ICON_ARRAY_GAP`` past the tread-lock and one gap before the plus
|
||||
icon, so the layout naturally allocates the count label's X without
|
||||
any subclass-side gap math."""
|
||||
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
|
||||
from bonsai.bim.module.model.stair import GizmoStairEdition
|
||||
|
||||
slot_x = GizmoStairEdition._slot_x_positions()
|
||||
gap = BaseParametricGizmoGroup.ICON_ARRAY_GAP
|
||||
|
||||
assert "tread_count_label" in slot_x
|
||||
assert slot_x["tread_count_label"] - slot_x["tread_lock"] == pytest.approx(gap)
|
||||
assert slot_x["plus"] - slot_x["tread_count_label"] == pytest.approx(gap)
|
||||
assert slot_x["minus"] - slot_x["plus"] == pytest.approx(gap)
|
||||
|
||||
|
||||
def test_update_tread_count_gizmos_toggles_label_with_editing():
|
||||
"""``update_tread_count_gizmos`` must propagate ``props.is_editing``
|
||||
to the label's hide state so the badge appears only inside edit mode."""
|
||||
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
|
||||
from bonsai.bim.module.model.stair import GizmoStairEdition
|
||||
|
||||
class _GizmoStub:
|
||||
def __init__(self):
|
||||
self.hide = False
|
||||
|
||||
plus_gz = _GizmoStub()
|
||||
minus_gz = _GizmoStub()
|
||||
label_gz = _GizmoStub()
|
||||
|
||||
fake_self = types.SimpleNamespace(
|
||||
plus_gizmo=plus_gz,
|
||||
minus_gizmo=minus_gz,
|
||||
tread_count_label_gizmo=label_gz,
|
||||
update_gizmo_visibility=lambda g, v: BaseParametricGizmoGroup.update_gizmo_visibility(fake_self, g, v),
|
||||
is_gizmo_hidden_by_modal=lambda g: False,
|
||||
)
|
||||
|
||||
props_editing = types.SimpleNamespace(is_editing=True, number_of_treads=5)
|
||||
GizmoStairEdition.update_tread_count_gizmos(fake_self, props_editing)
|
||||
assert label_gz.hide is False
|
||||
|
||||
props_idle = types.SimpleNamespace(is_editing=False, number_of_treads=5)
|
||||
GizmoStairEdition.update_tread_count_gizmos(fake_self, props_idle)
|
||||
assert label_gz.hide is True
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Behaviour contract: every parametric gizmo group hides while a Blender
|
||||
transform modal (G/R/S and siblings) is dragging ``matrix_world``.
|
||||
|
||||
Discovery walks each parametric-edit module rather than naming gizmo groups —
|
||||
adding a new group automatically joins the test. The test exercises the
|
||||
BEHAVIOUR (poll returns False / draw_prepare early-returns when a transform
|
||||
modal is active) without pinning the name of the helper used internally."""
|
||||
|
||||
import importlib
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
PARAMETRIC_MODULES = (
|
||||
"bonsai.bim.module.model.array",
|
||||
"bonsai.bim.module.model.door",
|
||||
"bonsai.bim.module.model.host_add_opening_gizmo",
|
||||
"bonsai.bim.module.model.roof",
|
||||
"bonsai.bim.module.model.stair",
|
||||
"bonsai.bim.module.model.wall",
|
||||
"bonsai.bim.module.model.window",
|
||||
)
|
||||
|
||||
|
||||
def _discover_parametric_gizmo_groups():
|
||||
"""Walk each parametric-edit module for ``bpy.types.GizmoGroup`` subclasses
|
||||
defined locally. Preview-owning gizmo groups (bl_idname contains 'preview')
|
||||
are excluded from the poll-level test: their poll legitimately fires while
|
||||
the preview is active, and the transform-modal hide for them lives in
|
||||
``draw_prepare`` via ``BillboardingGizmoGroupMixin``."""
|
||||
out = []
|
||||
for mod_path in PARAMETRIC_MODULES:
|
||||
mod = importlib.import_module(mod_path)
|
||||
for name in dir(mod):
|
||||
obj = getattr(mod, name)
|
||||
if not isinstance(obj, type):
|
||||
continue
|
||||
if not issubclass(obj, bpy.types.GizmoGroup) or obj is bpy.types.GizmoGroup:
|
||||
continue
|
||||
if obj.__module__ != mod.__name__:
|
||||
continue
|
||||
bl_idname = (getattr(obj, "bl_idname", "") or "").lower()
|
||||
if "preview" in bl_idname:
|
||||
continue
|
||||
out.append((f"{mod_path.rsplit('.', 1)[-1]}.{name}", obj))
|
||||
return out
|
||||
|
||||
|
||||
class TestDiscoveryFindsParametricGizmoGroups:
|
||||
def test_at_least_one_group_per_canonical_module(self):
|
||||
"""If discovery returns zero groups for a module the walk has drifted —
|
||||
likely the gizmo group moved to a different file. Surface the drift
|
||||
with the module name in the diagnostic."""
|
||||
per_module: dict[str, int] = {}
|
||||
for fq_name, _cls in _discover_parametric_gizmo_groups():
|
||||
mod_short = fq_name.split(".", 1)[0]
|
||||
per_module[mod_short] = per_module.get(mod_short, 0) + 1
|
||||
empty = [m.rsplit(".", 1)[-1] for m in PARAMETRIC_MODULES if per_module.get(m.rsplit(".", 1)[-1], 0) == 0]
|
||||
assert not empty, (
|
||||
f"Parametric modules with zero GizmoGroup subclasses (discovery walk drifted?): {empty}. "
|
||||
"Update PARAMETRIC_MODULES or check whether the gizmo groups moved to a new file."
|
||||
)
|
||||
|
||||
|
||||
class TestParametricGizmoPollsHideDuringTransformModal:
|
||||
"""For each discovered parametric gizmo group, mock the transform-modal
|
||||
detector to True and call ``poll(bpy.context)``. Every poll must return
|
||||
False — any True is a poll that wouldn't hide during a G/R/S drag, leaving
|
||||
the gizmos jittering against the dragging matrix."""
|
||||
|
||||
def test_every_group_poll_returns_false_when_transform_modal_active(self):
|
||||
groups = _discover_parametric_gizmo_groups()
|
||||
offenders = []
|
||||
with patch(
|
||||
"bonsai.bim.module.drawing.gizmos._is_transform_modal_active",
|
||||
return_value=True,
|
||||
):
|
||||
for name, cls in groups:
|
||||
poll = getattr(cls, "poll", None)
|
||||
if poll is None:
|
||||
continue
|
||||
try:
|
||||
result = poll(bpy.context)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
offenders.append((name, f"poll raised: {type(exc).__name__}: {exc}"))
|
||||
continue
|
||||
if result:
|
||||
offenders.append((name, "poll returned True with transform modal active"))
|
||||
|
||||
assert not offenders, (
|
||||
"Parametric gizmo polls that don't gate on the transform-modal detector "
|
||||
"(or raise instead of returning False): "
|
||||
+ ", ".join(f"{n} — {why}" for n, why in offenders)
|
||||
+ ". Hide parametric gizmos while Blender's transform modal is dragging "
|
||||
"matrix_world so they don't jitter off-cursor. The conventional path is to "
|
||||
"early-return from poll when _is_transform_modal_active(context) is True."
|
||||
)
|
||||
|
||||
|
||||
class TestBaseParametricPollHidesDuringTransformModal:
|
||||
"""Cross-feature base poll: door / window / stair / roof / railing / array
|
||||
all inherit ``BaseParametricGizmoGroup``. Its poll must short-circuit on
|
||||
the transform-modal detector so every inheriting feature behaves uniformly."""
|
||||
|
||||
def test_base_parametric_poll_returns_false(self):
|
||||
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
|
||||
|
||||
with patch("bonsai.tool.Blender.get_active_object", return_value=object()):
|
||||
with patch("bonsai.tool.Blender.are_viewport_gizmos_enabled", return_value=True):
|
||||
with patch(
|
||||
"bonsai.bim.module.model.preview_base.any_preview_active",
|
||||
return_value=False,
|
||||
):
|
||||
with patch(
|
||||
"bonsai.bim.module.drawing.gizmos._is_transform_modal_active",
|
||||
return_value=True,
|
||||
):
|
||||
assert BaseParametricGizmoGroup.poll(bpy.context) is False
|
||||
|
||||
|
||||
class TestBaseIconActionPollHidesDuringTransformModal:
|
||||
"""``BaseIconActionGroup`` is the parent of the simple icon-row gizmo
|
||||
groups; its poll mirrors the base parametric gate for forward-compat
|
||||
symmetry. Pinning here ensures a future icon-row group authored via this
|
||||
base inherits the transform-modal hide for free."""
|
||||
|
||||
def test_base_icon_action_poll_returns_false(self):
|
||||
from bonsai.bim.module.drawing.gizmos import BaseIconActionGroup
|
||||
|
||||
with patch("bonsai.tool.Blender.get_active_object", return_value=object()):
|
||||
with patch("bonsai.tool.Blender.are_viewport_gizmos_enabled", return_value=True):
|
||||
with patch(
|
||||
"bonsai.bim.module.drawing.gizmos._is_transform_modal_active",
|
||||
return_value=True,
|
||||
):
|
||||
assert BaseIconActionGroup.poll(bpy.context) is False
|
||||
|
||||
|
||||
class TestHelperReadsWindowModalOperators:
|
||||
"""Pin the public contract of ``_is_transform_modal_active``: it reads
|
||||
``context.window.modal_operators`` (Blender 4.2+) and returns True iff any
|
||||
operator's ``bl_idname`` starts with ``TRANSFORM_OT_``. The check itself
|
||||
is dependency-free and worth pinning so a future refactor that swaps the
|
||||
detection mechanism either keeps the contract or updates the test."""
|
||||
|
||||
def test_returns_true_for_transform_translate(self):
|
||||
from bonsai.bim.module.drawing.gizmos import _is_transform_modal_active
|
||||
|
||||
fake_op = MagicMock()
|
||||
fake_op.bl_idname = "TRANSFORM_OT_translate"
|
||||
fake_context = MagicMock()
|
||||
fake_context.window.modal_operators = [fake_op]
|
||||
assert _is_transform_modal_active(fake_context) is True
|
||||
|
||||
def test_returns_true_for_transform_rotate_and_resize(self):
|
||||
from bonsai.bim.module.drawing.gizmos import _is_transform_modal_active
|
||||
|
||||
for idname in ("TRANSFORM_OT_rotate", "TRANSFORM_OT_resize", "TRANSFORM_OT_shear"):
|
||||
fake_op = MagicMock()
|
||||
fake_op.bl_idname = idname
|
||||
fake_context = MagicMock()
|
||||
fake_context.window.modal_operators = [fake_op]
|
||||
assert _is_transform_modal_active(fake_context) is True, f"missed {idname}"
|
||||
|
||||
def test_returns_false_for_non_transform_modal(self):
|
||||
from bonsai.bim.module.drawing.gizmos import _is_transform_modal_active
|
||||
|
||||
fake_op = MagicMock()
|
||||
fake_op.bl_idname = "VIEW3D_OT_select_box"
|
||||
fake_context = MagicMock()
|
||||
fake_context.window.modal_operators = [fake_op]
|
||||
assert _is_transform_modal_active(fake_context) is False
|
||||
|
||||
def test_returns_true_for_bonsai_move_macro(self):
|
||||
"""Bonsai overrides the G key with a macro that wraps
|
||||
``TRANSFORM_OT_translate``. While the macro is the outer modal entry
|
||||
the inner transform does not surface in ``modal_operators``; matching
|
||||
the macro idname covers the gap. Note Blender exposes ``bl_idname``
|
||||
at runtime in the ``BIM_OT_<verb_noun>`` form, not the ``bim.<verb_noun>``
|
||||
form used in the class declaration — verified via real-Blender modal
|
||||
introspection during grab."""
|
||||
from bonsai.bim.module.drawing.gizmos import _is_transform_modal_active
|
||||
|
||||
macros = (
|
||||
"BIM_OT_override_move_macro",
|
||||
"BIM_OT_override_object_duplicate_move_macro",
|
||||
"BIM_OT_override_object_duplicate_move_linked_macro",
|
||||
"BIM_OT_object_duplicate_move_linked_aggregate_macro",
|
||||
)
|
||||
for idname in macros:
|
||||
fake_op = MagicMock()
|
||||
fake_op.bl_idname = idname
|
||||
fake_context = MagicMock()
|
||||
fake_context.window.modal_operators = [fake_op]
|
||||
assert _is_transform_modal_active(fake_context) is True, f"missed {idname}"
|
||||
|
||||
def test_returns_false_for_empty_modal_stack(self):
|
||||
from bonsai.bim.module.drawing.gizmos import _is_transform_modal_active
|
||||
|
||||
fake_context = MagicMock()
|
||||
fake_context.window.modal_operators = []
|
||||
assert _is_transform_modal_active(fake_context) is False
|
||||
|
||||
def test_returns_false_when_window_is_none(self):
|
||||
from bonsai.bim.module.drawing.gizmos import _is_transform_modal_active
|
||||
|
||||
fake_context = MagicMock()
|
||||
fake_context.window = None
|
||||
assert _is_transform_modal_active(fake_context) is False
|
||||
@@ -1,133 +0,0 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Forward-compat AST guard: every multi-object wall topology GizmoGroup
|
||||
filters Bonsai array children via ``_wall_topology_gizmo_poll_gate`` or
|
||||
the central ``any_selected_is_array_child`` predicate.
|
||||
|
||||
Allow-list (gizmos intentionally outside the rule):
|
||||
|
||||
- ``GizmoWallEdition`` — single-object parametric edit gizmo. Its base
|
||||
parametric poll already filters array children.
|
||||
- ``GizmoWallFilletPreview`` — the preview-owner whose poll must fire
|
||||
WHILE its own preview is active; routing it through the topology gate
|
||||
would self-block it.
|
||||
|
||||
Host-opening gizmos live in a sibling module and intentionally use the
|
||||
loose base ``_wall_gizmo_poll_gate``: openings track with the child
|
||||
through ``regenerate_array`` and stay authorable on children.
|
||||
|
||||
A new wall ``GizmoGroup`` added without the filter (and not added to the
|
||||
allow-list with an explanation) fails this test."""
|
||||
|
||||
import ast
|
||||
import inspect
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
# Wall gizmo groups intentionally outside the rule. Add a new entry only
|
||||
# with the in-code reasoning above.
|
||||
_ALLOWLIST = frozenset({"GizmoWallEdition", "GizmoWallFilletPreview"})
|
||||
|
||||
_REQUIRED_CALLEES = frozenset({"_wall_topology_gizmo_poll_gate", "any_selected_is_array_child"})
|
||||
|
||||
|
||||
def _wall_module_source():
|
||||
from bonsai.bim.module.model import wall as wall_mod
|
||||
|
||||
return inspect.getsource(wall_mod), wall_mod.__name__
|
||||
|
||||
|
||||
def _wall_gizmo_group_classes():
|
||||
"""All ``bpy.types.GizmoGroup`` subclasses defined locally in wall.py."""
|
||||
from bonsai.bim.module.model import wall as wall_mod
|
||||
|
||||
out = []
|
||||
for name in dir(wall_mod):
|
||||
obj = getattr(wall_mod, name)
|
||||
if not isinstance(obj, type):
|
||||
continue
|
||||
if not issubclass(obj, bpy.types.GizmoGroup) or obj is bpy.types.GizmoGroup:
|
||||
continue
|
||||
if obj.__module__ != wall_mod.__name__:
|
||||
continue
|
||||
out.append((name, obj))
|
||||
return out
|
||||
|
||||
|
||||
def _poll_function_calls(class_node):
|
||||
"""Names of every function called inside ``class_node``'s ``poll`` body.
|
||||
|
||||
``ast.Call.func`` may be an ``ast.Name`` (bare call) or an ``ast.Attribute``
|
||||
(dotted call). For the dotted case the leaf attribute is returned so
|
||||
``tool.Blender.Modifier.any_selected_is_array_child(...)`` registers as
|
||||
``any_selected_is_array_child``."""
|
||||
poll_node = next(
|
||||
(node for node in class_node.body if isinstance(node, ast.FunctionDef) and node.name == "poll"),
|
||||
None,
|
||||
)
|
||||
if poll_node is None:
|
||||
return None
|
||||
names = set()
|
||||
for sub in ast.walk(poll_node):
|
||||
if not isinstance(sub, ast.Call):
|
||||
continue
|
||||
func = sub.func
|
||||
if isinstance(func, ast.Name):
|
||||
names.add(func.id)
|
||||
elif isinstance(func, ast.Attribute):
|
||||
names.add(func.attr)
|
||||
return names
|
||||
|
||||
|
||||
def test_every_wall_gizmo_group_filters_array_children_or_is_allowlisted():
|
||||
"""For every locally-defined wall ``GizmoGroup`` not in the allow-list,
|
||||
its ``poll`` must call ``_wall_gizmo_poll_gate`` or the central
|
||||
``any_selected_is_array_child`` predicate. A failure surfaces the list
|
||||
of offending classes — the fix is a single early-return through the
|
||||
central helper, mirroring the existing peers."""
|
||||
source, _module_name = _wall_module_source()
|
||||
tree = ast.parse(source)
|
||||
class_nodes = {node.name: node for node in ast.walk(tree) if isinstance(node, ast.ClassDef)}
|
||||
offenders = []
|
||||
for class_name, _cls in _wall_gizmo_group_classes():
|
||||
if class_name in _ALLOWLIST:
|
||||
continue
|
||||
node = class_nodes.get(class_name)
|
||||
if node is None:
|
||||
offenders.append((class_name, "AST parse did not find the class"))
|
||||
continue
|
||||
calls = _poll_function_calls(node)
|
||||
if calls is None:
|
||||
offenders.append((class_name, "no poll() defined; expected the array-child filter call"))
|
||||
continue
|
||||
if not (calls & _REQUIRED_CALLEES):
|
||||
offenders.append((class_name, f"poll() does not call any of {sorted(_REQUIRED_CALLEES)}"))
|
||||
|
||||
assert not offenders, (
|
||||
"Wall GizmoGroup classes missing the array-child filter: "
|
||||
+ ", ".join(f"{n} — {why}" for n, why in offenders)
|
||||
+ ". Route the poll through `_wall_topology_gizmo_poll_gate(context)` "
|
||||
"so the central `any_selected_is_array_child` filter applies, or add "
|
||||
"the class to the file's allow-list with a documented reason."
|
||||
)
|
||||
@@ -1,147 +0,0 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Behaviour contract: every wall gizmo group hides while a parametric-edit
|
||||
preview is active.
|
||||
|
||||
Enumerates wall gizmo groups by walking the wall module for ``bpy.types.GizmoGroup``
|
||||
subclasses rather than naming them — adding a new wall gizmo group automatically
|
||||
joins the test. The test then asserts the BEHAVIOUR (poll returns False when
|
||||
``preview_base.any_preview_active`` is True) without pinning the name of the
|
||||
helper function the gizmo uses internally to enforce it."""
|
||||
|
||||
import inspect
|
||||
from unittest.mock import patch
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
def _wall_gizmo_groups():
|
||||
"""Walk the wall module for ``bpy.types.GizmoGroup`` subclasses defined
|
||||
locally (skip imported references). Returns a list of (name, cls) tuples.
|
||||
|
||||
A gizmo group whose ``poll`` legitimately needs to fire WHILE a preview
|
||||
is active — i.e. it IS the preview's own gizmo group — is excluded by
|
||||
convention: classes whose bl_idname references the preview surface
|
||||
(``preview`` in the idname) are the preview-owner exception."""
|
||||
from bonsai.bim.module.model import wall as wall_mod
|
||||
|
||||
out = []
|
||||
for name in dir(wall_mod):
|
||||
obj = getattr(wall_mod, name)
|
||||
if not isinstance(obj, type):
|
||||
continue
|
||||
if not issubclass(obj, bpy.types.GizmoGroup) or obj is bpy.types.GizmoGroup:
|
||||
continue
|
||||
# Local definitions only — skip re-exports / aliases.
|
||||
if obj.__module__ != wall_mod.__name__:
|
||||
continue
|
||||
# Preview-owner exception: the gizmo group that drives a preview
|
||||
# itself must remain visible while its preview is active, so a
|
||||
# "no preview active" gate would self-block it. The bl_idname
|
||||
# contains the substring 'preview' for these groups by Bonsai
|
||||
# convention (e.g. OBJECT_GGT_bim_wall_fillet_preview).
|
||||
bl_idname = getattr(obj, "bl_idname", "") or ""
|
||||
if "preview" in bl_idname.lower():
|
||||
continue
|
||||
out.append((name, obj))
|
||||
return out
|
||||
|
||||
|
||||
class TestWallGizmoGroupsHideDuringPreview:
|
||||
"""Behaviour contract: a parametric-edit preview is the only interactive
|
||||
surface in the viewport, so every sister wall gizmo must self-hide via
|
||||
its poll. The test exercises this BEHAVIOUR — when ``any_preview_active``
|
||||
reports True, every wall gizmo's poll returns False — without pinning
|
||||
the helper function name each poll uses internally."""
|
||||
|
||||
def test_discovery_finds_wall_gizmo_groups(self):
|
||||
"""Sanity check: at least one wall gizmo group is found. If this fails,
|
||||
the discovery walk drifted out of sync with the module structure (e.g.
|
||||
wall gizmo groups got moved to a separate file)."""
|
||||
groups = _wall_gizmo_groups()
|
||||
assert groups, "Expected at least one wall GizmoGroup subclass in wall.py — discovery walk broke?"
|
||||
|
||||
def test_every_wall_gizmo_hides_when_a_preview_is_active(self):
|
||||
"""For each discovered wall gizmo group, mock ``any_preview_active`` to
|
||||
True and call ``poll(bpy.context)``. Every poll must return False —
|
||||
any True is a poll that wouldn't hide during a fillet/bend preview,
|
||||
leaving the user with two competing icon stacks on the same selection."""
|
||||
groups = _wall_gizmo_groups()
|
||||
offenders = []
|
||||
with patch("bonsai.bim.module.model.preview_base.any_preview_active", return_value=True):
|
||||
for name, cls in groups:
|
||||
poll = getattr(cls, "poll", None)
|
||||
if poll is None:
|
||||
# Inherits poll from a mixin / base — the base poll's gating
|
||||
# is covered separately. Skip rather than crash.
|
||||
continue
|
||||
try:
|
||||
result = poll(bpy.context)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
offenders.append((name, f"poll raised: {type(exc).__name__}: {exc}"))
|
||||
continue
|
||||
if result:
|
||||
offenders.append((name, "poll returned True with preview active"))
|
||||
|
||||
assert not offenders, (
|
||||
"Wall gizmo polls that don't gate on any_preview_active "
|
||||
"(or raise instead of returning False): "
|
||||
+ ", ".join(f"{n} — {why}" for n, why in offenders)
|
||||
+ ". Hide sister gizmos during previews so the preview is the only "
|
||||
"interactive surface in the viewport. The conventional path is to "
|
||||
"early-return from poll when preview_base.any_preview_active(context) "
|
||||
"is True."
|
||||
)
|
||||
|
||||
|
||||
class TestBaseParametricGizmoPollHidesDuringPreview:
|
||||
"""Mirror of the wall-specific test for the cross-feature parametric
|
||||
framework: door / window / stair / roof / railing / array all inherit
|
||||
``BaseParametricGizmoGroup``. Its poll must also short-circuit on
|
||||
``any_preview_active`` so sister features behave consistently with walls."""
|
||||
|
||||
def test_base_parametric_poll_returns_false_when_a_preview_is_active(self):
|
||||
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
|
||||
|
||||
# The base poll requires an active selected object before checking the
|
||||
# preview gate. Mock both the selected-object check (return a sentinel)
|
||||
# AND the gate so the test exercises ONLY the preview short-circuit.
|
||||
with patch("bonsai.tool.Blender.get_active_object", return_value=object()):
|
||||
with patch("bonsai.tool.Blender.are_viewport_gizmos_enabled", return_value=True):
|
||||
with patch(
|
||||
"bonsai.bim.module.model.preview_base.any_preview_active",
|
||||
return_value=True,
|
||||
):
|
||||
assert BaseParametricGizmoGroup.poll(bpy.context) is False
|
||||
|
||||
|
||||
class TestModulePathIsFindable:
|
||||
"""If wall.py is split across multiple modules (e.g. wall_gizmos.py),
|
||||
update ``_wall_gizmo_groups`` to walk each. This sanity check fails first
|
||||
so the diagnostic message is obvious."""
|
||||
|
||||
def test_wall_module_resolves(self):
|
||||
from bonsai.bim.module.model import wall as wall_mod
|
||||
|
||||
assert inspect.ismodule(wall_mod)
|
||||
@@ -25,6 +25,7 @@ logic can be exercised without a real IFC fixture. Each test pins one of the
|
||||
gates ``poll()`` walks, so any silent regression in the gate order or in the
|
||||
LAYER3-active / LAYER2-other contract is caught by a dedicated assertion."""
|
||||
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -34,15 +35,10 @@ import pytest
|
||||
pytestmark = pytest.mark.wall
|
||||
|
||||
|
||||
class _Obj:
|
||||
"""Hashable, name-bearing stand-in for a ``bpy.types.Object`` selection
|
||||
slot. ``SimpleNamespace`` defines ``__eq__`` (and so ``__hash__ = None``)
|
||||
which makes it unusable inside the ``set()`` that
|
||||
``get_selected_objects()`` returns; a plain class falls back to
|
||||
identity-based hashing and works inside both ``set`` and ``list``."""
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
self.name = name
|
||||
@pytest.fixture(autouse=True)
|
||||
def _require_real_bpy():
|
||||
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
|
||||
pytest.skip("requires real Blender (bpy is mocked or absent)")
|
||||
|
||||
|
||||
def _make_context(active, selected):
|
||||
@@ -80,23 +76,19 @@ def _patch_tools(prefs_on, selected, active_element, other_element, active_usage
|
||||
patch.object(tool.Blender, "get_selected_objects", return_value=set(selected)),
|
||||
patch.object(tool.Ifc, "get_entity", side_effect=get_entity),
|
||||
patch.object(tool.Model, "get_usage_type", side_effect=get_usage_type),
|
||||
# The array-child filter is pinned by its own test file; stub it here
|
||||
# so these poll tests stay focused on the count / layer-usage gates
|
||||
# and don't have to scaffold the memoization cache key.
|
||||
patch.object(tool.Blender.Modifier, "any_selected_is_array_child", return_value=False),
|
||||
]
|
||||
|
||||
|
||||
def _run_poll(prefs_on, active_is_in_selected, len_override, active_usage, other_usage, active_has_entity=True):
|
||||
from bonsai.bim.module.model.wall import GizmoWallExtendVertically
|
||||
|
||||
slab_obj = _Obj("slab")
|
||||
wall_obj = _Obj("wall")
|
||||
active = slab_obj if active_is_in_selected else _Obj("active_extra")
|
||||
slab_obj = object()
|
||||
wall_obj = object()
|
||||
active = slab_obj if active_is_in_selected else object()
|
||||
if len_override is None:
|
||||
selected = [slab_obj, wall_obj]
|
||||
else:
|
||||
selected = [_Obj(f"obj_{i}") for i in range(len_override)]
|
||||
selected = [object() for _ in range(len_override)]
|
||||
if active_is_in_selected and selected:
|
||||
active = selected[0]
|
||||
|
||||
@@ -187,215 +179,3 @@ def test_poll_rejects_when_other_is_not_layer2_wall():
|
||||
_run_poll(prefs_on=True, active_is_in_selected=True, len_override=None, active_usage="LAYER3", other_usage=None)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# _iter_path_connections — IfcRelConnectsPathElements inverse-graph walk
|
||||
# ----------------------------------------------------------------------------
|
||||
#
|
||||
# Normalises both ConnectedTo and ConnectedFrom orientations to (other, self_ct,
|
||||
# other_ct) so callers always read "self first" regardless of which side of the
|
||||
# rel this wall was authored on. Non-wall partners and malformed (None) refs are
|
||||
# filtered out so per-frame gizmo positioning survives partial IFC state.
|
||||
|
||||
|
||||
def _make_path_rel(relating, related, relating_ct, related_ct, kind="IfcRelConnectsPathElements"):
|
||||
"""Build a stub IfcRelConnectsPathElements for inverse-walk tests."""
|
||||
return SimpleNamespace(
|
||||
is_a=lambda name, _k=kind: name == _k,
|
||||
RelatingElement=relating,
|
||||
RelatedElement=related,
|
||||
RelatingConnectionType=relating_ct,
|
||||
RelatedConnectionType=related_ct,
|
||||
)
|
||||
|
||||
|
||||
def _run_iter_path_connections(elem, *, partner_predicate=lambda _e: True):
|
||||
from bonsai import tool
|
||||
from bonsai.bim.module.model.wall import _iter_path_connections
|
||||
|
||||
with patch.object(tool.Parametric, "is_path_connectable_wall", side_effect=partner_predicate):
|
||||
return _iter_path_connections(elem)
|
||||
|
||||
|
||||
def test_iter_path_connections_empty_inverses_yields_nothing():
|
||||
elem = SimpleNamespace(ConnectedTo=[], ConnectedFrom=[])
|
||||
assert _run_iter_path_connections(elem) == []
|
||||
|
||||
|
||||
def test_iter_path_connections_connected_to_orientation_is_self_first():
|
||||
# Self is the rel's RelatingElement → its connection type is RelatingConnectionType.
|
||||
self_elem = object()
|
||||
other = object()
|
||||
rel = _make_path_rel(relating=self_elem, related=other, relating_ct="ATEND", related_ct="ATSTART")
|
||||
elem = SimpleNamespace(ConnectedTo=[rel], ConnectedFrom=[])
|
||||
assert _run_iter_path_connections(elem) == [(other, "ATEND", "ATSTART")]
|
||||
|
||||
|
||||
def test_iter_path_connections_connected_from_orientation_is_self_first():
|
||||
# Self is the rel's RelatedElement → its connection type is RelatedConnectionType.
|
||||
# The helper must FLIP the tuple so callers still see (other, self_ct, other_ct).
|
||||
self_elem = object()
|
||||
other = object()
|
||||
rel = _make_path_rel(relating=other, related=self_elem, relating_ct="ATSTART", related_ct="ATEND")
|
||||
elem = SimpleNamespace(ConnectedTo=[], ConnectedFrom=[rel])
|
||||
assert _run_iter_path_connections(elem) == [(other, "ATEND", "ATSTART")]
|
||||
|
||||
|
||||
def test_iter_path_connections_skips_non_path_rels():
|
||||
# IfcRelAggregates, IfcRelContainedInSpatialStructure, etc. share the
|
||||
# ConnectedTo/ConnectedFrom inverse arrays — only IfcRelConnectsPathElements
|
||||
# carries the per-end connection-type semantics we care about.
|
||||
self_elem = object()
|
||||
other = object()
|
||||
non_path = _make_path_rel(
|
||||
relating=self_elem, related=other, relating_ct="ATSTART", related_ct="ATEND", kind="IfcRelAggregates"
|
||||
)
|
||||
path = _make_path_rel(relating=self_elem, related=other, relating_ct="ATEND", related_ct="ATSTART")
|
||||
elem = SimpleNamespace(ConnectedTo=[non_path, path], ConnectedFrom=[])
|
||||
assert _run_iter_path_connections(elem) == [(other, "ATEND", "ATSTART")]
|
||||
|
||||
|
||||
def test_iter_path_connections_skips_non_wall_partners():
|
||||
# Walls may path-connect to non-wall elements (columns, beams). The single-
|
||||
# wall unjoin gizmo only surfaces wall-to-wall joins to match the existing
|
||||
# two-wall gizmo's scope.
|
||||
self_elem = object()
|
||||
wall_partner = object()
|
||||
non_wall_partner = object()
|
||||
rel_wall = _make_path_rel(relating=self_elem, related=wall_partner, relating_ct="ATEND", related_ct="ATSTART")
|
||||
rel_non_wall = _make_path_rel(
|
||||
relating=self_elem, related=non_wall_partner, relating_ct="ATEND", related_ct="ATSTART"
|
||||
)
|
||||
elem = SimpleNamespace(ConnectedTo=[rel_wall, rel_non_wall], ConnectedFrom=[])
|
||||
result = _run_iter_path_connections(elem, partner_predicate=lambda e: e is wall_partner)
|
||||
assert result == [(wall_partner, "ATEND", "ATSTART")]
|
||||
|
||||
|
||||
def test_iter_path_connections_includes_fillet_corner_partner():
|
||||
# Fillet-corner walls carry no LAYER2 usage but are still valid path
|
||||
# partners. The enumeration must use the same predicate the gizmo group's
|
||||
# poll uses for the host wall — otherwise the corner is silently dropped
|
||||
# from the neighbour's connection list and looks unconnected from the
|
||||
# LAYER2 wall's perspective.
|
||||
self_elem = object()
|
||||
fillet_partner = object()
|
||||
rel = _make_path_rel(relating=self_elem, related=fillet_partner, relating_ct="ATEND", related_ct="ATSTART")
|
||||
elem = SimpleNamespace(ConnectedTo=[rel], ConnectedFrom=[])
|
||||
result = _run_iter_path_connections(elem, partner_predicate=lambda e: e is fillet_partner)
|
||||
assert result == [(fillet_partner, "ATEND", "ATSTART")]
|
||||
|
||||
|
||||
def test_iter_path_connections_tolerates_none_partner_refs():
|
||||
# Malformed / partial IFC files can leave a rel's element ref unset.
|
||||
# Without a None guard, the partner predicate would receive None and
|
||||
# raise on `.is_a(...)` mid-frame, silently breaking the gizmo group.
|
||||
self_elem = object()
|
||||
other = object()
|
||||
rel_none = _make_path_rel(relating=self_elem, related=None, relating_ct="ATEND", related_ct="ATSTART")
|
||||
rel_ok = _make_path_rel(relating=self_elem, related=other, relating_ct="ATSTART", related_ct="ATEND")
|
||||
elem = SimpleNamespace(ConnectedTo=[rel_none, rel_ok], ConnectedFrom=[])
|
||||
assert _run_iter_path_connections(elem) == [(other, "ATSTART", "ATEND")]
|
||||
|
||||
|
||||
def test_iter_path_connections_walks_both_inverses_in_order():
|
||||
# A wall can sit on both sides of different path rels (e.g. authored once
|
||||
# as the RelatingElement, once as the RelatedElement). The helper walks
|
||||
# ConnectedTo first, then ConnectedFrom — pinning the order so callers can
|
||||
# depend on it for icon-slot allocation.
|
||||
self_elem = object()
|
||||
p1 = object()
|
||||
p2 = object()
|
||||
rel_to = _make_path_rel(relating=self_elem, related=p1, relating_ct="ATSTART", related_ct="ATSTART")
|
||||
rel_from = _make_path_rel(relating=p2, related=self_elem, relating_ct="ATEND", related_ct="ATEND")
|
||||
elem = SimpleNamespace(ConnectedTo=[rel_to], ConnectedFrom=[rel_from])
|
||||
assert _run_iter_path_connections(elem) == [(p1, "ATSTART", "ATSTART"), (p2, "ATEND", "ATEND")]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# _perpendicular_wall_params — clamping + side detection for the
|
||||
# "add perpendicular wall at cursor" gizmo and its operator.
|
||||
# ----------------------------------------------------------------------------
|
||||
#
|
||||
# Pure scalar math. The dead-zone is ``CURSOR_STACK_OFFSET`` — inside it the
|
||||
# on-axis split / extend-X icons own the click and this helper returns None.
|
||||
|
||||
|
||||
def _wall_consts():
|
||||
from bonsai.bim.module.model.wall import GizmoWallEdition
|
||||
|
||||
return GizmoWallEdition.CURSOR_STACK_OFFSET
|
||||
|
||||
|
||||
def _run_perp_params(cursor_x, cursor_y, anchor_x=0.0, length=5.0):
|
||||
from bonsai.bim.module.model.wall import _perpendicular_wall_params
|
||||
|
||||
return _perpendicular_wall_params(cursor_x, cursor_y, anchor_x, length)
|
||||
|
||||
|
||||
def test_perpendicular_params_on_axis_returns_none():
|
||||
assert _run_perp_params(cursor_x=2.0, cursor_y=0.0) is None
|
||||
|
||||
|
||||
def test_perpendicular_params_at_dead_zone_boundary_returns_none():
|
||||
# Inclusive boundary: at exactly the threshold the on-axis icons still own
|
||||
# the click; the gizmo only takes over strictly past the dead zone.
|
||||
threshold = _wall_consts()
|
||||
assert _run_perp_params(cursor_x=2.0, cursor_y=threshold) is None
|
||||
assert _run_perp_params(cursor_x=2.0, cursor_y=-threshold) is None
|
||||
|
||||
|
||||
def test_perpendicular_params_just_past_dead_zone_returns_params():
|
||||
threshold = _wall_consts()
|
||||
result = _run_perp_params(cursor_x=2.0, cursor_y=threshold + 0.01)
|
||||
assert result is not None
|
||||
clamped_x, length, side = result
|
||||
assert clamped_x == pytest.approx(2.0)
|
||||
assert length == pytest.approx(threshold + 0.01)
|
||||
assert side == 1.0
|
||||
|
||||
|
||||
def test_perpendicular_params_negative_y_flips_side_sign():
|
||||
result = _run_perp_params(cursor_x=2.0, cursor_y=-1.5)
|
||||
assert result is not None
|
||||
_, length, side = result
|
||||
# Length is always positive — the side sign carries the direction so the
|
||||
# operator can pick the +90° vs -90° rotation without sign-flipping length.
|
||||
assert length == pytest.approx(1.5)
|
||||
assert side == -1.0
|
||||
|
||||
|
||||
def test_perpendicular_params_clamps_low_when_cursor_left_of_wall():
|
||||
result = _run_perp_params(cursor_x=-2.0, cursor_y=1.5, anchor_x=0.0, length=5.0)
|
||||
assert result is not None
|
||||
clamped_x, _length, _side = result
|
||||
assert clamped_x == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_perpendicular_params_clamps_high_when_cursor_right_of_wall():
|
||||
result = _run_perp_params(cursor_x=10.0, cursor_y=1.5, anchor_x=0.0, length=5.0)
|
||||
assert result is not None
|
||||
clamped_x, _length, _side = result
|
||||
assert clamped_x == pytest.approx(5.0)
|
||||
|
||||
|
||||
def test_perpendicular_params_respects_nonzero_anchor_x():
|
||||
# Non-zero anchor_x shifts the wall span; clamping must follow.
|
||||
result = _run_perp_params(cursor_x=0.5, cursor_y=1.5, anchor_x=2.0, length=5.0)
|
||||
assert result is not None
|
||||
clamped_x, _length, _side = result
|
||||
assert clamped_x == pytest.approx(2.0)
|
||||
|
||||
result = _run_perp_params(cursor_x=10.0, cursor_y=1.5, anchor_x=2.0, length=5.0)
|
||||
assert result is not None
|
||||
clamped_x, _length, _side = result
|
||||
assert clamped_x == pytest.approx(7.0)
|
||||
|
||||
|
||||
def test_perpendicular_params_in_range_passes_cursor_x_through():
|
||||
result = _run_perp_params(cursor_x=3.0, cursor_y=1.5, anchor_x=0.0, length=5.0)
|
||||
assert result is not None
|
||||
clamped_x, length, side = result
|
||||
assert clamped_x == pytest.approx(3.0)
|
||||
assert length == pytest.approx(1.5)
|
||||
assert side == 1.0
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Behaviour contract: wall topology gizmos and operators reject any
|
||||
selection that contains a Bonsai array child. Discovers gated gizmo
|
||||
groups and guarded operators by source inspection so additions inherit
|
||||
the rule automatically."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
def _wall_gizmo_groups_using_gate():
|
||||
"""Wall-module ``bpy.types.GizmoGroup`` subclasses whose ``poll`` calls
|
||||
``_wall_topology_gizmo_poll_gate``. Discovered by source inspection so
|
||||
the test tracks the gate's user set as the module grows."""
|
||||
import inspect
|
||||
|
||||
from bonsai.bim.module.model import wall as wall_mod
|
||||
|
||||
out = []
|
||||
for name in dir(wall_mod):
|
||||
obj = getattr(wall_mod, name)
|
||||
if not isinstance(obj, type):
|
||||
continue
|
||||
if not issubclass(obj, bpy.types.GizmoGroup) or obj is bpy.types.GizmoGroup:
|
||||
continue
|
||||
if obj.__module__ != wall_mod.__name__:
|
||||
continue
|
||||
poll = obj.__dict__.get("poll")
|
||||
if poll is None:
|
||||
continue
|
||||
try:
|
||||
src = inspect.getsource(poll)
|
||||
except (OSError, TypeError):
|
||||
continue
|
||||
if "_wall_topology_gizmo_poll_gate" not in src:
|
||||
continue
|
||||
out.append((name, obj))
|
||||
return out
|
||||
|
||||
|
||||
def _wall_operators_with_array_child_guard():
|
||||
"""Wall-module ``bpy.types.Operator`` subclasses whose ``poll`` rejects
|
||||
array-child selections, either by referencing the central predicate
|
||||
directly or by routing through the shared ``_poll_reject_array_children``
|
||||
helper that wraps it. The operator-level guard is defence in depth
|
||||
against keymap / F3 paths that bypass the gizmo entirely."""
|
||||
import inspect
|
||||
|
||||
from bonsai.bim.module.model import wall as wall_mod
|
||||
|
||||
out = []
|
||||
for name in dir(wall_mod):
|
||||
obj = getattr(wall_mod, name)
|
||||
if not isinstance(obj, type):
|
||||
continue
|
||||
if not issubclass(obj, bpy.types.Operator) or obj is bpy.types.Operator:
|
||||
continue
|
||||
if obj.__module__ != wall_mod.__name__:
|
||||
continue
|
||||
poll = obj.__dict__.get("poll")
|
||||
if poll is None:
|
||||
continue
|
||||
try:
|
||||
src = inspect.getsource(poll)
|
||||
except (OSError, TypeError):
|
||||
continue
|
||||
if "any_selected_is_array_child" not in src and "_poll_reject_array_children" not in src:
|
||||
continue
|
||||
out.append((name, obj))
|
||||
return out
|
||||
|
||||
|
||||
class TestWallGizmoGroupsHideOnArrayChildSelection:
|
||||
def test_discovery_finds_wall_multi_object_gizmo_groups(self):
|
||||
groups = _wall_gizmo_groups_using_gate()
|
||||
assert groups, (
|
||||
"Expected at least one wall GizmoGroup whose poll calls "
|
||||
"_wall_gizmo_poll_gate — discovery walk drifted out of sync?"
|
||||
)
|
||||
|
||||
def test_every_gated_wall_gizmo_hides_when_any_selection_is_array_child(self):
|
||||
"""Mocks the central ``any_selected_is_array_child`` predicate to True
|
||||
and asserts every gizmo whose poll routes through
|
||||
``_wall_gizmo_poll_gate`` returns False. The point is the BEHAVIOUR:
|
||||
a child wall in the selection must never surface a topology gizmo,
|
||||
regardless of which gate function the poll calls internally."""
|
||||
groups = _wall_gizmo_groups_using_gate()
|
||||
offenders = []
|
||||
with patch("bonsai.tool.Blender.are_viewport_gizmos_enabled", return_value=True):
|
||||
with patch("bonsai.bim.module.model.preview_base.any_preview_active", return_value=False):
|
||||
with patch(
|
||||
"bonsai.tool.Blender.Modifier.any_selected_is_array_child",
|
||||
return_value=True,
|
||||
):
|
||||
for name, cls in groups:
|
||||
try:
|
||||
result = cls.poll(bpy.context)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
offenders.append((name, f"poll raised: {type(exc).__name__}: {exc}"))
|
||||
continue
|
||||
if result:
|
||||
offenders.append((name, "poll returned True with array child selected"))
|
||||
|
||||
assert not offenders, (
|
||||
"Wall gizmo polls that surface on array-child selections: "
|
||||
+ ", ".join(f"{n} — {why}" for n, why in offenders)
|
||||
+ ". Route the poll through _wall_topology_gizmo_poll_gate so the "
|
||||
"central any_selected_is_array_child filter applies."
|
||||
)
|
||||
|
||||
|
||||
class TestWallOperatorsRejectArrayChildSelection:
|
||||
def test_discovery_finds_wall_topology_operators(self):
|
||||
ops = _wall_operators_with_array_child_guard()
|
||||
assert ops, (
|
||||
"Expected at least one wall Operator whose poll rejects array-child "
|
||||
"selections (via any_selected_is_array_child or _poll_reject_array_children) "
|
||||
"— discovery walk drifted out of sync?"
|
||||
)
|
||||
|
||||
def test_every_guarded_wall_operator_polls_false_on_array_child_selection(self):
|
||||
"""Operators reachable from keymaps / F3 must reject array-child
|
||||
invocation independently of the gizmo gating, because not every
|
||||
invocation path goes through a gizmo. The shared predicate makes
|
||||
this a one-line guard per operator; this test pins it for every
|
||||
operator that opted in."""
|
||||
ops = _wall_operators_with_array_child_guard()
|
||||
offenders = []
|
||||
with patch(
|
||||
"bonsai.tool.Blender.Modifier.any_selected_is_array_child",
|
||||
return_value=True,
|
||||
):
|
||||
with patch("bonsai.tool.Model.has_selected_ifc_objects", return_value=True):
|
||||
with patch("bonsai.tool.Model.get_selected_ifc_objects", return_value=[]):
|
||||
for name, cls in ops:
|
||||
try:
|
||||
result = cls.poll(bpy.context)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
offenders.append((name, f"poll raised: {type(exc).__name__}: {exc}"))
|
||||
continue
|
||||
if result:
|
||||
offenders.append((name, "poll returned True with array child selected"))
|
||||
|
||||
assert not offenders, (
|
||||
"Wall topology operators that accept array-child selections: "
|
||||
+ ", ".join(f"{n} — {why}" for n, why in offenders)
|
||||
+ ". Route the poll through `_poll_reject_array_children(cls)` (the "
|
||||
"shared helper that sets the standard poll message and reuses the "
|
||||
"central `any_selected_is_array_child` predicate)."
|
||||
)
|
||||
|
||||
|
||||
class TestAnySelectedIsArrayChildHelper:
|
||||
"""Smoke checks on the central predicate. Returns ``False`` when nothing
|
||||
is selected; returns ``True`` when at least one selected element passes
|
||||
``is_array_child``."""
|
||||
|
||||
def test_returns_false_with_empty_selection(self):
|
||||
from bonsai import tool
|
||||
|
||||
with patch.object(tool.Blender, "get_selected_objects", return_value=[]):
|
||||
assert tool.Blender.Modifier.any_selected_is_array_child() is False
|
||||
|
||||
def test_returns_true_when_any_selected_passes_predicate(self):
|
||||
from bonsai import tool
|
||||
|
||||
child_obj, child_element = SimpleNamespace(name="child"), object()
|
||||
parent_obj, parent_element = SimpleNamespace(name="parent"), object()
|
||||
|
||||
def get_entity(obj):
|
||||
return {id(child_obj): child_element, id(parent_obj): parent_element}.get(id(obj))
|
||||
|
||||
def is_array_child(element):
|
||||
return element is child_element
|
||||
|
||||
with patch.object(tool.Blender, "get_selected_objects", return_value=[parent_obj, child_obj]):
|
||||
with patch.object(tool.Ifc, "get_entity", side_effect=get_entity):
|
||||
with patch.object(tool.Blender.Modifier, "is_array_child", side_effect=is_array_child):
|
||||
assert tool.Blender.Modifier.any_selected_is_array_child() is True
|
||||
|
||||
def test_returns_false_when_no_selected_passes_predicate(self):
|
||||
from bonsai import tool
|
||||
|
||||
parent_obj, parent_element = SimpleNamespace(name="parent"), object()
|
||||
with patch.object(tool.Blender, "get_selected_objects", return_value=[parent_obj]):
|
||||
with patch.object(tool.Ifc, "get_entity", return_value=parent_element):
|
||||
with patch.object(tool.Blender.Modifier, "is_array_child", return_value=False):
|
||||
assert tool.Blender.Modifier.any_selected_is_array_child() is False
|
||||
|
||||
|
||||
class TestHostOpeningGizmoStaysAvailableOnArrayChildren:
|
||||
"""Openings on array children are array-safe: ``regenerate_array``
|
||||
applies opening cuts after replicating child geometry, so an opening
|
||||
authored on a child survives regen and tracks with the replicated
|
||||
instance. The host-opening gizmos therefore route through the loose
|
||||
base wall gate, not the tighter topology gate that excludes
|
||||
children."""
|
||||
|
||||
def test_host_opening_module_does_not_apply_topology_gate(self):
|
||||
import inspect
|
||||
|
||||
from bonsai.bim.module.model import host_add_opening_gizmo
|
||||
|
||||
src = inspect.getsource(host_add_opening_gizmo)
|
||||
assert "_wall_topology_gizmo_poll_gate" not in src, (
|
||||
"host-opening gizmo module references the topology gate; that "
|
||||
"would suppress add-opening on array-child hosts. Openings "
|
||||
"track with the regenerated child via the array regen pipeline."
|
||||
)
|
||||
assert "any_selected_is_array_child" not in src, (
|
||||
"host-opening gizmo module references any_selected_is_array_child; "
|
||||
"openings are array-safe, drop the filter."
|
||||
)
|
||||
@@ -1,386 +0,0 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Forward-compat AST contracts for wall gizmo internals.
|
||||
|
||||
Pins structural invariants that no per-call-site behavioural test can catch
|
||||
on its own: the kind of "someone tidied the imports" regression that leaves
|
||||
tests green but silently changes runtime semantics. Each contract names the
|
||||
invariant it pins so a future revert tells the contributor exactly what the
|
||||
rule is."""
|
||||
|
||||
import ast
|
||||
import inspect
|
||||
import textwrap
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.wall
|
||||
|
||||
|
||||
def test_iter_path_connections_uses_path_connectable_predicate():
|
||||
"""The partner filter must consult the looser ``is_path_connectable_wall``
|
||||
predicate, matching the host-side predicate used by the gizmo group's
|
||||
poll. Strict ``is_wall`` rejects fillet-corner walls (which have no
|
||||
LAYER2 usage by IFC spec), so a regression to ``is_wall`` would silently
|
||||
drop fillet partners from the connection list — visible to the user as
|
||||
"the corner looks unconnected from the adjacent wall's selection.\" """
|
||||
from bonsai.bim.module.model.wall import _iter_path_connections
|
||||
|
||||
source = inspect.getsource(_iter_path_connections)
|
||||
tree = ast.parse(source)
|
||||
attr_names = {node.attr for node in ast.walk(tree) if isinstance(node, ast.Attribute)}
|
||||
|
||||
assert "is_path_connectable_wall" in attr_names, (
|
||||
"_iter_path_connections must filter partners with is_path_connectable_wall — "
|
||||
"the same predicate the gizmo group's poll uses on the host wall. "
|
||||
"Symmetry between host and partner predicates is required for fillet "
|
||||
"corners (no LAYER2 usage) to surface as connected from their LAYER2 "
|
||||
"neighbours' perspective."
|
||||
)
|
||||
assert "is_wall" not in attr_names, (
|
||||
"_iter_path_connections must NOT call .is_wall on partner elements — "
|
||||
"that strict predicate drops fillet-corner walls. Use "
|
||||
"is_path_connectable_wall instead."
|
||||
)
|
||||
|
||||
|
||||
def test_gizmo_wall_link_toggle_invokes_partner_bbox_helper():
|
||||
"""The wall subclass must call draw_wall_partner_bbox when its hover
|
||||
state is active. Without this contract the partner-wall highlight
|
||||
silently regresses if someone "tidies" the draw() override away."""
|
||||
from bonsai.bim.module.model import wall as wall_module
|
||||
|
||||
source = textwrap.dedent(inspect.getsource(wall_module.GizmoWallLinkToggle.draw))
|
||||
tree = ast.parse(source)
|
||||
attr_names = {node.attr for node in ast.walk(tree) if isinstance(node, ast.Attribute)}
|
||||
call_names: set[str] = set()
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
if isinstance(node.func, ast.Attribute):
|
||||
call_names.add(node.func.attr)
|
||||
elif isinstance(node.func, ast.Name):
|
||||
call_names.add(node.func.id)
|
||||
|
||||
assert "is_highlight" in attr_names, (
|
||||
"GizmoWallLinkToggle.draw must gate its highlight call on self.is_highlight — "
|
||||
"without it the partner outline would draw every frame, not just on hover."
|
||||
)
|
||||
assert "draw_wall_partner_bbox" in call_names, (
|
||||
"GizmoWallLinkToggle.draw must call draw_wall_partner_bbox to render the "
|
||||
"partner outline. The shared composite in decorator.py is the canonical "
|
||||
"trigger for this feature; replacing it with an ad-hoc draw call would "
|
||||
"drift from the array-children bbox styling."
|
||||
)
|
||||
|
||||
|
||||
def test_every_wall_gizmo_group_resolves_get_decoration_colors():
|
||||
"""Any wall ``GizmoGroup`` whose ``setup()`` reads decoration colours via
|
||||
``self.get_decoration_colors()`` must inherit from a mixin that supplies
|
||||
it (``gizmo.BaseParametricGizmoGroup`` or ``gizmo.BillboardingGizmoGroupMixin``).
|
||||
Without the mixin the call AttributeErrors inside ``setup()``, Blender
|
||||
logs the failure and skips the rest of ``setup()``, and every later
|
||||
``draw_prepare()`` blows up on whichever attribute the truncated setup
|
||||
failed to assign — a silent, runtime-only regression that no other test
|
||||
catches."""
|
||||
import bpy
|
||||
|
||||
from bonsai.bim.module.model import wall as wall_module
|
||||
|
||||
offenders: list[str] = []
|
||||
for name in dir(wall_module):
|
||||
cls = getattr(wall_module, name)
|
||||
if not inspect.isclass(cls):
|
||||
continue
|
||||
if inspect.getmodule(cls) is not wall_module:
|
||||
continue
|
||||
if not issubclass(cls, bpy.types.GizmoGroup):
|
||||
continue
|
||||
setup = cls.__dict__.get("setup")
|
||||
if setup is None:
|
||||
continue
|
||||
try:
|
||||
src = inspect.getsource(setup)
|
||||
except (OSError, TypeError):
|
||||
continue
|
||||
if "self.get_decoration_colors()" not in src:
|
||||
continue
|
||||
if not hasattr(cls, "get_decoration_colors"):
|
||||
offenders.append(cls.__name__)
|
||||
|
||||
assert not offenders, (
|
||||
f"GizmoGroup subclasses {offenders} call self.get_decoration_colors() in "
|
||||
"setup() but inherit from no class that provides it. Add "
|
||||
"gizmo.BillboardingGizmoGroupMixin (or gizmo.BaseParametricGizmoGroup) to "
|
||||
"the class bases — both define get_decoration_colors and are the canonical "
|
||||
"wall-gizmo mixins."
|
||||
)
|
||||
|
||||
|
||||
def test_host_add_opening_accepts_fillet_corner_active():
|
||||
"""``is_supported_host`` (the gate ``GizmoHostAddOpening.poll`` dispatches
|
||||
through) must classify walls via ``is_path_connectable_wall``, not the
|
||||
strict ``is_wall`` predicate. Fillet-corner walls carry no LAYER2 usage
|
||||
by IFC spec, so the strict predicate rejects them and the add-opening
|
||||
icon never surfaces over a curved corner — symmetry with the join /
|
||||
unjoin / extend wall gizmos (all of which already poll on the looser
|
||||
predicate) is required for the user to drop openings into fillet
|
||||
corners at all."""
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import is_supported_host
|
||||
|
||||
source = textwrap.dedent(inspect.getsource(is_supported_host))
|
||||
tree = ast.parse(source)
|
||||
attr_names = {node.attr for node in ast.walk(tree) if isinstance(node, ast.Attribute)}
|
||||
|
||||
assert "is_path_connectable_wall" in attr_names, (
|
||||
"is_supported_host must gate walls on tool.Parametric.is_path_connectable_wall. "
|
||||
"The strict is_wall predicate hides the add-opening gizmo over every "
|
||||
"fillet-corner wall."
|
||||
)
|
||||
assert "is_wall" not in attr_names, (
|
||||
"is_supported_host must NOT call .is_wall — that strict predicate drops "
|
||||
"fillet-corner walls. Use is_path_connectable_wall instead, matching the "
|
||||
"host gate every other wall-state gizmo group uses."
|
||||
)
|
||||
|
||||
|
||||
def test_join_intersection_uses_l_and_t_glyphs():
|
||||
"""``GizmoWallJoinIntersection.setup`` must bind the join icon to the L
|
||||
glyph (``VIEW3D_GT_wall_corner``) and the extend-to icon to the T glyph
|
||||
(``VIEW3D_GT_wall_tee``). The L / T pair makes the corner-join vs
|
||||
extend-into-side distinction read at a glance — a regression to the
|
||||
arrow-merge glyph for both icons makes them visually indistinguishable
|
||||
once they're stacked at the same XY."""
|
||||
from bonsai.bim.module.model.wall import GizmoWallJoinIntersection
|
||||
|
||||
source = textwrap.dedent(inspect.getsource(GizmoWallJoinIntersection.setup))
|
||||
assert '"VIEW3D_GT_wall_corner"' in source, (
|
||||
"GizmoWallJoinIntersection.setup must bind join_icon to VIEW3D_GT_wall_corner "
|
||||
"(the L glyph). The arrow-merge glyph (VIEW3D_GT_merge) is the collinear-merge "
|
||||
"case and was visually ambiguous with the extend-to icon when both were stacked."
|
||||
)
|
||||
assert '"VIEW3D_GT_wall_tee"' in source, (
|
||||
"GizmoWallJoinIntersection.setup must bind extend_to_wall_icon to "
|
||||
"VIEW3D_GT_wall_tee (the T glyph). The arrow-extend glyph was visually "
|
||||
"ambiguous with the join icon when both were stacked."
|
||||
)
|
||||
|
||||
|
||||
def test_join_intersection_stacks_along_screen_up_in_both_states():
|
||||
"""``GizmoWallJoinIntersection.position_gizmos`` must route both the
|
||||
joined (unjoin + fillet) and the intersecting (join + extend + fillet)
|
||||
states through ``_stack_at`` so the icons stay individually clickable
|
||||
in any view, including top / plan view where world-Z separation
|
||||
collapses to zero on screen. A regression that re-introduces a
|
||||
per-state ``billboarded_at(corner, ...)`` write outside ``_stack_at``
|
||||
silently flattens the stack back onto one screen pixel."""
|
||||
from bonsai.bim.module.model.wall import GizmoWallJoinIntersection
|
||||
|
||||
source = textwrap.dedent(inspect.getsource(GizmoWallJoinIntersection.position_gizmos))
|
||||
tree = ast.parse(source)
|
||||
call_names: set[str] = set()
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
if isinstance(node.func, ast.Attribute):
|
||||
call_names.add(node.func.attr)
|
||||
elif isinstance(node.func, ast.Name):
|
||||
call_names.add(node.func.id)
|
||||
|
||||
assert "_stack_at" in call_names, (
|
||||
"GizmoWallJoinIntersection.position_gizmos must call self._stack_at to "
|
||||
"lay icons along screen-up at the wall-top anchor. Direct "
|
||||
"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."
|
||||
)
|
||||
@@ -18,20 +18,21 @@
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Regression tests for the post-IFC-commit refresh path.
|
||||
"""Regression tests for the post-IFC-commit refresh path that re-syncs the
|
||||
workspace tool header (``BIMModelProperties``) and invalidates the per-wall
|
||||
gizmo geometry cache.
|
||||
|
||||
Two invariants:
|
||||
|
||||
* Every commit bumps ``_geom_generation`` so caches keyed off it drop
|
||||
stale entries on the next read.
|
||||
* The BIM Tool header float refresh (``refresh_bim_tool_headers``) fires
|
||||
only for commits whose operator is a parametric ``finish_op`` from
|
||||
``tool.Parametric.EDIT_TYPES`` — the validate-gizmo path. Other
|
||||
operators skip it; their commit context may lack the view-layer
|
||||
attributes the refresh reads."""
|
||||
Bug repro before the fix: hotkey operators that edited the active wall in
|
||||
place (``bpy.ops.bim.hotkey(hotkey="S_E")`` / ``"C_E"``) mutated IFC but never
|
||||
fired ``active_object_callback`` (no selection change), so the header H/L/A
|
||||
fields and the gizmo cache both kept showing stale values. ``refresh_ui_data``
|
||||
ran, but it never resynced ``BIMModelProperties`` and never invalidated the
|
||||
per-gizmo-group geometry cache. The fix wires both refreshes through
|
||||
``tool.Parametric.refresh_post_commit`` and calls it from every
|
||||
``tool.Ifc.Operator`` epilogue."""
|
||||
|
||||
import types
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
@@ -39,42 +40,23 @@ import pytest
|
||||
pytestmark = pytest.mark.wall
|
||||
|
||||
|
||||
def test_refresh_post_commit_bumps_generation_for_every_operator():
|
||||
"""The generation counter advances on every commit, regardless of
|
||||
operator class — it's the cache-invalidation signal for any code
|
||||
keyed off ``tool.Parametric.get_geom_generation()``."""
|
||||
@pytest.fixture(autouse=True)
|
||||
def _require_real_bpy():
|
||||
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
|
||||
pytest.skip("requires real Blender (bpy is mocked or absent)")
|
||||
|
||||
|
||||
def test_refresh_post_commit_bumps_generation_and_resyncs_header():
|
||||
"""``refresh_post_commit`` must bump the generation counter and call
|
||||
``update_bim_tool_props`` so the workspace tool header re-syncs from IFC."""
|
||||
import bonsai.bim.handler as handler
|
||||
from bonsai import tool
|
||||
|
||||
before = tool.Parametric.get_geom_generation()
|
||||
tool.Parametric.refresh_post_commit(MagicMock(bl_idname="bim.append_library_element"))
|
||||
with patch.object(handler, "update_bim_tool_props") as mock_resync:
|
||||
tool.Parametric.refresh_post_commit()
|
||||
assert tool.Parametric.get_geom_generation() == before + 1
|
||||
|
||||
|
||||
def test_refresh_post_commit_refreshes_headers_for_validate_gizmo_operators():
|
||||
"""Operators whose ``bl_idname`` matches a ``ParametricObject.finish_op``
|
||||
in ``EDIT_TYPES`` are the validate-gizmo path: selection didn't
|
||||
change, but the IFC values backing the BIM Tool header did. The
|
||||
commit hook must push the new IFC state into the header floats."""
|
||||
import bonsai.bim.handler as handler
|
||||
from bonsai import tool
|
||||
|
||||
finish_op_idname = tool.Parametric.EDIT_TYPES[0].finish_op
|
||||
with patch.object(handler, "refresh_bim_tool_headers") as mock_refresh:
|
||||
tool.Parametric.refresh_post_commit(MagicMock(bl_idname=finish_op_idname))
|
||||
mock_refresh.assert_called_once()
|
||||
|
||||
|
||||
def test_refresh_post_commit_skips_header_refresh_for_non_finish_operators():
|
||||
"""Other operators must not trigger the header refresh. The refresh
|
||||
reads ``bpy.context``; for commits invoked from a stripped operator
|
||||
context (e.g. nested ``bpy.ops`` calls during project setup) this
|
||||
would raise ``AttributeError`` and break the outer operator chain."""
|
||||
import bonsai.bim.handler as handler
|
||||
from bonsai import tool
|
||||
|
||||
with patch.object(handler, "refresh_bim_tool_headers") as mock_refresh:
|
||||
tool.Parametric.refresh_post_commit(MagicMock(bl_idname="bim.append_library_element"))
|
||||
mock_refresh.assert_not_called()
|
||||
mock_resync.assert_called_once()
|
||||
|
||||
|
||||
def test_geom_generation_invalidates_wall_geom_cache():
|
||||
@@ -93,7 +75,7 @@ def test_geom_generation_invalidates_wall_geom_cache():
|
||||
sentinel_a = {"length": 1.0, "height": 2.0, "x_angle": 0.0}
|
||||
sentinel_b = {"length": 1.5, "height": 2.5, "x_angle": 0.0}
|
||||
|
||||
with patch.object(tool.Wall, "read_geometry", side_effect=[sentinel_a, sentinel_b]):
|
||||
with patch.object(wall_mod, "_read_wall_geometry", side_effect=[sentinel_a, sentinel_b]):
|
||||
first = wall_mod._get_wall_geom_cached(group, fake_obj)
|
||||
assert first is sentinel_a
|
||||
# Same call without a generation bump must hit the cache (no extra read).
|
||||
|
||||
@@ -1,453 +0,0 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Offset arithmetic for the filling (Door / Window) wall-offset dimension gizmos.
|
||||
|
||||
The apply path (``_set_offset``) must translate ``obj.matrix_world`` so the
|
||||
corresponding read path (``_get_offset``) reads back the new value — i.e.
|
||||
drag a left-offset to 2.0 m, then reading the left offset must return ~2.0 m.
|
||||
The tests below pin that round-trip, the rotated/flipped filling case (the
|
||||
add-opening flow may 180° a filling onto the wall's opposite face), and the
|
||||
visibility predicate."""
|
||||
|
||||
from math import pi
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from mathutils import Matrix
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
# Module under test, plus its cache dict so each test starts from a clean slate.
|
||||
import bonsai.bim.module.model.wall_offset_gizmos as subject
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_geom_cache():
|
||||
"""Per-test isolation — the module-level cache would otherwise carry mocks
|
||||
across tests and surface as flaky-looking failures."""
|
||||
subject._GEOM_CACHE.clear()
|
||||
yield
|
||||
subject._GEOM_CACHE.clear()
|
||||
|
||||
|
||||
def _make_props(filling_matrix, overall_width=1.0, overall_height=2.0, name="FillingObj"):
|
||||
"""Filling PropertyGroup stand-in (``BIMDoorProperties`` /
|
||||
``BIMWindowProperties`` share the relevant shape). The wall-offset helpers
|
||||
only touch ``id_data`` (the filling obj), ``overall_width``, and
|
||||
``overall_height``; nothing else from the real PropertyGroup matters here."""
|
||||
filling_obj = mock.Mock(name=name)
|
||||
filling_obj.name = name
|
||||
filling_obj.matrix_world = filling_matrix
|
||||
props = mock.Mock(spec=["id_data", "overall_width", "overall_height"])
|
||||
props.id_data = filling_obj
|
||||
props.overall_width = overall_width
|
||||
props.overall_height = overall_height
|
||||
return props, filling_obj
|
||||
|
||||
|
||||
def _patch_host_wall(wall_matrix, length, height, geom_gen=1, host_present=True, x_angle=0.0):
|
||||
"""Mock the chain ``Ifc.get_entity → Spatial.get_host_wall → Ifc.get_object``
|
||||
and the ``tool.Wall.*`` IFC reads so the helpers see a host wall
|
||||
positioned at ``wall_matrix`` with the supplied length, height, and
|
||||
extrusion angle. The IFC axis is taken to start at wall-local X=0 and
|
||||
extend to X=``length``. ``host_present=False`` makes the chain return
|
||||
None partway through."""
|
||||
wall_obj = mock.Mock(name="WallObj")
|
||||
wall_obj.matrix_world = wall_matrix
|
||||
host_wall = mock.Mock(name="IfcWall") if host_present else None
|
||||
return mock.patch.multiple(
|
||||
subject.tool,
|
||||
Ifc=mock.MagicMock(
|
||||
spec=subject.tool.Ifc,
|
||||
get_entity=mock.Mock(return_value=mock.Mock(name="IfcDoor")),
|
||||
get_object=mock.Mock(return_value=wall_obj if host_present else None),
|
||||
),
|
||||
Spatial=mock.MagicMock(
|
||||
spec=subject.tool.Spatial,
|
||||
get_host_wall=mock.Mock(return_value=host_wall),
|
||||
),
|
||||
Wall=mock.MagicMock(
|
||||
spec=subject.tool.Wall,
|
||||
get_length_and_height=mock.Mock(return_value=(length, height) if host_present else None),
|
||||
get_axis_local_extent=mock.Mock(return_value=(0.0, length) if host_present else None),
|
||||
get_x_angle=mock.Mock(return_value=x_angle if host_present else None),
|
||||
),
|
||||
Parametric=mock.MagicMock(
|
||||
spec=subject.tool.Parametric,
|
||||
get_geom_generation=mock.Mock(return_value=geom_gen),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Visibility predicate
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_has_host_wall_returns_true_when_chain_resolves():
|
||||
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
assert subject.has_host_wall(props) is True
|
||||
|
||||
|
||||
def test_has_host_wall_returns_false_when_no_host():
|
||||
props, _ = _make_props(Matrix.Identity(4))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0, host_present=False):
|
||||
assert subject.has_host_wall(props) is False
|
||||
|
||||
|
||||
def test_has_host_wall_returns_true_for_slanted_wall():
|
||||
"""Slanted LAYER2 walls keep ``matrix_world`` upright — the slope lives in
|
||||
the IFC extrusion direction and in the wall mesh vertices, not in the
|
||||
object transform. So wall-local Z still equals world Z, the offset math
|
||||
round-trips, and the gizmos must remain visible."""
|
||||
import math
|
||||
|
||||
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0, x_angle=math.radians(15)):
|
||||
assert subject.has_host_wall(props) is True
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Compute helpers (un-flipped filling, wall at world origin)
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_wall_offset_left_for_filling_at_known_x():
|
||||
"""Wall span 0→5 on X; filling origin at wall-local X=1.5 with +X aligned.
|
||||
Filling's left edge is at wall-X 1.5 → offset_left = 1.5."""
|
||||
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
assert subject._get_offset(props, subject._LEFT) == pytest.approx(1.5)
|
||||
|
||||
|
||||
def test_get_wall_offset_right_complements_left_plus_width():
|
||||
"""offset_left + overall_width + offset_right == wall length."""
|
||||
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
left = subject._get_offset(props, subject._LEFT)
|
||||
right = subject._get_offset(props, subject._RIGHT)
|
||||
assert left + props.overall_width + right == pytest.approx(5.0)
|
||||
|
||||
|
||||
def test_get_wall_offset_bottom_for_filling_at_sill_height():
|
||||
"""Wall base at world Z=0; filling origin at wall-local Z=0.5 → sill at 0.5 m."""
|
||||
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
assert subject._get_offset(props, subject._BOTTOM) == pytest.approx(0.5)
|
||||
|
||||
|
||||
def test_get_wall_offset_top_complements_bottom_plus_height():
|
||||
"""offset_bottom + overall_height + offset_top == wall height."""
|
||||
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
bottom = subject._get_offset(props, subject._BOTTOM)
|
||||
top = subject._get_offset(props, subject._TOP)
|
||||
assert bottom + props.overall_height + top == pytest.approx(3.0)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Slanted wall (LAYER2): wall ``matrix_world`` stays upright, so the
|
||||
# offset math is the same as for a vertical wall. Pinning this guards
|
||||
# against the visibility gate being re-added or the math diverging.
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_wall_offset_bottom_for_slanted_wall():
|
||||
"""For a LAYER2 slanted wall the wall matrix is identity rotation — sill
|
||||
height read in the wall's local Z is still the world Z above the wall
|
||||
base."""
|
||||
import math
|
||||
|
||||
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.9)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0, x_angle=math.radians(15)):
|
||||
assert subject._get_offset(props, subject._BOTTOM) == pytest.approx(0.9)
|
||||
|
||||
|
||||
def test_set_wall_offset_bottom_round_trips_for_slanted_wall():
|
||||
"""Round-trip on a slanted wall: setting then reading the bottom offset
|
||||
yields the input. The apply path translates along the wall's local Z
|
||||
direction in world space (``matrix_world.to_3x3().col[2]``), which equals
|
||||
world Z for an upright wall matrix regardless of IFC slope."""
|
||||
import math
|
||||
|
||||
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0, x_angle=math.radians(15)):
|
||||
subject._set_offset(props, subject._BOTTOM, 1.2)
|
||||
assert subject._get_offset(props, subject._BOTTOM) == pytest.approx(1.2)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Flipped filling (180° around Z) — add-opening flow flips a filling that
|
||||
# lands on the wall's opposite face. Offset arithmetic must still report
|
||||
# the leftmost/rightmost edges in wall coordinates, not in filling coordinates.
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_wall_offset_left_handles_flipped_filling():
|
||||
"""Flipped filling at wall-local X=1.5: filling extends from X=1.5 in filling's +X
|
||||
direction, which is wall's -X. So filling's leftmost edge in wall coords is
|
||||
at wall-X 0.5, not wall-X 1.5."""
|
||||
filling_matrix = Matrix.Translation((1.5, 0.0, 0.5)) @ Matrix.Rotation(pi, 4, "Z")
|
||||
props, _ = _make_props(filling_matrix, overall_width=1.0)
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
assert subject._get_offset(props, subject._LEFT) == pytest.approx(0.5)
|
||||
|
||||
|
||||
def test_get_wall_offset_right_handles_flipped_filling():
|
||||
"""Same flipped filling — filling's rightmost edge in wall coords is at the
|
||||
filling origin (wall-X 1.5), so offset_right = wall_length - 1.5 = 3.5."""
|
||||
filling_matrix = Matrix.Translation((1.5, 0.0, 0.5)) @ Matrix.Rotation(pi, 4, "Z")
|
||||
props, _ = _make_props(filling_matrix, overall_width=1.0)
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
assert subject._get_offset(props, subject._RIGHT) == pytest.approx(3.5)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Apply helpers — must round-trip with the compute helpers.
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_set_wall_offset_left_translates_filling_along_wall_x():
|
||||
"""Setting left-offset to 2.0 (from 1.5) shifts the filling origin by +0.5
|
||||
along the wall's local X axis."""
|
||||
props, filling_obj = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
subject._set_offset(props, subject._LEFT, 2.0)
|
||||
assert filling_obj.matrix_world.translation.x == pytest.approx(2.0)
|
||||
assert filling_obj.matrix_world.translation.z == pytest.approx(0.5)
|
||||
|
||||
|
||||
def test_set_wall_offset_bottom_translates_filling_along_wall_z():
|
||||
"""Setting bottom-offset to 1.0 (from 0.5) shifts the filling origin by +0.5
|
||||
along the wall's local Z axis."""
|
||||
props, filling_obj = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
subject._set_offset(props, subject._BOTTOM, 1.0)
|
||||
assert filling_obj.matrix_world.translation.z == pytest.approx(1.0)
|
||||
assert filling_obj.matrix_world.translation.x == pytest.approx(1.5)
|
||||
|
||||
|
||||
def test_set_wall_offset_right_round_trips_with_get():
|
||||
"""The right-edge setter is the symmetric pair of the left-edge setter —
|
||||
they must produce mutually consistent geometry, otherwise pulling the
|
||||
right edge would silently desync the left."""
|
||||
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
subject._set_offset(props, subject._RIGHT, 1.0)
|
||||
result = subject._get_offset(props, subject._RIGHT)
|
||||
assert result == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_set_wall_offset_top_round_trips_with_get():
|
||||
"""Same round-trip invariant for the top edge."""
|
||||
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
subject._set_offset(props, subject._TOP, 0.25)
|
||||
result = subject._get_offset(props, subject._TOP)
|
||||
assert result == pytest.approx(0.25)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Cache invalidation
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_geom_cache_invalidates_when_generation_bumps():
|
||||
"""The cache must reset when ``tool.Parametric.get_geom_generation()``
|
||||
advances so IFC mutations don't leave stale host-wall reads in memory."""
|
||||
props, _ = _make_props(Matrix.Translation((1.0, 0.0, 0.0)))
|
||||
# Patch get_geom_generation on the real class — the cache binds to the class
|
||||
# at definition time, so a mock.patch.multiple on subject.tool.Parametric
|
||||
# would be invisible to it.
|
||||
from bonsai.tool.parametric import Parametric
|
||||
|
||||
with mock.patch.object(Parametric, "get_geom_generation", return_value=1):
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
first = subject._get_offset(props, subject._LEFT)
|
||||
with mock.patch.object(Parametric, "get_geom_generation", return_value=2):
|
||||
with _patch_host_wall(Matrix.Identity(4), length=8.0, height=3.0):
|
||||
right_after_bump = subject._get_offset(props, subject._RIGHT)
|
||||
assert first == pytest.approx(1.0)
|
||||
# 8 m wall, filling at x=1, width 1 → right offset = 6. Reads 6 only if the
|
||||
# cache dropped on the generation bump.
|
||||
assert right_after_bump == pytest.approx(6.0)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Signed value the gizmo reports for left/right (sign-flip on filling's 180° Z rotation)
|
||||
#
|
||||
# Without the sign flip the dim arrow renders in the wrong world direction
|
||||
# for a filling whose local +X is opposite the wall's local +X. The gizmo
|
||||
# system flips its rendered dim arrow 180° around Z whenever the reported
|
||||
# value is negative, so the unflipped/flipped cases produce mirrored signs
|
||||
# and the arrow ends up pointing the right way visually in both orientations.
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_left_signed_value_positive_for_unflipped_filling():
|
||||
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
assert subject._compute_value(props, subject._LEFT) == pytest.approx(1.5)
|
||||
|
||||
|
||||
def test_left_signed_value_negative_for_flipped_filling():
|
||||
"""Filling rotated 180° around Z: its leftmost edge (in wall coords) sits
|
||||
at wall-X 0.5, so the user-facing left offset is 0.5 — but the signed
|
||||
value must be -0.5 so the gizmo flips its rendered arrow 180° around Z."""
|
||||
from math import pi
|
||||
|
||||
filling = Matrix.Translation((1.5, 0.0, 0.5)) @ Matrix.Rotation(pi, 4, "Z")
|
||||
props, _ = _make_props(filling, overall_width=1.0)
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
assert subject._compute_value(props, subject._LEFT) == pytest.approx(-0.5)
|
||||
|
||||
|
||||
def test_right_signed_value_positive_for_unflipped_filling():
|
||||
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
assert subject._compute_value(props, subject._RIGHT) == pytest.approx(2.5)
|
||||
|
||||
|
||||
def test_right_signed_value_negative_for_flipped_filling():
|
||||
from math import pi
|
||||
|
||||
filling = Matrix.Translation((1.5, 0.0, 0.5)) @ Matrix.Rotation(pi, 4, "Z")
|
||||
props, _ = _make_props(filling, overall_width=1.0)
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
assert subject._compute_value(props, subject._RIGHT) == pytest.approx(-3.5)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Matrix-position anchors at the wall edge (visual: arrow tail at wall,
|
||||
# head at filling). The position is in filling-local frame so that mw @ pos
|
||||
# lands at the wall edge in world.
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_left_offset_position_lands_at_wall_start_in_world():
|
||||
"""For an unflipped filling at wall-local (1.5, 0, 0.5) with the wall at the
|
||||
world origin (bound_box.min_x=0), the matrix_position transformed by the
|
||||
filling's world matrix must land at world (0, 0, mid_height)."""
|
||||
props, filling_obj = _make_props(Matrix.Translation((1.5, 0.0, 0.5)), overall_height=2.0)
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
pos_filling_local = subject._edge_position(props, subject._LEFT)
|
||||
pos_world = filling_obj.matrix_world @ pos_filling_local
|
||||
# Wall's start at world X=0 (bound_box.min_x=0 in the patched wall).
|
||||
assert pos_world.x == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_top_offset_position_lands_at_wall_top_in_world():
|
||||
"""The top arrow anchors at wall-top — filling-local Z must equal
|
||||
``overall_height + top_offset`` so the mw-multiplied point lands on the
|
||||
wall's top edge at world height."""
|
||||
props, filling_obj = _make_props(Matrix.Translation((1.5, 0.0, 0.5)), overall_height=2.0)
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
pos_filling_local = subject._edge_position(props, subject._TOP)
|
||||
pos_world = filling_obj.matrix_world @ pos_filling_local
|
||||
# Wall height 3.0, wall base at world Z=0 → wall top at world Z=3.
|
||||
assert pos_world.z == pytest.approx(3.0)
|
||||
|
||||
|
||||
def test_bottom_offset_position_lands_at_wall_base_in_world():
|
||||
"""Same idea for the bottom anchor — filling-local Z = ``-bottom_offset``
|
||||
so the point lands at world Z=0 (the wall's base)."""
|
||||
props, filling_obj = _make_props(Matrix.Translation((1.5, 0.0, 0.5)), overall_height=2.0)
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
pos_filling_local = subject._edge_position(props, subject._BOTTOM)
|
||||
pos_world = filling_obj.matrix_world @ pos_filling_local
|
||||
assert pos_world.z == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_apply_value_takes_absolute_value():
|
||||
"""Apply lambdas use ``abs(v)`` so the apply path stays correct even when
|
||||
the compute side returned a negative signed value (flipped filling)."""
|
||||
props, filling_obj = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
# Simulate the gizmo handing back a negative value (flipped-filling scenario).
|
||||
# The user-facing offset is +2.0, the filling must end up at wall-X=2.0.
|
||||
left_cfg = next(c for c in subject.WALL_OFFSET_GIZMO_CONFIGS if c.attr_name == "host_wall_offset_left")
|
||||
left_cfg.apply_value(props, -2.0)
|
||||
assert filling_obj.matrix_world.translation.x == pytest.approx(2.0)
|
||||
|
||||
|
||||
def test_clear_caches_drops_all_entries():
|
||||
"""The ``load_post`` handler calls ``clear_caches`` so a fresh file
|
||||
doesn't inherit stale entries from the previous one. Pin the contract."""
|
||||
props, _ = _make_props(Matrix.Translation((1.0, 0.0, 0.0)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
subject._get_offset(props, subject._LEFT)
|
||||
assert subject._GEOM_CACHE._data
|
||||
subject.clear_caches()
|
||||
assert not subject._GEOM_CACHE._data
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Stale-cache edge cases — cache keys are Blender object names, invalidated
|
||||
# only by parametric generation bumps and ``load_post``. Anything that
|
||||
# changes scene state without bumping the generation (Blender rename,
|
||||
# external Python script deleting a wall) leaves the cache holding stale
|
||||
# entries until the next IFC mutation. These tests pin that behavior.
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_filling_rename_within_session_reads_correctly_under_new_name():
|
||||
"""Reading offsets after a Blender rename hits a cache miss under the
|
||||
new name and recomputes — the old-name entry is leaked but harmless,
|
||||
and the new-name read returns correct geometry."""
|
||||
props, filling_obj = _make_props(Matrix.Translation((1.5, 0.0, 0.5)), name="Door1")
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
first = subject._get_offset(props, subject._LEFT)
|
||||
assert "Door1" in subject._GEOM_CACHE._data
|
||||
filling_obj.name = "Door1_renamed"
|
||||
second = subject._get_offset(props, subject._LEFT)
|
||||
assert first == pytest.approx(1.5)
|
||||
assert second == pytest.approx(1.5)
|
||||
assert "Door1_renamed" in subject._GEOM_CACHE._data
|
||||
|
||||
|
||||
def test_host_wall_deletion_serves_stale_cache_until_invalidation():
|
||||
"""If the host wall is removed without bumping the generation counter
|
||||
(e.g. external script), the cache keeps returning the pre-deletion
|
||||
geometry — only an IFC mutation or ``clear_caches()`` drops the stale
|
||||
entry."""
|
||||
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
assert subject.has_host_wall(props) is True
|
||||
# Even after the patch exits and the chain would now return None, the
|
||||
# cached entry under the filling's name is still served.
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0, host_present=False):
|
||||
assert subject.has_host_wall(props) is True # stale
|
||||
subject.clear_caches()
|
||||
assert subject.has_host_wall(props) is False # recomputed
|
||||
|
||||
|
||||
def test_filling_rotated_90_degrees_in_wall_plane_falls_back_to_positive_sign():
|
||||
"""A filling rotated exactly 90° around Z has ``col[0].x == 0.0``, which
|
||||
is the ambiguous boundary for the X-sign. The implementation falls back
|
||||
to +1 (the ``>= 0.0`` branch), so the renderer-side value reads as
|
||||
positive — same sign as an unflipped filling."""
|
||||
filling_matrix = Matrix.Translation((1.5, 0.0, 0.5)) @ Matrix.Rotation(pi / 2, 4, "Z")
|
||||
props, _ = _make_props(filling_matrix, overall_width=1.0)
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
signed = subject._compute_value(props, subject._LEFT)
|
||||
# +1 fallback × unflipped-equivalent left offset = +1.5 (filling origin in wall coords).
|
||||
assert signed == pytest.approx(1.5)
|
||||
@@ -1,73 +0,0 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Pins the outward-normals invariant of the parametric-wall draft preview mesh.
|
||||
|
||||
``regenerate_wall_mesh_from_props`` rebuilds ``obj.data`` as a fresh bmesh
|
||||
box from ``BIMWallProperties`` every time a gizmo handle moves. The hand
|
||||
authored face windings carry no guarantee of outward orientation, so the
|
||||
function must normalise face windings before writing the mesh back —
|
||||
otherwise the viewport renders the draft with inverted shading and
|
||||
back-face culling hides faces the user expects to see."""
|
||||
|
||||
import types
|
||||
from unittest.mock import patch
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
from mathutils import Vector
|
||||
|
||||
pytestmark = pytest.mark.wall
|
||||
|
||||
|
||||
def test_regenerate_wall_mesh_from_props_outward_normals():
|
||||
"""Every face of the preview box must have its normal pointing away
|
||||
from the box centroid — the contract every other preview-mesh builder
|
||||
in ``bim/module/model`` (door / window / roof / railing) holds."""
|
||||
from bonsai.bim.module.model.wall import regenerate_wall_mesh_from_props
|
||||
|
||||
mesh = bpy.data.meshes.new("preview_mesh")
|
||||
obj = bpy.data.objects.new("preview_wall", mesh)
|
||||
fake_props = types.SimpleNamespace(
|
||||
length=2.0,
|
||||
height=3.0,
|
||||
thickness=0.2,
|
||||
offset=0.0,
|
||||
x_angle=0.0,
|
||||
anchor_x=0.0,
|
||||
mesh_dirty=False,
|
||||
)
|
||||
|
||||
try:
|
||||
with patch("bonsai.tool.Model.get_wall_props", return_value=fake_props):
|
||||
regenerate_wall_mesh_from_props(obj)
|
||||
|
||||
assert len(mesh.polygons) == 6, f"expected 6 faces, got {len(mesh.polygons)}"
|
||||
centroid = sum((v.co for v in mesh.vertices), Vector()) / len(mesh.vertices)
|
||||
for face in mesh.polygons:
|
||||
outward = (face.center - centroid).normalized()
|
||||
dot = face.normal.dot(outward)
|
||||
assert dot > 0.5, (
|
||||
f"face {face.index} normal {tuple(face.normal)} points inward "
|
||||
f"(outward direction {tuple(outward)}, dot={dot:.3f})"
|
||||
)
|
||||
finally:
|
||||
bpy.data.objects.remove(obj)
|
||||
bpy.data.meshes.remove(mesh)
|
||||
@@ -1,164 +0,0 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Cache-invalidation tests for the wall-topology gizmo helpers.
|
||||
|
||||
``GizmoWallUnjoinSingle`` and ``GizmoWallJoinIntersection`` re-run
|
||||
``_iter_path_connections``, ``_are_walls_joined``, ``_are_walls_collinear``,
|
||||
and ``core.project_axis_intersection`` every viewport redraw without the
|
||||
cache helpers wrapping them. These tests pin that:
|
||||
|
||||
- Repeat calls within one IFC generation reuse the cached result.
|
||||
- An IFC-generation bump invalidates the cache.
|
||||
- ``refresh()`` (the Blender state-change hook on the mixin) drops the cache."""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
def test_get_wall_connections_cached_returns_cached_within_generation():
|
||||
from bonsai.bim.module.model import wall
|
||||
|
||||
group = Mock(spec=[])
|
||||
elem = Mock()
|
||||
elem.GlobalId = "0AAAAAAAAAAAAAAAAAAAAA"
|
||||
expected = [(Mock(), "ATEND", "ATSTART")]
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
def counting_iter(e):
|
||||
call_count["n"] += 1
|
||||
return expected
|
||||
|
||||
with patch.object(wall, "_iter_path_connections", side_effect=counting_iter), patch(
|
||||
"bonsai.bim.module.model.wall.tool.Parametric.get_geom_generation", return_value=7
|
||||
):
|
||||
first = wall._get_wall_connections_cached(group, elem)
|
||||
second = wall._get_wall_connections_cached(group, elem)
|
||||
|
||||
assert first is second
|
||||
assert call_count["n"] == 1
|
||||
|
||||
|
||||
def test_get_wall_connections_cached_invalidates_on_generation_bump():
|
||||
from bonsai.bim.module.model import wall
|
||||
|
||||
group = Mock(spec=[])
|
||||
elem = Mock()
|
||||
elem.GlobalId = "0AAAAAAAAAAAAAAAAAAAAA"
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
def counting_iter(e):
|
||||
call_count["n"] += 1
|
||||
return []
|
||||
|
||||
gen_state = {"gen": 1}
|
||||
with patch.object(wall, "_iter_path_connections", side_effect=counting_iter), patch(
|
||||
"bonsai.bim.module.model.wall.tool.Parametric.get_geom_generation", side_effect=lambda: gen_state["gen"]
|
||||
):
|
||||
wall._get_wall_connections_cached(group, elem)
|
||||
gen_state["gen"] = 2
|
||||
wall._get_wall_connections_cached(group, elem)
|
||||
|
||||
assert call_count["n"] == 2
|
||||
|
||||
|
||||
def test_get_wall_pair_predicate_cached_reuses_value_within_generation():
|
||||
from bonsai.bim.module.model import wall
|
||||
|
||||
group = Mock(spec=[])
|
||||
call_count = {"n": 0}
|
||||
|
||||
def compute():
|
||||
call_count["n"] += 1
|
||||
return "result"
|
||||
|
||||
with patch("bonsai.bim.module.model.wall.tool.Parametric.get_geom_generation", return_value=3):
|
||||
first = wall._get_wall_pair_predicate_cached(group, ("joined", ("guid_a", "guid_b")), compute)
|
||||
second = wall._get_wall_pair_predicate_cached(group, ("joined", ("guid_a", "guid_b")), compute)
|
||||
|
||||
assert first == second == "result"
|
||||
assert call_count["n"] == 1
|
||||
|
||||
|
||||
def test_get_wall_pair_predicate_cached_distinguishes_predicate_kind():
|
||||
"""The cache key includes a tag string ("joined" vs "collinear" vs
|
||||
"intersection") so adding a second predicate for the same pair doesn't
|
||||
return the first predicate's value."""
|
||||
from bonsai.bim.module.model import wall
|
||||
|
||||
group = Mock(spec=[])
|
||||
pair = ("guid_a", "guid_b")
|
||||
with patch("bonsai.bim.module.model.wall.tool.Parametric.get_geom_generation", return_value=3):
|
||||
a = wall._get_wall_pair_predicate_cached(group, ("joined", pair), lambda: "JOINED")
|
||||
b = wall._get_wall_pair_predicate_cached(group, ("collinear", pair), lambda: "COLLINEAR")
|
||||
|
||||
assert a == "JOINED"
|
||||
assert b == "COLLINEAR"
|
||||
|
||||
|
||||
def test_get_wall_pair_predicate_cached_invalidates_on_generation_bump():
|
||||
from bonsai.bim.module.model import wall
|
||||
|
||||
group = Mock(spec=[])
|
||||
call_count = {"n": 0}
|
||||
|
||||
def compute():
|
||||
call_count["n"] += 1
|
||||
return call_count["n"]
|
||||
|
||||
gen_state = {"gen": 1}
|
||||
with patch(
|
||||
"bonsai.bim.module.model.wall.tool.Parametric.get_geom_generation", side_effect=lambda: gen_state["gen"]
|
||||
):
|
||||
first = wall._get_wall_pair_predicate_cached(group, ("joined", ("a", "b")), compute)
|
||||
gen_state["gen"] = 2
|
||||
second = wall._get_wall_pair_predicate_cached(group, ("joined", ("a", "b")), compute)
|
||||
|
||||
assert first == 1
|
||||
assert second == 2
|
||||
assert call_count["n"] == 2
|
||||
|
||||
|
||||
def test_mixin_refresh_clears_pair_and_connection_caches():
|
||||
"""``refresh()`` is Blender's "state changed" signal — typically a
|
||||
selection change. Both the connection list and pair predicate caches
|
||||
must drop alongside the geometry cache, otherwise the next frame would
|
||||
read predicates that targeted the previously-selected pair."""
|
||||
from bonsai.bim.module.model import wall
|
||||
|
||||
class _Group(wall._WallGeomCachedBillboardingMixin):
|
||||
def position_gizmos(self, context):
|
||||
pass
|
||||
|
||||
group = _Group()
|
||||
group._wall_geom_cache = {"x": "geom"}
|
||||
group._wall_connections_cache = {"guid": []}
|
||||
group._wall_pair_predicate_cache = {"key": "value"}
|
||||
|
||||
group.refresh(context=Mock())
|
||||
|
||||
assert group._wall_geom_cache is None
|
||||
assert group._wall_connections_cache is None
|
||||
assert group._wall_pair_predicate_cache is None
|
||||
@@ -1,19 +0,0 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
@@ -1,108 +0,0 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import pytest
|
||||
|
||||
import bonsai.tool as tool
|
||||
from test.bim.bootstrap import NewIfc
|
||||
|
||||
pytestmark = pytest.mark.project
|
||||
|
||||
|
||||
def _populate_pending(*element_ids: int) -> None:
|
||||
pending = tool.Project.get_project_props().pending_opening_recut
|
||||
pending.clear()
|
||||
for eid in element_ids:
|
||||
pending.add().ifc_definition_id = eid
|
||||
|
||||
|
||||
def _make_linked_wall(name: str = "Wall") -> tuple[ifcopenshell.entity_instance, bpy.types.Object]:
|
||||
ifc_file = tool.Ifc.get()
|
||||
element = ifc_file.create_entity("IfcWall", GlobalId=ifcopenshell.guid.new(), Name=name)
|
||||
obj = bpy.data.objects.new(name, bpy.data.meshes.new(name))
|
||||
bpy.context.scene.collection.objects.link(obj)
|
||||
tool.Ifc.link(element, obj)
|
||||
return element, obj
|
||||
|
||||
|
||||
class TestApplyPendingOpeningCuts(NewIfc):
|
||||
def test_clears_pending_and_calls_reimport_with_apply_openings(self):
|
||||
element, obj = _make_linked_wall()
|
||||
_populate_pending(element.id())
|
||||
|
||||
with patch.object(tool.Geometry, "reimport_element_representations") as mock_reimport, patch(
|
||||
"ifcopenshell.util.representation.get_representation",
|
||||
return_value=object(),
|
||||
):
|
||||
result = bpy.ops.bim.apply_pending_opening_cuts()
|
||||
|
||||
assert result == {"FINISHED"}
|
||||
assert len(tool.Project.get_project_props().pending_opening_recut) == 0
|
||||
mock_reimport.assert_called_once()
|
||||
_, kwargs = mock_reimport.call_args
|
||||
assert kwargs.get("apply_openings") is True
|
||||
|
||||
def test_skips_entries_whose_entity_is_gone(self):
|
||||
_populate_pending(99999) # ID guaranteed not present
|
||||
|
||||
with patch.object(tool.Geometry, "reimport_element_representations") as mock_reimport:
|
||||
result = bpy.ops.bim.apply_pending_opening_cuts()
|
||||
|
||||
assert result == {"FINISHED"}
|
||||
assert len(tool.Project.get_project_props().pending_opening_recut) == 0
|
||||
mock_reimport.assert_not_called()
|
||||
|
||||
|
||||
class TestDismissPendingOpeningCuts(NewIfc):
|
||||
def test_clears_collection_without_calling_reimport(self):
|
||||
element, _obj = _make_linked_wall()
|
||||
_populate_pending(element.id())
|
||||
|
||||
with patch.object(tool.Geometry, "reimport_element_representations") as mock_reimport:
|
||||
result = bpy.ops.bim.dismiss_pending_opening_cuts()
|
||||
|
||||
assert result == {"FINISHED"}
|
||||
assert len(tool.Project.get_project_props().pending_opening_recut) == 0
|
||||
mock_reimport.assert_not_called()
|
||||
|
||||
|
||||
class TestSelectPendingOpeningCuts(NewIfc):
|
||||
def test_selects_objects_for_each_pending_entry(self):
|
||||
e1, o1 = _make_linked_wall("WallA")
|
||||
e2, o2 = _make_linked_wall("WallB")
|
||||
_populate_pending(e1.id(), e2.id())
|
||||
|
||||
for obj in bpy.context.view_layer.objects:
|
||||
obj.select_set(False)
|
||||
|
||||
result = bpy.ops.bim.select_pending_opening_cuts()
|
||||
|
||||
assert result == {"FINISHED"}
|
||||
assert o1.select_get() and o2.select_get()
|
||||
assert bpy.context.view_layer.objects.active in (o1, o2)
|
||||
|
||||
def test_cancels_when_no_objects_match(self):
|
||||
_populate_pending(99999)
|
||||
result = bpy.ops.bim.select_pending_opening_cuts()
|
||||
assert result == {"CANCELLED"}
|
||||
@@ -1,232 +0,0 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Framework contract test for the partial-state recovery hint in
|
||||
``IfcStore.execute_ifc_operator``.
|
||||
|
||||
The framework wraps every ``tool.Ifc.Operator._execute`` call between
|
||||
``ifc_file.begin_transaction()`` and ``ifc_file.end_transaction()``. When
|
||||
``_execute`` raises after at least one ``ifcopenshell.api.*`` mutation
|
||||
has been captured, the user is in a partial state (IFC mutated, Blender
|
||||
side stale) and the framework surfaces a WARNING naming Ctrl+Z so the
|
||||
recovery path is discoverable instead of buried behind a raw traceback.
|
||||
|
||||
The contract has three parts pinned here:
|
||||
|
||||
1. ``ifcopenshell.file.Transaction.operations`` is a public list and is
|
||||
the introspection idiom the framework relies on.
|
||||
2. The WARNING fires only when ``_execute`` raised AND the transaction
|
||||
captured at least one operation.
|
||||
3. A successful ``_execute`` never emits the WARNING regardless of
|
||||
whether IFC was mutated."""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.misc
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _require_real_bpy():
|
||||
import types as _types
|
||||
|
||||
import bpy
|
||||
|
||||
if not isinstance(bpy, _types.ModuleType) or hasattr(bpy, "_mock_name"):
|
||||
pytest.skip("requires real Blender (bpy is mocked or absent)")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_ifc():
|
||||
"""Set up a fresh ``ifcopenshell.file`` as ``IfcStore.file`` and tear
|
||||
it down afterwards. Each test gets a virgin transaction state."""
|
||||
import ifcopenshell
|
||||
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
|
||||
previous = IfcStore.file
|
||||
previous_transaction = IfcStore.current_transaction
|
||||
IfcStore.file = ifcopenshell.file(schema="IFC4")
|
||||
IfcStore.current_transaction = ""
|
||||
try:
|
||||
yield IfcStore.file
|
||||
finally:
|
||||
IfcStore.file = previous
|
||||
IfcStore.current_transaction = previous_transaction
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def neutralised_framework():
|
||||
"""Patch the side-effect-heavy helpers in ``IfcStore.execute_ifc_operator``
|
||||
so a bare unit test can drive it without a populated Scene / props /
|
||||
decorator handlers."""
|
||||
with mock.patch("bonsai.bim.ifc.tool.Blender.get_bim_props") as get_props, mock.patch(
|
||||
"bonsai.bim.handler.refresh_ui_data"
|
||||
), mock.patch("bonsai.bim.ifc.tool.Parametric.refresh_post_commit"), mock.patch(
|
||||
"bonsai.bim.ifc.IfcStore.add_transaction_operation"
|
||||
), mock.patch(
|
||||
"bonsai.bim.ifc.IfcStore.begin_transaction"
|
||||
), mock.patch(
|
||||
"bonsai.bim.ifc.IfcStore.end_transaction"
|
||||
), mock.patch(
|
||||
"bonsai.bim.ifc.IfcStore.get_ifc_file_undo_callback", return_value=lambda data: True
|
||||
):
|
||||
get_props.return_value = mock.Mock(is_dirty=False)
|
||||
yield
|
||||
|
||||
|
||||
def _make_operator(execute_callback):
|
||||
"""Build a ``Mock`` operator that satisfies the attribute reads the
|
||||
framework performs (``bl_idname``, ``_execute``, ``report``, etc.)."""
|
||||
op = mock.Mock(spec=["bl_idname", "_execute", "_invoke", "_modal", "report", "transaction_key"])
|
||||
op.bl_idname = "bim.test_partial_state"
|
||||
op._execute = execute_callback
|
||||
return op
|
||||
|
||||
|
||||
def _mutate_ifc():
|
||||
"""Single ``ifcopenshell.api.*`` call so the transaction captures at
|
||||
least one operation. ``project.create_file`` would not work here since
|
||||
it replaces the file; pick a small entity mutation that always lands."""
|
||||
import ifcopenshell.api.owner
|
||||
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
|
||||
ifcopenshell.api.owner.add_person(IfcStore.get_file())
|
||||
|
||||
|
||||
def test_transaction_operations_is_empty_until_first_api_call(fresh_ifc):
|
||||
"""Pin the introspection contract the framework relies on:
|
||||
``Transaction.operations`` is empty after ``begin_transaction()`` and
|
||||
populated by any ``ifcopenshell.api.*`` call."""
|
||||
fresh_ifc.begin_transaction()
|
||||
assert fresh_ifc.transaction is not None
|
||||
assert fresh_ifc.transaction.operations == []
|
||||
|
||||
_mutate_ifc()
|
||||
|
||||
assert len(fresh_ifc.transaction.operations) > 0
|
||||
|
||||
|
||||
def test_no_mutation_no_raise_no_warning(fresh_ifc, neutralised_framework):
|
||||
"""Happy path: ``_execute`` does nothing, returns FINISHED.
|
||||
Framework MUST NOT emit the partial-state WARNING."""
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
|
||||
op = _make_operator(execute_callback=lambda context: {"FINISHED"})
|
||||
IfcStore.execute_ifc_operator(op, context=mock.Mock())
|
||||
|
||||
for call in op.report.call_args_list:
|
||||
assert "Ctrl+Z" not in call.args[1], "partial-state WARNING fired on a clean success path"
|
||||
|
||||
|
||||
def test_raise_before_mutation_no_warning(fresh_ifc, neutralised_framework):
|
||||
"""``_execute`` raises before any IFC mutation. The transaction has no
|
||||
operations → no partial state → no WARNING."""
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
|
||||
def _raise_immediately(context):
|
||||
raise RuntimeError("kaboom")
|
||||
|
||||
op = _make_operator(execute_callback=_raise_immediately)
|
||||
with pytest.raises(RuntimeError, match="kaboom"):
|
||||
IfcStore.execute_ifc_operator(op, context=mock.Mock())
|
||||
|
||||
for call in op.report.call_args_list:
|
||||
assert "Ctrl+Z" not in call.args[1], "partial-state WARNING fired without any mutation"
|
||||
|
||||
|
||||
def test_mutation_then_success_no_warning(fresh_ifc, neutralised_framework):
|
||||
"""Real mutation, normal FINISHED return. WARNING is exception-path
|
||||
only and MUST NOT fire on a clean success."""
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
|
||||
def _mutate_and_finish(context):
|
||||
_mutate_ifc()
|
||||
return {"FINISHED"}
|
||||
|
||||
op = _make_operator(execute_callback=_mutate_and_finish)
|
||||
IfcStore.execute_ifc_operator(op, context=mock.Mock())
|
||||
|
||||
for call in op.report.call_args_list:
|
||||
assert "Ctrl+Z" not in call.args[1], "partial-state WARNING fired on a successful mutation"
|
||||
|
||||
|
||||
def test_mutation_then_raise_emits_warning(fresh_ifc, neutralised_framework):
|
||||
"""The contract this whole change exists for: mutate, then raise.
|
||||
Framework MUST emit a WARNING naming Ctrl+Z before the exception
|
||||
re-raises into Blender's normal operator error flow."""
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
|
||||
def _mutate_then_raise(context):
|
||||
_mutate_ifc()
|
||||
raise RuntimeError("rebuild failed after IFC mutation")
|
||||
|
||||
op = _make_operator(execute_callback=_mutate_then_raise)
|
||||
with pytest.raises(RuntimeError, match="rebuild failed"):
|
||||
IfcStore.execute_ifc_operator(op, context=mock.Mock())
|
||||
|
||||
warning_calls = [
|
||||
call
|
||||
for call in op.report.call_args_list
|
||||
if call.args and call.args[0] == {"WARNING"} and "Ctrl+Z" in call.args[1]
|
||||
]
|
||||
assert (
|
||||
len(warning_calls) == 1
|
||||
), f"expected exactly one partial-state WARNING with Ctrl+Z guidance, got: {op.report.call_args_list}"
|
||||
|
||||
|
||||
def test_mutation_then_raise_pushes_blender_undo_step(fresh_ifc, neutralised_framework):
|
||||
"""A raised operator does not get an automatic Blender undo step (same gap
|
||||
as the CANCELLED-modal path). The framework pushes one explicitly so the
|
||||
Ctrl+Z the WARNING advertises actually rewinds the partial mutation."""
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
|
||||
def _mutate_then_raise(context):
|
||||
_mutate_ifc()
|
||||
raise RuntimeError("rebuild failed after IFC mutation")
|
||||
|
||||
op = _make_operator(execute_callback=_mutate_then_raise)
|
||||
with mock.patch("bonsai.bim.ifc.bpy.ops", new=mock.Mock()) as bpy_ops:
|
||||
undo_push = bpy_ops.ed.undo_push
|
||||
with pytest.raises(RuntimeError, match="rebuild failed"):
|
||||
IfcStore.execute_ifc_operator(op, context=mock.Mock())
|
||||
|
||||
assert undo_push.call_count == 1, f"expected exactly one undo_push, got {undo_push.call_count}"
|
||||
pushed_message = undo_push.call_args.kwargs.get("message", "")
|
||||
assert op.bl_idname in pushed_message, f"undo step message should name the operator, got: {pushed_message!r}"
|
||||
|
||||
|
||||
def test_raise_before_mutation_does_not_push_undo_step(fresh_ifc, neutralised_framework):
|
||||
"""No mutation captured → nothing to recover → no recovery undo step.
|
||||
Avoids polluting the undo history with no-op recovery snapshots."""
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
|
||||
def _raise_immediately(context):
|
||||
raise RuntimeError("kaboom")
|
||||
|
||||
op = _make_operator(execute_callback=_raise_immediately)
|
||||
with mock.patch("bonsai.bim.ifc.bpy.ops", new=mock.Mock()) as bpy_ops:
|
||||
undo_push = bpy_ops.ed.undo_push
|
||||
with pytest.raises(RuntimeError, match="kaboom"):
|
||||
IfcStore.execute_ifc_operator(op, context=mock.Mock())
|
||||
|
||||
assert undo_push.call_count == 0, "undo_push fired on a non-partial-state raise"
|
||||
@@ -1764,17 +1764,7 @@ def i_load_the_ifc_test_file(filepath):
|
||||
@given("I load the demo construction library")
|
||||
@when("I load the demo construction library")
|
||||
def i_add_a_construction_library():
|
||||
# Pick the library file whose schema matches the current project so the
|
||||
# appended types are valid (IFC2X3-vs-IFC4 entity attributes differ).
|
||||
schema_to_library = {
|
||||
"IFC2X3": "IFC2X3 Demo Library.ifc",
|
||||
"IFC4": "IFC4 Demo Library.ifc",
|
||||
"IFC4X3": "IFC4X3 Demo Library.ifc",
|
||||
"IFC4X3_ADD2": "IFC4X3 Demo Library.ifc",
|
||||
}
|
||||
schema = tool.Ifc.get().schema
|
||||
lib_name = schema_to_library.get(schema, "IFC4 Demo Library.ifc")
|
||||
lib_path = f"./bonsai/bim/data/libraries/{lib_name}"
|
||||
lib_path = "./bonsai/bim/data/libraries/IFC4 Demo Library.ifc"
|
||||
bpy.ops.bim.select_library_file(filepath=lib_path, append_all=True)
|
||||
|
||||
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Forward-compat AST contracts for the BIM Tool refresh path.
|
||||
|
||||
Pins structural invariants that no behavioural test can catch on its own:
|
||||
the commit-driven header refresh fires only for the parametric validate-
|
||||
gizmo operators (``bim.finish_editing_<name>``), never universally — and
|
||||
the header writer never drifts into user-intent enum writes."""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
HANDLER_PATH = Path(__file__).parent.parent.parent / "bonsai" / "bim" / "handler.py"
|
||||
PARAMETRIC_PATH = HANDLER_PATH.parent.parent / "tool" / "parametric.py"
|
||||
MODEL_MODULE_DIR = HANDLER_PATH.parent / "module" / "model"
|
||||
|
||||
# User-intent enums encode the user's "what to build next" choice on the
|
||||
# BIM Tool panel. The header-only writer must never drift into enum writes;
|
||||
# user-intent enums are owned by the selection-change path.
|
||||
USER_INTENT_ENUM_ATTRS = frozenset({"ifc_class", "relating_type_id"})
|
||||
|
||||
|
||||
def _function_node(tree: ast.Module, name: str) -> ast.FunctionDef:
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.FunctionDef) and node.name == name:
|
||||
return node
|
||||
raise AssertionError(f"{name!r} not found in {HANDLER_PATH.name}")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def handler_tree() -> ast.Module:
|
||||
return ast.parse(HANDLER_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_read_headers_into_props_writes_only_header_floats(handler_tree: ast.Module) -> None:
|
||||
"""``_read_headers_into_props`` is the header-only writer called from
|
||||
the selection-driven refresh. It must not assign to user-intent enum
|
||||
slots (``ifc_class``, ``relating_type_id``); those are the
|
||||
'what to build next' choice and have their own targeted writes
|
||||
earlier in ``update_bim_tool_props``."""
|
||||
fn = _function_node(handler_tree, "_read_headers_into_props")
|
||||
offenders = []
|
||||
for node in ast.walk(fn):
|
||||
if not isinstance(node, ast.Assign):
|
||||
continue
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Attribute) and target.attr in USER_INTENT_ENUM_ATTRS:
|
||||
offenders.append((target.attr, node.lineno))
|
||||
if offenders:
|
||||
msgs = ", ".join(f"{attr} at line {line}" for attr, line in offenders)
|
||||
pytest.fail(
|
||||
f"_read_headers_into_props assigns to user-intent enum slot(s): {msgs}. "
|
||||
f"Header refresh must not re-target the user's BIM Tool panel selection."
|
||||
)
|
||||
|
||||
|
||||
def test_refresh_post_commit_gates_header_refresh_on_edit_types_registry() -> None:
|
||||
"""``tool.Parametric.refresh_post_commit`` fires for every IFC
|
||||
operator commit. Only operators whose ``bl_idname`` matches a
|
||||
``ParametricObject.finish_op`` in ``EDIT_TYPES`` (the validate-
|
||||
gizmo path) must trigger a BIM Tool header refresh — selection
|
||||
didn't change but the header values did. Other operators must
|
||||
skip the refresh: they don't target an active-object header edit,
|
||||
and their commit context may lack the view-layer attributes the
|
||||
refresh reads.
|
||||
|
||||
The gate must consult the registry, not match a string prefix —
|
||||
``EDIT_TYPES`` is the canonical list of parametric features, and
|
||||
querying it stays correct even if ``ParametricObject.finish_op``
|
||||
changes its derivation rule."""
|
||||
parametric_tree = ast.parse(PARAMETRIC_PATH.read_text(encoding="utf-8"))
|
||||
fn = _function_node(parametric_tree, "refresh_post_commit")
|
||||
found_gated_call = False
|
||||
for node in ast.walk(fn):
|
||||
if not isinstance(node, ast.If):
|
||||
continue
|
||||
references_registry = any(
|
||||
isinstance(sub, ast.Attribute) and sub.attr == "EDIT_TYPES" for sub in ast.walk(node.test)
|
||||
)
|
||||
if not references_registry:
|
||||
continue
|
||||
for body_node in ast.walk(node):
|
||||
if (
|
||||
isinstance(body_node, ast.Call)
|
||||
and isinstance(body_node.func, ast.Attribute)
|
||||
and body_node.func.attr == "refresh_bim_tool_headers"
|
||||
):
|
||||
found_gated_call = True
|
||||
break
|
||||
if found_gated_call:
|
||||
break
|
||||
assert found_gated_call, (
|
||||
"tool.Parametric.refresh_post_commit must gate refresh_bim_tool_headers on an "
|
||||
"If whose test references EDIT_TYPES (the parametric registry). An ungated call "
|
||||
"fires the refresh for commits in contexts that strip view-layer attributes; "
|
||||
"a missing call silently drops the validate-gizmo header refresh."
|
||||
)
|
||||
|
||||
|
||||
def _modules_with_module_scope_cache_and_clear():
|
||||
"""Yield ``module_name`` for every ``bim/module/model/*.py`` source that
|
||||
declares a module-scope ``GenerationKeyedCache()`` assignment AND a
|
||||
top-level ``def clear_caches``. These are the modules whose cache state
|
||||
survives file loads and must be drained from ``_apply_save_file_invariants``."""
|
||||
for path in MODEL_MODULE_DIR.glob("*.py"):
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
has_cache = False
|
||||
has_clear = False
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.FunctionDef) and node.name == "clear_caches":
|
||||
has_clear = True
|
||||
continue
|
||||
if isinstance(node, ast.Assign):
|
||||
for sub in ast.walk(node.value):
|
||||
if (
|
||||
isinstance(sub, ast.Call)
|
||||
and isinstance(sub.func, ast.Attribute)
|
||||
and sub.func.attr == "GenerationKeyedCache"
|
||||
):
|
||||
has_cache = True
|
||||
break
|
||||
if has_cache and has_clear:
|
||||
yield path.stem
|
||||
|
||||
|
||||
def test_on_load_post_drains_every_module_scope_geom_cache() -> None:
|
||||
"""Module-scope ``GenerationKeyedCache`` instances persist across file
|
||||
loads — the counter they invalidate against is class-level and survives
|
||||
a ``.blend`` reload. Without a ``load_post`` drain the cache may serve
|
||||
entries whose ``bpy_struct`` references point into the previous file's
|
||||
freed ``bpy.data``, raising ``ReferenceError`` on the next attribute read.
|
||||
|
||||
Pin: every model module that exposes both a module-scope cache and a
|
||||
top-level ``clear_caches`` is called from ``tool.Parametric.on_load_post``,
|
||||
the central post-load drain."""
|
||||
parametric_tree = ast.parse(PARAMETRIC_PATH.read_text(encoding="utf-8"))
|
||||
fn = _function_node(parametric_tree, "on_load_post")
|
||||
drained: set[str] = set()
|
||||
for node in ast.walk(fn):
|
||||
if (
|
||||
isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Attribute)
|
||||
and node.func.attr == "clear_caches"
|
||||
and isinstance(node.func.value, ast.Name)
|
||||
):
|
||||
drained.add(node.func.value.id)
|
||||
missing = [name for name in _modules_with_module_scope_cache_and_clear() if name not in drained]
|
||||
if missing:
|
||||
pytest.fail(
|
||||
"Module(s) expose a module-scope GenerationKeyedCache + clear_caches() but "
|
||||
f"tool.Parametric.on_load_post does not drain them on load_post: {sorted(missing)}. "
|
||||
"Add a `<module>.clear_caches()` call so freshly-loaded files cannot serve "
|
||||
"entries holding freed bpy.data references from the previous file."
|
||||
)
|
||||
@@ -1,74 +0,0 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Restricted-context regression test for ``tool.Blender.get_active_object``.
|
||||
|
||||
Some Blender contexts (e.g. the C-side operator context handed to
|
||||
programmatically-invoked nested ``bpy.ops`` calls) lack the view-layer
|
||||
attributes a normal UI context exposes. The canonical accessor must
|
||||
return ``None`` in that case rather than ``AttributeError`` — otherwise
|
||||
every caller routed through it inherits the same crash class that
|
||||
originally broke ``bpy.ops.bim.new_project(preset='demo')``."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
class _RestrictedContext:
|
||||
"""Stand-in for a ``bpy.context`` stripped of view-layer attributes."""
|
||||
|
||||
def __getattr__(self, name):
|
||||
raise AttributeError(name)
|
||||
|
||||
|
||||
def test_get_active_object_returns_none_in_restricted_context():
|
||||
"""``tool.Blender.get_active_object`` is the canonical defensive
|
||||
accessor. Both the primary read (``bpy.context.active_object``) and
|
||||
the fallback (``bpy.context.view_layer.objects.active``) must
|
||||
tolerate a stripped context — otherwise the 150+ callers in the
|
||||
codebase that route through this helper inherit the crash."""
|
||||
import bonsai.tool.blender as blender_tool
|
||||
|
||||
with patch.object(blender_tool, "bpy") as bpy_patch:
|
||||
bpy_patch.context = _RestrictedContext()
|
||||
assert blender_tool.Blender.get_active_object() is None
|
||||
|
||||
|
||||
def test_property_header_tools_whitelists_bim_tool_family_only():
|
||||
"""``tool.Blender.get_property_header_tools`` gates the validate-
|
||||
gizmo header refresh. Parametric ``BimTool`` subclasses and the
|
||||
base ``BimTool`` itself must be included; ``AnnotationTool`` and
|
||||
workspace tools outside the ``BimTool`` family (spatial, structural,
|
||||
etc.) must not — they don't surface these header floats."""
|
||||
import bonsai.tool as tool_
|
||||
|
||||
# Ensure the lru_cache picks up subclasses registered by the
|
||||
# current Blender session (idempotent if already populated).
|
||||
tool_.Blender.get_property_header_tools.cache_clear()
|
||||
headers = tool_.Blender.get_property_header_tools()
|
||||
|
||||
assert "bim.bim_tool" in headers, "base BimTool must surface property headers"
|
||||
assert "bim.wall_tool" in headers, "parametric BimTool subclass must surface property headers"
|
||||
assert "bim.annotation_tool" not in headers, "AnnotationTool is not a BimTool subclass — no header surface"
|
||||
assert "bim.spatial_tool" not in headers, "SpatialTool is not BimTool-derived"
|
||||
assert "bim.structural_tool" not in headers, "StructuralTool is not BimTool-derived"
|
||||
@@ -22,15 +22,14 @@
|
||||
|
||||
The registry is the single source of truth for which parametric element types
|
||||
exist. Every consumer (auto-commit on save, finish/cancel chains, the
|
||||
``PointerProperty`` attachment, the ``GizmoPreferences`` per-feature toggle)
|
||||
derives identifiers from each entry's short ``name`` token. Forget any
|
||||
downstream registration and the silent-desync the framework exists to prevent
|
||||
will ship.
|
||||
``PointerProperty`` attachment, the ``GizmoPreferences<X>`` registration) derives
|
||||
identifiers from each entry's short ``name`` token. Forget any downstream
|
||||
registration and the silent-desync the framework exists to prevent will ship.
|
||||
|
||||
These tests pin the registry-to-runtime contract: for every entry the operator
|
||||
``bl_idname``s resolve to registered ``bpy.ops.bim.*`` callables, the
|
||||
``PropertyGroup`` class is attached to ``bpy.types.Object``, and the per-type
|
||||
predicate exists on `tool.Parametric`."""
|
||||
predicate exists on `tool.Blender.Modifier`."""
|
||||
|
||||
import types
|
||||
|
||||
@@ -82,11 +81,11 @@ def test_every_entry_has_property_group_attached(registry):
|
||||
)
|
||||
|
||||
|
||||
def test_every_entry_has_parametric_predicate(registry):
|
||||
def test_every_entry_has_modifier_predicate(registry):
|
||||
from bonsai import tool
|
||||
|
||||
missing = [e.name for e in registry if getattr(tool.Parametric, f"is_{e.name}", None) is None]
|
||||
assert not missing, f"tool.Parametric missing is_<name> predicates: {missing}"
|
||||
missing = [e.name for e in registry if getattr(tool.Blender.Modifier, f"is_{e.name}", None) is None]
|
||||
assert not missing, f"tool.Blender.Modifier missing is_<name> predicates: {missing}"
|
||||
|
||||
|
||||
def test_every_predicate_does_not_raise_on_non_matching_element(registry):
|
||||
@@ -109,7 +108,7 @@ def test_every_predicate_does_not_raise_on_non_matching_element(registry):
|
||||
|
||||
raised = []
|
||||
for feature in registry:
|
||||
predicate = getattr(tool.Parametric, f"is_{feature.name}", None)
|
||||
predicate = getattr(tool.Blender.Modifier, f"is_{feature.name}", None)
|
||||
if predicate is None:
|
||||
continue
|
||||
try:
|
||||
@@ -123,14 +122,19 @@ def test_every_predicate_does_not_raise_on_non_matching_element(registry):
|
||||
)
|
||||
|
||||
|
||||
def test_gizmo_preferences_field_per_registry_entry(registry):
|
||||
"""Every registry entry must have a matching ``<name>: BoolProperty`` field
|
||||
on ``ui.GizmoPreferences`` so the addon-preferences UI auto-renders a
|
||||
toggle for it and ``BaseParametricGizmoGroup.poll`` can gate the whole
|
||||
gizmo group on ``prefs.gizmos.<name>``.
|
||||
def test_gizmo_preferences_attached_when_class_exists(registry):
|
||||
"""For every registry entry whose ``GizmoPreferences<Name>`` class exists in
|
||||
``bonsai.bim.ui``, the matching sub-PointerProperty must be declared on
|
||||
``ui.GizmoPreferences`` under the registry entry's ``name`` token.
|
||||
|
||||
Catches the silent-skip behaviour of the registry-driven gizmo-prefs
|
||||
discovery: a typo in the class name or a dropped registration would
|
||||
otherwise produce a missing sub-panel at runtime with no error.
|
||||
Entries without a ``GizmoPreferences<Name>`` class are allowed — not
|
||||
every parametric type ships gizmo prefs.
|
||||
|
||||
Checks ``__annotations__`` rather than ``hasattr`` because Blender's
|
||||
PropertyGroup syntax (``field: bpy.props.BoolProperty(...)``) is an
|
||||
PropertyGroup syntax (``field: bpy.props.PointerProperty(...)``) is an
|
||||
annotation-only assignment — the attribute only materialises on the
|
||||
class after Blender's metaclass installs the bpy_struct descriptor,
|
||||
which depends on registration timing. Reading ``__annotations__``
|
||||
@@ -138,9 +142,16 @@ def test_gizmo_preferences_field_per_registry_entry(registry):
|
||||
from bonsai.bim import ui
|
||||
|
||||
annotations = getattr(ui.GizmoPreferences, "__annotations__", {})
|
||||
missing = [feature.name for feature in registry if feature.name not in annotations]
|
||||
missing = []
|
||||
for feature in registry:
|
||||
prefs_class_name = f"GizmoPreferences{feature.name.capitalize()}"
|
||||
if not hasattr(ui, prefs_class_name):
|
||||
continue
|
||||
if feature.name not in annotations:
|
||||
missing.append((feature.name, prefs_class_name))
|
||||
assert not missing, (
|
||||
f"ui.GizmoPreferences missing BoolProperty field(s) for: {missing} — "
|
||||
f"each registry entry must have a matching ``<name>: BoolProperty(...)`` "
|
||||
f"field on ``ui.GizmoPreferences`` so the preferences UI surfaces a toggle"
|
||||
f"ui.GizmoPreferences missing sub-PointerProperty field(s) for: {missing} — "
|
||||
f"each registered ``GizmoPreferences<Name>`` class must have a matching "
|
||||
f"``<name>: PointerProperty(type=GizmoPreferences<Name>)`` field on "
|
||||
f"``ui.GizmoPreferences``"
|
||||
)
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Forward-compat AST contract for the pen-icon dispatcher monopoly.
|
||||
|
||||
Every parametric gizmo group's pen icon must bind to the universal
|
||||
``bim.enable_editing_parametric`` dispatcher rather than the feature's own
|
||||
enable operator. The dispatcher is the single chokepoint where pre-edit
|
||||
checks (shared-representation warning, future safety gates) run; a feature
|
||||
that binds directly bypasses every such check silently."""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.drawing
|
||||
|
||||
|
||||
BONSAI_ROOT = Path(__file__).parent.parent.parent / "bonsai"
|
||||
BIM_DIR = BONSAI_ROOT / "bim"
|
||||
DISPATCHER_IDNAME = "bim.enable_editing_parametric"
|
||||
|
||||
|
||||
def _iter_pen_gizmo_target_set_operator_calls(tree: ast.Module):
|
||||
"""Yield each ``ast.Call`` matching ``<receiver>.pen_gizmo.target_set_operator(...)``.
|
||||
Receiver is any attribute access (``self.pen_gizmo``, ``group.pen_gizmo``, etc.)."""
|
||||
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 != "target_set_operator":
|
||||
continue
|
||||
receiver = func.value
|
||||
if not isinstance(receiver, ast.Attribute) or receiver.attr != "pen_gizmo":
|
||||
continue
|
||||
yield node
|
||||
|
||||
|
||||
def test_every_pen_gizmo_binding_routes_through_the_universal_dispatcher() -> None:
|
||||
violations: list[str] = []
|
||||
found_any = False
|
||||
for path in BIM_DIR.rglob("*.py"):
|
||||
try:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
except SyntaxError:
|
||||
continue
|
||||
for call in _iter_pen_gizmo_target_set_operator_calls(tree):
|
||||
found_any = True
|
||||
if not call.args:
|
||||
violations.append(f"{path}:{call.lineno} pen_gizmo.target_set_operator() called with no args")
|
||||
continue
|
||||
first_arg = call.args[0]
|
||||
if not isinstance(first_arg, ast.Constant) or not isinstance(first_arg.value, str):
|
||||
violations.append(
|
||||
f"{path}:{call.lineno} pen_gizmo.target_set_operator() first arg is not a string literal"
|
||||
)
|
||||
continue
|
||||
if first_arg.value != DISPATCHER_IDNAME:
|
||||
violations.append(
|
||||
f"{path}:{call.lineno} pen_gizmo.target_set_operator({first_arg.value!r}) "
|
||||
f"bypasses the universal dispatcher"
|
||||
)
|
||||
|
||||
assert found_any, (
|
||||
"No pen_gizmo.target_set_operator(...) calls found anywhere under bim/. "
|
||||
"Either the gizmo-binding pattern has been refactored away (this test "
|
||||
"needs updating) or the search root is wrong."
|
||||
)
|
||||
assert not violations, (
|
||||
"Pen-icon bindings must route through the universal dispatcher "
|
||||
f"({DISPATCHER_IDNAME!r}) so the shared-representation warning and any "
|
||||
"future pre-edit checks apply to every feature. Violations:\n " + "\n ".join(violations)
|
||||
)
|
||||
@@ -1,144 +0,0 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Forward-compat AST contract for the preview cancellation registry.
|
||||
|
||||
Every ``PointerProperty`` child of ``BIMPreviewProperties`` whose target
|
||||
PropertyGroup declares an ``is_active`` BoolProperty is a Scene-level
|
||||
preview. Each must have a matching ``(child_attr, cancel_op_name)`` entry
|
||||
in ``preview_base.PREVIEW_CANCEL_OPS`` so the Esc dispatcher and the
|
||||
``load_post`` stale-flag discard both cover it.
|
||||
|
||||
A new preview type that defines its own Enable / Decorator without
|
||||
registering the cancel pair will silently ignore Esc and leave a stuck
|
||||
``is_active`` flag across file reloads — exactly the failure mode the
|
||||
sibling forward-compat guards exist to prevent."""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
BONSAI_ROOT = Path(__file__).parent.parent.parent / "bonsai"
|
||||
PROP_FILE = BONSAI_ROOT / "bim" / "module" / "model" / "prop.py"
|
||||
UMBRELLA_CLASS = "BIMPreviewProperties"
|
||||
|
||||
|
||||
def _find_class(tree: ast.Module, name: str) -> ast.ClassDef | None:
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ClassDef) and node.name == name:
|
||||
return node
|
||||
return None
|
||||
|
||||
|
||||
def _iter_pointer_property_children(class_node: ast.ClassDef):
|
||||
"""Yield ``(attr_name, target_class_name)`` for each
|
||||
``<attr>: bpy.props.PointerProperty(type=<TargetClass>)`` annotated
|
||||
assignment in the umbrella class body.
|
||||
|
||||
Bonsai follows the Blender convention where the property call lives in
|
||||
the *annotation* (PEP 526 syntax) rather than the value — Blender's
|
||||
PropertyGroup metaclass picks it up at class creation time."""
|
||||
for node in class_node.body:
|
||||
if not isinstance(node, ast.AnnAssign) or not isinstance(node.target, ast.Name):
|
||||
continue
|
||||
if not isinstance(node.annotation, ast.Call):
|
||||
continue
|
||||
func = node.annotation.func
|
||||
if not isinstance(func, ast.Attribute) or func.attr != "PointerProperty":
|
||||
continue
|
||||
for kw in node.annotation.keywords:
|
||||
if kw.arg == "type" and isinstance(kw.value, ast.Name):
|
||||
yield node.target.id, kw.value.id
|
||||
break
|
||||
|
||||
|
||||
def _class_has_is_active_bool(class_node: ast.ClassDef) -> bool:
|
||||
"""Return True if ``class_node`` declares ``is_active: bpy.props.BoolProperty(...)``."""
|
||||
for node in class_node.body:
|
||||
if not isinstance(node, ast.AnnAssign) or not isinstance(node.target, ast.Name):
|
||||
continue
|
||||
if node.target.id != "is_active":
|
||||
continue
|
||||
if not isinstance(node.annotation, ast.Call):
|
||||
continue
|
||||
func = node.annotation.func
|
||||
if isinstance(func, ast.Attribute) and func.attr == "BoolProperty":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def test_every_preview_propertygroup_is_registered_in_cancel_ops() -> None:
|
||||
from bonsai.bim.module.model import preview_base
|
||||
|
||||
registered_attrs = {attr for attr, _op in preview_base.PREVIEW_CANCEL_OPS}
|
||||
|
||||
tree = ast.parse(PROP_FILE.read_text(encoding="utf-8"))
|
||||
umbrella = _find_class(tree, UMBRELLA_CLASS)
|
||||
assert umbrella is not None, (
|
||||
f"Could not find {UMBRELLA_CLASS!r} in {PROP_FILE}. Either the umbrella class "
|
||||
"was renamed (this test needs updating) or prop.py was restructured."
|
||||
)
|
||||
|
||||
preview_children: list[tuple[str, str]] = []
|
||||
for attr, target_class_name in _iter_pointer_property_children(umbrella):
|
||||
target = _find_class(tree, target_class_name)
|
||||
if target is None:
|
||||
continue
|
||||
if _class_has_is_active_bool(target):
|
||||
preview_children.append((attr, target_class_name))
|
||||
|
||||
assert preview_children, (
|
||||
"No PointerProperty children with ``is_active`` BoolProperty found under "
|
||||
f"{UMBRELLA_CLASS}. Either the preview convention has been refactored away "
|
||||
"(this test needs updating) or prop.py was restructured."
|
||||
)
|
||||
|
||||
missing = [(attr, cls) for attr, cls in preview_children if attr not in registered_attrs]
|
||||
assert not missing, (
|
||||
"Every Scene-level preview PropertyGroup must have a matching "
|
||||
"(child_attr, cancel_op_name) tuple in preview_base.PREVIEW_CANCEL_OPS so "
|
||||
"Esc dispatch and load_post stale-flag discard cover it. Missing entries:\n "
|
||||
+ "\n ".join(f"BIMPreviewProperties.{attr} (target={cls!r})" for attr, cls in missing)
|
||||
)
|
||||
|
||||
|
||||
def test_every_cancel_ops_entry_has_a_real_preview_propertygroup() -> None:
|
||||
"""The reverse contract: a stale entry in ``PREVIEW_CANCEL_OPS`` whose
|
||||
PropertyGroup has been deleted would silently leak to every Esc press
|
||||
(dispatching to a missing operator raises ``AttributeError`` inside
|
||||
``try_cancel_active_preview``). Pin that the registry never goes
|
||||
stale relative to ``BIMPreviewProperties``."""
|
||||
from bonsai.bim.module.model import preview_base
|
||||
|
||||
tree = ast.parse(PROP_FILE.read_text(encoding="utf-8"))
|
||||
umbrella = _find_class(tree, UMBRELLA_CLASS)
|
||||
assert umbrella is not None
|
||||
|
||||
declared_attrs = {attr for attr, _target in _iter_pointer_property_children(umbrella)}
|
||||
orphaned = [attr for attr, _op in preview_base.PREVIEW_CANCEL_OPS if attr not in declared_attrs]
|
||||
assert not orphaned, (
|
||||
"PREVIEW_CANCEL_OPS contains entries whose PointerProperty child no longer "
|
||||
f"exists on {UMBRELLA_CLASS}. Drop the stale tuple(s):\n "
|
||||
+ "\n ".join(orphaned)
|
||||
)
|
||||
@@ -56,7 +56,6 @@ class TestCopyClass:
|
||||
ifc.get_entity("data").should_be_called().will_return("new_representation")
|
||||
geometry.get_representation_name("new_representation").should_be_called().will_return("name")
|
||||
geometry.rename_object("data", "name").should_be_called()
|
||||
root.has_material_styles("element").should_be_called().will_return(False)
|
||||
root.assign_body_styles("element", "obj").should_be_called()
|
||||
collector.assign("obj").should_be_called()
|
||||
subject.copy_class(ifc, collector, geometry, root, obj="obj")
|
||||
|
||||
@@ -52,35 +52,6 @@ class TestAssignContainer:
|
||||
collector.assign("obj2").should_be_called()
|
||||
subject.assign_container(ifc, collector, spatial, container="container", objs=["obj"])
|
||||
|
||||
def test_root_resolves_to_self_for_a_filling(self, ifc, collector, spatial):
|
||||
ifc.get_entity("door_obj").should_be_called().will_return("door")
|
||||
spatial.get_root_element("door").should_be_called().will_return("door")
|
||||
spatial.disable_editing("door_obj").should_be_called()
|
||||
spatial.get_decomposition("door").should_be_called().will_return(["door"])
|
||||
spatial.can_contain("container", "door").should_be_called().will_return(True)
|
||||
ifc.run("spatial.assign_container", products=["door"], relating_structure="container").should_be_called()
|
||||
ifc.get_object("door").should_be_called().will_return("door_obj")
|
||||
collector.assign("door_obj").should_be_called()
|
||||
subject.assign_container(ifc, collector, spatial, container="container", objs=["door_obj"])
|
||||
|
||||
def test_can_contain_is_evaluated_per_root_element(self, ifc, collector, spatial):
|
||||
ifc.get_entity("door_obj").should_be_called().will_return("door")
|
||||
spatial.get_root_element("door").should_be_called().will_return("door")
|
||||
spatial.disable_editing("door_obj").should_be_called()
|
||||
spatial.get_decomposition("door").should_be_called().will_return(["door"])
|
||||
ifc.get_entity("opening_obj").should_be_called().will_return("opening")
|
||||
spatial.get_root_element("opening").should_be_called().will_return("opening")
|
||||
spatial.disable_editing("opening_obj").should_be_called()
|
||||
spatial.get_decomposition("opening").should_be_called().will_return(["opening"])
|
||||
spatial.can_contain("container", "door").should_be_called().will_return(True)
|
||||
spatial.can_contain("container", "opening").should_be_called().will_return(False)
|
||||
ifc.run("spatial.assign_container", products=["door"], relating_structure="container").should_be_called()
|
||||
ifc.get_object("door").should_be_called().will_return("door_obj")
|
||||
ifc.get_object("opening").should_be_called().will_return("opening_obj")
|
||||
collector.assign("door_obj").should_be_called()
|
||||
collector.assign("opening_obj").should_be_called()
|
||||
subject.assign_container(ifc, collector, spatial, container="container", objs=["door_obj", "opening_obj"])
|
||||
|
||||
|
||||
class TestEnableEditingContainer:
|
||||
def test_run(self, spatial):
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user