mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-06 07:51:47 +00:00
Compare commits
95 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b9dbebccc | |||
| 7bcbe1b908 | |||
| 1ccd74e78a | |||
| 93c6350e0f | |||
| a139adaa2c | |||
| a4a806147d | |||
| 4962e3256d | |||
| 06d99feeea | |||
| f584a50fbb | |||
| 24a241addc | |||
| 365be8fb52 | |||
| eacff93945 | |||
| 9d956f18b7 | |||
| 8faf9ff43d | |||
| 87bca20df7 | |||
| a30546f1f2 | |||
| fbe6fe5384 | |||
| bd264f1d85 | |||
| 674ed36e41 | |||
| 1f2b20fd86 | |||
| 94fab271cd | |||
| 8583d0963f | |||
| 77a2284f8a | |||
| 4520a72152 | |||
| 25651a1507 | |||
| 94faaa3160 | |||
| 0e922074b9 | |||
| 90ea256cc3 | |||
| 387bd51b4a | |||
| de4c394b50 | |||
| ab64b652ff | |||
| 99bb1e30ad | |||
| 1707c36bd8 | |||
| 44f5ee028f | |||
| ed7b2fc233 | |||
| 2c18155d98 | |||
| 1e8c0b86a0 | |||
| ac11044261 | |||
| ab9152e32d | |||
| 18dc7abb06 | |||
| b7549f2476 | |||
| ba6cfe9c24 | |||
| f158ae7377 | |||
| b154cadf3b | |||
| 5dde402f8b | |||
| 1daee04d9c | |||
| 0d7c378db5 | |||
| 1ba9341201 | |||
| f0aec7b38e | |||
| 4cf34b69d2 | |||
| 44723e83b6 | |||
| 2272c35e9b | |||
| 1c128a2d6a | |||
| 36372627db | |||
| a9b6f02f02 | |||
| 9e7d97e298 | |||
| 187b8e7167 | |||
| 08e33fe572 | |||
| e142b9d7b4 | |||
| de29d12b00 | |||
| 9440bafc32 | |||
| 431cf435ef | |||
| a433f56337 | |||
| 0d3543fa31 | |||
| e764559133 | |||
| a3f92eb427 | |||
| 453e6dc1cc | |||
| 728026d3f0 | |||
| 3f270df11e | |||
| 792a0c7da1 | |||
| 5856d29fbe | |||
| cd482a7874 | |||
| f6e95c8e8e | |||
| ee63137c6c | |||
| c83b4eb69f | |||
| 5eef433abf | |||
| 7e5e7b8d6a | |||
| 788d4fe8e8 | |||
| a1c2aecf1b | |||
| d501970352 | |||
| fd96e6a4d2 | |||
| 3dd3a0d70c | |||
| 2e5995176a | |||
| 2114c1d5d0 | |||
| 3f680f5c21 | |||
| 97e8deb069 | |||
| 49348908e6 | |||
| c250b2c1a7 | |||
| 6874d52100 | |||
| 6c21e2b6f4 | |||
| 70845e4dd4 | |||
| d7b5ac1453 | |||
| 1961cd905e | |||
| f1cf757ba2 | |||
| b039e12623 |
@@ -258,10 +258,14 @@ 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.
|
||||
target_link_libraries(IFCOPENSHELL_RocksDB INTERFACE RocksDB::rocksdb)
|
||||
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()
|
||||
|
||||
if(WITH_ZSTD)
|
||||
# @todo do we actually need the zstd include dir or rather just pass
|
||||
|
||||
@@ -88,7 +88,15 @@ 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}'.")
|
||||
set(HDF5_LIBRARIES hdf5_cpp-static)
|
||||
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()
|
||||
else()
|
||||
# If it failed, still try to find as a module.
|
||||
# E.g. on Ubuntu `libhdf5-dev` doesn't provie hdf5-config.cmake.
|
||||
|
||||
+9
-1
@@ -192,7 +192,11 @@ endif
|
||||
# Provides networkx graph analysis for project dependency calculations
|
||||
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download networkx --dest=./wheels
|
||||
# Required by IFCDiff
|
||||
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download deepdiff --dest=./wheels
|
||||
# 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
|
||||
# Required by IFCCSV and ifcopenshell.util.selector
|
||||
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download lark --dest=./wheels
|
||||
# Required by IFC4D
|
||||
@@ -356,6 +360,10 @@ 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,18 +29,6 @@ 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:
|
||||
@@ -171,10 +159,6 @@ 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.DefaultParameters and ui.BIM_ADDON_preferences are registered separately after modules (see late_classes below)
|
||||
# Tabs panel
|
||||
@@ -287,6 +271,7 @@ def register():
|
||||
parametric_lifecycle.install_parametric_lifecycle_handlers()
|
||||
bpy.app.handlers.load_post.append(handler.load_post)
|
||||
bpy.app.handlers.load_post.append(handler.loadIfcStore)
|
||||
bpy.app.handlers.save_post.append(handler.save_post)
|
||||
bpy.types.Scene.BIMProperties = bpy.props.PointerProperty(type=prop.BIMProperties)
|
||||
bpy.types.Scene.BIMSnapProperties = bpy.props.PointerProperty(type=prop.BIMSnapProperties)
|
||||
bpy.types.Scene.BIMSnapGroups = bpy.props.PointerProperty(type=prop.BIMSnapGroups)
|
||||
@@ -345,6 +330,7 @@ def unregister():
|
||||
parametric_lifecycle.uninstall_parametric_lifecycle_handlers()
|
||||
bpy.app.handlers.load_post.remove(handler.load_post)
|
||||
bpy.app.handlers.load_post.remove(handler.loadIfcStore)
|
||||
bpy.app.handlers.save_post.remove(handler.save_post)
|
||||
del bpy.types.Scene.BIMProperties
|
||||
del bpy.types.Collection.BIMCollectionProperties
|
||||
del bpy.types.Object.BIMObjectProperties
|
||||
|
||||
@@ -41,13 +41,18 @@ 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 (
|
||||
BoundingBoxDecorator,
|
||||
SlabDirectionDecorator,
|
||||
WallAxisDecorator,
|
||||
WallFilletPreviewDecorator,
|
||||
)
|
||||
from bonsai.bim.module.model.preview_base import discard_pending_previews
|
||||
from bonsai.bim.module.model.wall import WallGizmoPreviewDecorator
|
||||
from bonsai.bim.module.nest.decorator import NestDecorator
|
||||
|
||||
cwd = os.path.dirname(os.path.realpath(__file__))
|
||||
@@ -115,19 +120,13 @@ def active_object_callback():
|
||||
|
||||
|
||||
def update_bim_tool_props():
|
||||
"""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:
|
||||
"""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:
|
||||
return
|
||||
obj, current_tool, element = ctx
|
||||
|
||||
props = tool.Model.get_model_props()
|
||||
aprops = tool.Drawing.get_annotation_props()
|
||||
@@ -150,7 +149,14 @@ def update_bim_tool_props():
|
||||
return
|
||||
|
||||
if is_bim_tool:
|
||||
props.ifc_class = element_type.is_a()
|
||||
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
|
||||
|
||||
# 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
|
||||
@@ -170,6 +176,48 @@ 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
|
||||
@@ -187,6 +235,7 @@ def update_bim_tool_props():
|
||||
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"]
|
||||
@@ -383,10 +432,33 @@ def subscribe_to_viewport_shading_changes():
|
||||
)
|
||||
|
||||
|
||||
@persistent
|
||||
def save_post(scene) -> None:
|
||||
"""After saving the .blend file, convert the stored IFC path to relative if enabled."""
|
||||
pprops = tool.Project.get_project_props()
|
||||
if not pprops.use_relative_project_path:
|
||||
return
|
||||
bim_props = tool.Blender.get_bim_props()
|
||||
ifc_path = bim_props.ifc_file
|
||||
if not ifc_path or not os.path.isabs(ifc_path):
|
||||
return
|
||||
blend_dir = bpy.path.abspath("//")
|
||||
if not blend_dir:
|
||||
return
|
||||
from pathlib import Path
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
try:
|
||||
rel_path = str(Path(ifc_path).relative_to(blend_dir))
|
||||
except ValueError:
|
||||
return # IFC file is not under the blend directory; keep absolute path
|
||||
bim_props.ifc_file = rel_path
|
||||
IfcStore.set_path(ifc_path) # keep IfcStore.path absolute for loading
|
||||
|
||||
|
||||
def _apply_save_file_invariants(scene: bpy.types.Scene) -> None:
|
||||
"""Invariants enforced on every load_post: msgbus subscription, IFC owner
|
||||
settings, scene-bound caches, draft-flag healing, multi-instance lock probe,
|
||||
and previews discarded so saved preview state never resurfaces on reopen."""
|
||||
settings, scene-bound caches, load-transient parametric state, and the
|
||||
multi-instance lock probe."""
|
||||
global global_subscription_owner
|
||||
active_object_key = bpy.types.LayerObjects, "active"
|
||||
bpy.msgbus.subscribe_rna(
|
||||
@@ -397,8 +469,7 @@ def _apply_save_file_invariants(scene: bpy.types.Scene) -> None:
|
||||
ifcopenshell.api.owner.settings.get_application = get_application
|
||||
AuthoringData.type_thumbnails = {}
|
||||
|
||||
tool.Parametric.heal_stale_edit_flags()
|
||||
discard_pending_previews(scene)
|
||||
tool.Parametric.on_load_post(scene)
|
||||
|
||||
if tool.Ifc.get() and bpy.data.is_saved:
|
||||
props = tool.Blender.get_bim_props()
|
||||
@@ -462,6 +533,10 @@ def _install_viewport_overlays() -> None:
|
||||
NestDecorator.uninstall()
|
||||
WallAxisDecorator.uninstall()
|
||||
SlabDirectionDecorator.uninstall()
|
||||
WallFilletPreviewDecorator.uninstall()
|
||||
WallGizmoPreviewDecorator.uninstall()
|
||||
ArrayPreviewDecorator.uninstall()
|
||||
ArraySelectionHighlightDecorator.uninstall()
|
||||
uninstall_decorator_cache_handlers()
|
||||
try:
|
||||
if georeference_props.should_visualise:
|
||||
@@ -476,6 +551,21 @@ 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: 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 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()
|
||||
tool.Parametric.refresh_post_commit(operator)
|
||||
|
||||
if method == "MODAL":
|
||||
cls.modal_in_progress = False
|
||||
|
||||
@@ -1219,8 +1219,8 @@ class IfcImporter:
|
||||
if element not in elements_to_import:
|
||||
continue
|
||||
for i in range(len(data)):
|
||||
tool.Blender.Modifier.Array.set_children_lock_state(element, i, True)
|
||||
tool.Blender.Modifier.Array.constrain_children_to_parent(element)
|
||||
tool.Array.set_children_lock_state(element, i, True)
|
||||
tool.Array.constrain_children_to_parent(element)
|
||||
|
||||
def update_linked_aggregates(self):
|
||||
# TODO Remove this after a while. See commit 17d6b8a
|
||||
|
||||
@@ -139,6 +139,7 @@ class BIMAggregateProperties(PropertyGroup):
|
||||
previous_editing_aggregate: PointerProperty(name="Editing Aggregate", type=bpy.types.Object)
|
||||
editing_objects: CollectionProperty(type=Objects)
|
||||
not_editing_objects: CollectionProperty(type=Objects)
|
||||
previously_selected_objects: CollectionProperty(type=Objects)
|
||||
aggregate_decorator: BoolProperty(
|
||||
name="Display Aggregate",
|
||||
default=False,
|
||||
@@ -155,5 +156,6 @@ class BIMAggregateProperties(PropertyGroup):
|
||||
previous_editing_aggregate: Union[bpy.types.Object, None]
|
||||
editing_objects: bpy.types.bpy_prop_collection_idprop[Objects]
|
||||
not_editing_objects: bpy.types.bpy_prop_collection_idprop[Objects]
|
||||
previously_selected_objects: bpy.types.bpy_prop_collection_idprop[Objects]
|
||||
aggregate_decorator: bool
|
||||
previous_state: bool
|
||||
|
||||
@@ -48,12 +48,14 @@ def draw_ui(context: bpy.types.Context, layout: bpy.types.UILayout, attributes)
|
||||
row = layout.row()
|
||||
op = row.operator("bim.enable_editing_attributes", icon="GREASEPENCIL", text="Edit")
|
||||
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
key_prefix = "type." if (element and element.is_a("IfcTypeObject")) else ""
|
||||
for attribute in attributes:
|
||||
row = layout.row(align=True)
|
||||
row.label(text=attribute["name"])
|
||||
value = bonsai.bim.helper.get_display_value(attribute["value"])
|
||||
op = row.operator("bim.select_similar", text=value, icon="NONE", emboss=False)
|
||||
op.key = attribute["name"]
|
||||
op.key = key_prefix + attribute["name"]
|
||||
|
||||
# TODO: reimplement, see #1222
|
||||
# if "IfcSite/" in context.active_object.name or "IfcBuilding/" in context.active_object.name:
|
||||
|
||||
@@ -138,15 +138,26 @@ classes = (
|
||||
gizmos.GizmoArrow2D,
|
||||
gizmos.GizmoCone,
|
||||
gizmos.GizmoDimension,
|
||||
gizmos.GizmoLock,
|
||||
gizmos.GizmoLockOpen,
|
||||
gizmos.GizmoLockClosed,
|
||||
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,
|
||||
@@ -154,6 +165,7 @@ 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,8 +44,12 @@ class ViewportData:
|
||||
|
||||
@classmethod
|
||||
def load(cls):
|
||||
cls.is_loaded = True
|
||||
# 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
|
||||
|
||||
@classmethod
|
||||
def mode(cls) -> tool.Blender.BLENDER_ENUM_ITEMS:
|
||||
@@ -76,9 +80,9 @@ class ViewportData:
|
||||
modes.append(edit_mode)
|
||||
elif element.is_a("IfcGridAxis"):
|
||||
modes.append(edit_mode)
|
||||
elif tool.Blender.Modifier.is_roof(element):
|
||||
elif tool.Parametric.is_roof(element):
|
||||
modes.append(edit_mode)
|
||||
elif tool.Blender.Modifier.is_railing(element):
|
||||
elif tool.Parametric.is_railing(element):
|
||||
modes.append(edit_mode)
|
||||
elif item_mode not in modes:
|
||||
modes.append(item_mode)
|
||||
|
||||
@@ -60,6 +60,7 @@ 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:
|
||||
@@ -1026,10 +1027,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.Blender.Modifier.Array.get_modifiers_data(array_parent))]
|
||||
data = [(i, data) for i, data in enumerate(tool.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.Blender.Modifier.Array.get_children_objects(modifier_data))
|
||||
children = set(tool.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)
|
||||
@@ -1288,9 +1289,7 @@ 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")
|
||||
@@ -2228,6 +2227,8 @@ 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
|
||||
@@ -2269,6 +2270,8 @@ class OverrideModeSetEdit(bpy.types.Operator, tool.Ifc.Operator):
|
||||
gprops = tool.Geometry.get_geometry_props()
|
||||
if gprops.representation_obj:
|
||||
tool.Geometry.disable_item_mode()
|
||||
if active_obj := bpy.context.active_object:
|
||||
active_obj.select_set(False)
|
||||
else:
|
||||
bonsai.core.aggregate.exit_aggregate_mode(tool.Aggregate)
|
||||
return {"FINISHED"}
|
||||
@@ -2355,6 +2358,7 @@ class OverrideModeSetEdit(bpy.types.Operator, tool.Ifc.Operator):
|
||||
and usage in ("LAYER1", "LAYER2")
|
||||
):
|
||||
self.report({"INFO"}, f"Parametric {usage} elements cannot be edited directly")
|
||||
obj.select_set(False)
|
||||
elif item.is_a("IfcSweptAreaSolid"):
|
||||
tool.Geometry.sync_item_positions()
|
||||
res = tool.Model.import_profile((profile := item.SweptArea), obj=obj)
|
||||
@@ -2363,6 +2367,7 @@ class OverrideModeSetEdit(bpy.types.Operator, tool.Ifc.Operator):
|
||||
{"INFO"},
|
||||
f"Couldn't import profile, editing it directly is not yet supported. Failing profile: {profile}.",
|
||||
)
|
||||
obj.select_set(False)
|
||||
return
|
||||
tool.Ifc.link(item, obj.data)
|
||||
self.enable_edit_mode(context)
|
||||
@@ -2490,9 +2495,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.Blender.Modifier.is_railing(element):
|
||||
elif tool.Parametric.is_railing(element):
|
||||
bpy.ops.bim.finish_editing_railing_path()
|
||||
elif tool.Blender.Modifier.is_roof(element):
|
||||
elif tool.Parametric.is_roof(element):
|
||||
bpy.ops.bim.finish_editing_roof_path()
|
||||
elif tool.Model.get_usage_type(element) == "PROFILE":
|
||||
bpy.ops.bim.edit_extrusion_axis()
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import bpy
|
||||
from bpy.types import Menu, Panel, UIList
|
||||
|
||||
import ifcopenshell.util.unit
|
||||
import bonsai.bim
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.helper import prop_with_search
|
||||
@@ -483,10 +484,32 @@ class BIM_PT_placement(Panel):
|
||||
row.label(text="No Object Placement Found")
|
||||
return
|
||||
|
||||
is_imperial = False
|
||||
if tool.Ifc.get():
|
||||
length_unit = ifcopenshell.util.unit.get_project_unit(tool.Ifc.get(), "LENGTHUNIT")
|
||||
if length_unit and length_unit.Name != "METRE":
|
||||
is_imperial = True
|
||||
|
||||
row = self.layout.row()
|
||||
row.prop(context.active_object, "location", text="Location")
|
||||
row.label(text="Location:")
|
||||
|
||||
if is_imperial:
|
||||
loc = context.active_object.location
|
||||
for i, (axis, comp) in enumerate(zip("XYZ", (loc.x, loc.y, loc.z))):
|
||||
split = self.layout.split(factor=0.6)
|
||||
split.prop(context.active_object, "location", index=i, text=axis)
|
||||
sub = split.row()
|
||||
sub.enabled = False
|
||||
sub.alignment = "LEFT"
|
||||
sub.label(text=tool.Unit.format_distance(comp))
|
||||
else:
|
||||
for i, axis in enumerate("XYZ"):
|
||||
self.layout.prop(context.active_object, "location", index=i, text=axis)
|
||||
|
||||
row = self.layout.row()
|
||||
row.prop(context.active_object, "rotation_euler", text="Rotation")
|
||||
row.label(text="Rotation:")
|
||||
for i, axis in enumerate("XYZ"):
|
||||
self.layout.prop(context.active_object, "rotation_euler", index=i, text=axis)
|
||||
|
||||
if props.blender_offset_type != "NONE":
|
||||
row = self.layout.row(align=True)
|
||||
|
||||
@@ -31,6 +31,7 @@ from . import (
|
||||
external,
|
||||
grid,
|
||||
handler,
|
||||
host_add_opening_gizmo,
|
||||
mep,
|
||||
opening,
|
||||
product,
|
||||
@@ -50,17 +51,28 @@ from . import (
|
||||
|
||||
classes = (
|
||||
array.AddArray,
|
||||
array.DisableEditingArray,
|
||||
array.EditArray,
|
||||
array.CancelEditingArray,
|
||||
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,
|
||||
@@ -83,15 +95,22 @@ classes = (
|
||||
wall.EnableEditingWall,
|
||||
wall.ExtendWallHeightToCursor,
|
||||
wall.ExtendWallsToUnderside,
|
||||
wall.RegenerateWallToUnderside,
|
||||
wall.ExtendWallsToWall,
|
||||
wall.ExtendWallsToPolylinePoint,
|
||||
wall.ExtendWallToCursor,
|
||||
wall.FinishEditingWall,
|
||||
wall.FlipWall,
|
||||
wall.GizmoWallAddOpening,
|
||||
host_add_opening_gizmo.GizmoHostAddOpening,
|
||||
host_add_opening_gizmo.GizmoHostToggleOpenings,
|
||||
wall.GizmoWallEdition,
|
||||
wall.GizmoWallExtendVertically,
|
||||
wall.GizmoWallFilletPreview,
|
||||
wall.GizmoWallFilletReedit,
|
||||
wall.GizmoWallFilletToggleOpenings,
|
||||
wall.GizmoWallJoinIntersection,
|
||||
wall.GizmoWallLinkToggle,
|
||||
wall.GizmoWallUnjoinSingle,
|
||||
wall.JoinWallsIntersection,
|
||||
wall.MergeWall,
|
||||
wall.OffsetWalls,
|
||||
@@ -99,8 +118,13 @@ classes = (
|
||||
wall.RotateWall90,
|
||||
wall.SplitWall,
|
||||
wall.SplitWallAtCursor,
|
||||
wall.ToggleWallOpenings,
|
||||
wall.UnjoinWallPathConnection,
|
||||
wall.UnjoinWalls,
|
||||
wall.EnableWallFilletPreview,
|
||||
wall.FinishWallFilletPreview,
|
||||
wall.CancelWallFilletPreview,
|
||||
wall.EnableWallFilletPreviewFromCorner,
|
||||
wall.CreateWallFillet,
|
||||
opening.AddBoolean,
|
||||
opening.CloneOpening,
|
||||
opening.EditOpenings,
|
||||
@@ -112,6 +136,7 @@ classes = (
|
||||
opening.RemoveBoolean,
|
||||
opening.SelectBoolean,
|
||||
opening.ShowOpenings,
|
||||
opening.ToggleHostOpenings,
|
||||
opening.UpdateOpeningsFocus,
|
||||
profile.ChangeCardinalPoint,
|
||||
profile.ChangeProfileDepth,
|
||||
@@ -161,6 +186,8 @@ classes = (
|
||||
prop.BIMWallProperties,
|
||||
prop.BIMPolylineProperties,
|
||||
prop.BIMExternalParametricGeometryProperties,
|
||||
prop.BIMWallFilletPreviewProperties,
|
||||
prop.BIMPreviewProperties,
|
||||
ui.BIM_PT_array,
|
||||
ui.BIM_PT_stair,
|
||||
ui.BIM_PT_wall,
|
||||
@@ -184,7 +211,8 @@ classes = (
|
||||
stair.ToggleStairProperty,
|
||||
stair.AdjustStairTreads,
|
||||
stair.SetStairTreads,
|
||||
stair.CycleStairType,
|
||||
stair.InputStairTreads,
|
||||
stair.PickStairType,
|
||||
stair.GizmoStairEdition,
|
||||
sverchok_modifier.CreateNewSverchokGraph,
|
||||
sverchok_modifier.UpdateDataFromSverchok,
|
||||
@@ -197,7 +225,7 @@ classes = (
|
||||
window.FinishEditingWindow,
|
||||
window.EnableEditingWindow,
|
||||
window.RemoveWindow,
|
||||
window.CycleWindowType,
|
||||
window.PickWindowType,
|
||||
window.GizmoWindowEdition,
|
||||
door.BIM_OT_add_door,
|
||||
door.AddDoor,
|
||||
@@ -206,7 +234,7 @@ classes = (
|
||||
door.EnableEditingDoor,
|
||||
door.RemoveDoor,
|
||||
door.ToggleDoorSwing,
|
||||
door.CycleDoorType,
|
||||
door.PickDoorType,
|
||||
door.GizmoDoorEdition,
|
||||
railing.BIM_OT_add_railing,
|
||||
railing.CopyRailingParameters,
|
||||
@@ -223,11 +251,13 @@ 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,
|
||||
@@ -291,6 +321,7 @@ def register():
|
||||
bpy.types.Object.BIMExternalParametricGeometryProperties = bpy.props.PointerProperty(
|
||||
type=prop.BIMExternalParametricGeometryProperties
|
||||
)
|
||||
bpy.types.Scene.BIMPreviewProperties = bpy.props.PointerProperty(type=prop.BIMPreviewProperties)
|
||||
|
||||
bpy.types.VIEW3D_MT_add.prepend(ui.add_menu)
|
||||
bpy.app.handlers.load_post.append(handler.load_post)
|
||||
@@ -299,6 +330,12 @@ def register():
|
||||
|
||||
|
||||
def unregister():
|
||||
# DecorationsHandler is installed lazily by bim.show_openings; tear it down
|
||||
# (along with its persistent depsgraph / undo / redo / load cache handlers)
|
||||
# before the rest of unregister so those handlers can't fire against
|
||||
# half-unloaded module state.
|
||||
opening.DecorationsHandler.uninstall()
|
||||
|
||||
if not bpy.app.background:
|
||||
for tool_data in reversed(tools):
|
||||
bpy.utils.unregister_tool(tool_data.tool)
|
||||
@@ -309,6 +346,7 @@ def unregister():
|
||||
del bpy.types.Object.BIMSverchokProperties
|
||||
tool.Parametric.unregister_object_properties()
|
||||
del bpy.types.Object.BIMExternalParametricGeometryProperties
|
||||
del bpy.types.Scene.BIMPreviewProperties
|
||||
|
||||
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
@@ -108,7 +108,7 @@ class ProfileDecorator:
|
||||
|
||||
obj = context.active_object
|
||||
|
||||
if obj.mode != "EDIT":
|
||||
if obj is None or obj.mode != "EDIT":
|
||||
if exit_edit_mode_callback:
|
||||
ProfileDecorator.uninstall()
|
||||
exit_edit_mode_callback()
|
||||
@@ -2029,3 +2029,248 @@ class BoundingBoxDecorator:
|
||||
else:
|
||||
co1.y += y_overlap / 2 + min_spacing
|
||||
co2.y -= y_overlap / 2 + min_spacing
|
||||
|
||||
|
||||
def _stroke_lines_alpha(
|
||||
context: bpy.types.Context,
|
||||
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]],
|
||||
color_rgb: tuple[float, float, float],
|
||||
line_width: float,
|
||||
line_alpha: float,
|
||||
) -> None:
|
||||
"""Render ``segments`` (a list of ``(start, end)`` tuples) as one
|
||||
anti-aliased LINES batch in world space. Early-returns when
|
||||
``context.region`` is unavailable (e.g. when called from a
|
||||
``_RestrictContext``)."""
|
||||
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(tuple(start))
|
||||
verts.append(tuple(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, line_alpha))
|
||||
batch = batch_for_shader(shader, "LINES", {"pos": verts}, indices=indices)
|
||||
gpu.state.blend_set("ALPHA")
|
||||
batch.draw(shader)
|
||||
gpu.state.blend_set("NONE")
|
||||
|
||||
|
||||
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. Companion to
|
||||
``_stroke_lines_alpha`` for filled previews."""
|
||||
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")
|
||||
|
||||
|
||||
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)),
|
||||
]
|
||||
_stroke_lines_alpha(context, legs, warning_color, self.LINE_WIDTH_LEG, self.LINE_ALPHA)
|
||||
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)]
|
||||
_stroke_lines_alpha(context, arc_segments, warning_color, self.LINE_WIDTH_ARC, self.LINE_ALPHA)
|
||||
elif geom.get("invalid_axes"):
|
||||
axes = geom["invalid_axes"]
|
||||
segments = [(tuple(a), tuple(b)) for a, b in axes]
|
||||
_stroke_lines_alpha(context, segments, warning_color, self.LINE_WIDTH_ARC, self.LINE_ALPHA)
|
||||
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"])),
|
||||
]
|
||||
_stroke_lines_alpha(context, legs, leg_color, self.LINE_WIDTH_LEG, self.LINE_ALPHA)
|
||||
|
||||
arc = geom["arc"]
|
||||
if len(arc) >= 2:
|
||||
arc_segments = [(tuple(arc[i]), tuple(arc[i + 1])) for i in range(len(arc) - 1)]
|
||||
_stroke_lines_alpha(context, arc_segments, arc_color, self.LINE_WIDTH_ARC, self.LINE_ALPHA)
|
||||
|
||||
# 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"])),
|
||||
]
|
||||
_stroke_lines_alpha(context, construction, arc_color, self.LINE_WIDTH_CONSTRUCTION, self.CONSTRUCTION_ALPHA)
|
||||
|
||||
@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
|
||||
|
||||
|
||||
_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,8 +37,9 @@ 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
|
||||
from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin, PickTypeMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bonsai.bim.module.model.prop import BIMDoorProperties
|
||||
@@ -580,7 +581,7 @@ class _DoorEditMixin(FeatureModifierEditMixin):
|
||||
|
||||
@classmethod
|
||||
def _is_element_type(cls, element):
|
||||
return tool.Blender.Modifier.is_door(element)
|
||||
return tool.Parametric.is_door(element)
|
||||
|
||||
@classmethod
|
||||
def _get_props(cls, obj: bpy.types.Object):
|
||||
@@ -629,7 +630,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.Blender.Modifier.is_door(element):
|
||||
if not tool.Parametric.is_door(element):
|
||||
return
|
||||
props = tool.Model.get_door_props(obj)
|
||||
props.is_editing = False
|
||||
@@ -644,12 +645,8 @@ 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 = "Toggle Door Swing"
|
||||
bl_label = "Change Door Swing"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
flip_geometry: bpy.props.BoolProperty(name="Flip Geometry", default=False)
|
||||
@@ -660,6 +657,15 @@ 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)
|
||||
@@ -686,7 +692,7 @@ class ToggleDoorSwing(bpy.types.Operator, tool.Ifc.Operator):
|
||||
if not element:
|
||||
return {"CANCELLED"}
|
||||
|
||||
is_door = tool.Blender.Modifier.is_door(element)
|
||||
is_door = tool.Parametric.is_door(element)
|
||||
|
||||
if self.flip_geometry:
|
||||
tool.Geometry.flip_object(obj, self.flip_local_axes)
|
||||
@@ -700,20 +706,20 @@ class ToggleDoorSwing(bpy.types.Operator, tool.Ifc.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class CycleDoorType(bpy.types.Operator, tool.Ifc.Operator, gizmo.CycleTypeMixin):
|
||||
"""Cycle through available door types. Shift+click to cycle in reverse."""
|
||||
class PickDoorType(bpy.types.Operator, tool.Ifc.Operator, PickTypeMixin):
|
||||
"""Pick a door type from a popup menu."""
|
||||
|
||||
bl_idname = "bim.cycle_door_type"
|
||||
bl_label = "Cycle Door Type"
|
||||
bl_idname = "bim.pick_door_type"
|
||||
bl_label = "Pick Door Type"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
element_checker = "is_door"
|
||||
props_getter = "get_door_props"
|
||||
element_checker = tool.Parametric.is_door
|
||||
props_getter = tool.Model.get_door_props
|
||||
type_literal = tool.Model.DoorType
|
||||
type_attr = "door_type"
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
return self._cycle_type(context)
|
||||
return self._pick_type(context)
|
||||
|
||||
|
||||
class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
@@ -726,7 +732,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"
|
||||
cycle_type_operator = "bim.cycle_door_type"
|
||||
pick_type_operator = "bim.pick_door_type"
|
||||
|
||||
# Declarative dimension gizmo configuration with visibility and position
|
||||
# matrix_position lambdas replace the get_dimension_matrix_* methods
|
||||
@@ -833,14 +839,44 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
p.get_transom_window_center_z(),
|
||||
),
|
||||
),
|
||||
*WALL_OFFSET_GIZMO_CONFIGS,
|
||||
]
|
||||
|
||||
props_getter = "get_door_props"
|
||||
# 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
|
||||
gizmo_pref_name = "door"
|
||||
|
||||
@classmethod
|
||||
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
return tool.Blender.Modifier.is_door(element)
|
||||
return tool.Parametric.is_door(element)
|
||||
|
||||
def get_icon_y_extent(self, props: "BIMDoorProperties") -> tuple[float, float]:
|
||||
"""Get Y extents for door icon positioning.
|
||||
@@ -858,24 +894,20 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
return (furthest_y, furthest_y)
|
||||
|
||||
def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None:
|
||||
"""Create door-specific swing arc gizmos."""
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
inactive_color = prefs.decorator_color_background[:3]
|
||||
special_color = prefs.decorator_color_special[:3]
|
||||
"""Create one (main, flip) swing-arc pair per ``swing_arc_props`` entry.
|
||||
|
||||
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",
|
||||
)
|
||||
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."""
|
||||
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)
|
||||
|
||||
def _refresh_element_specific(
|
||||
self, context: bpy.types.Context, mw: Matrix, props: "BIMDoorProperties" # noqa: ARG002
|
||||
@@ -894,29 +926,23 @@ 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:
|
||||
"""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
|
||||
"""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
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
# 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)
|
||||
)
|
||||
@@ -41,8 +41,187 @@ from mathutils import Matrix, Vector
|
||||
|
||||
import bonsai.core.geometry
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim import decorator_cache
|
||||
from bonsai.bim.module.drawing.decoration import DecoratorData
|
||||
|
||||
# Multi-entry cache for the opening preview's dissolved-edges fallback.
|
||||
# Single-entry wouldn't fit: the draw handler iterates every active opening
|
||||
# per frame, each with its own mesh. Bumped wholesale on the shared
|
||||
# decorator-cache token (depsgraph / undo / redo / load), one slot per
|
||||
# (mesh.session_uid, angle_limit). Outlier vs. the per-object caches below —
|
||||
# consulted only on world-draw-data miss, so the global wipe rarely fires in
|
||||
# steady state and the simpler invalidation is enough.
|
||||
_dissolved_edges_cache: dict[
|
||||
tuple[int, float],
|
||||
tuple[list[Vector], list[tuple[int, int]]],
|
||||
] = {}
|
||||
_dissolved_edges_cache_token: int = -1
|
||||
|
||||
|
||||
def _get_cached_dissolved_edges(
|
||||
mesh: bpy.types.Mesh,
|
||||
angle_limit: float = radians(1.0),
|
||||
) -> tuple[list[Vector], list[tuple[int, int]]]:
|
||||
global _dissolved_edges_cache_token
|
||||
token = decorator_cache.get_decorator_cache_token()
|
||||
if token != _dissolved_edges_cache_token:
|
||||
_dissolved_edges_cache.clear()
|
||||
_dissolved_edges_cache_token = token
|
||||
key = (mesh.session_uid, angle_limit)
|
||||
cached = _dissolved_edges_cache.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
result = tool.Geometry.get_dissolved_edges(mesh, angle_limit=angle_limit)
|
||||
_dissolved_edges_cache[key] = result
|
||||
return result
|
||||
|
||||
|
||||
# Per-object epoch: bumped only when this specific object's transform or geometry
|
||||
# updates land in the depsgraph delta. Invalidation work scales with the number
|
||||
# of changed objects, not total scene size — moving one object leaves every
|
||||
# other entry valid. Bumped by the depsgraph handler below; cleared on
|
||||
# undo/redo/load alongside the cache dicts.
|
||||
_object_epochs: dict[int, int] = {}
|
||||
|
||||
|
||||
@bpy.app.handlers.persistent
|
||||
def _bump_object_epochs_for_decoration(*args) -> None:
|
||||
# depsgraph_update_post is called as (scene, depsgraph) in 4.x but the
|
||||
# *args signature follows decorator_cache's defensive idiom.
|
||||
depsgraph = args[1] if len(args) >= 2 else None
|
||||
if depsgraph is None or not hasattr(depsgraph, "updates"):
|
||||
return
|
||||
for u in depsgraph.updates:
|
||||
if not isinstance(u.id, bpy.types.Object):
|
||||
continue
|
||||
if not (u.is_updated_geometry or u.is_updated_transform):
|
||||
continue
|
||||
# u.id is the evaluated COW copy; the cache keys are written from the
|
||||
# original Object (read by the draw handler), and session_uid can
|
||||
# differ across the COW boundary. Resolve to the original before keying.
|
||||
original = getattr(u.id, "original", u.id)
|
||||
if original is None:
|
||||
continue
|
||||
uid = original.session_uid
|
||||
_object_epochs[uid] = _object_epochs.get(uid, 0) + 1
|
||||
|
||||
|
||||
@bpy.app.handlers.persistent
|
||||
def _clear_decoration_caches_globally(*args) -> None:
|
||||
# Undo/redo/load: depsgraph deltas can't be trusted to describe the
|
||||
# transition, so wipe every per-object cache state.
|
||||
_object_epochs.clear()
|
||||
_world_draw_data_cache.clear()
|
||||
_batch_cache.clear()
|
||||
|
||||
|
||||
def _decoration_invalidation_hooks() -> tuple:
|
||||
return (
|
||||
bpy.app.handlers.undo_post,
|
||||
bpy.app.handlers.redo_post,
|
||||
bpy.app.handlers.load_post,
|
||||
)
|
||||
|
||||
|
||||
def install_decoration_cache_handlers() -> None:
|
||||
if _bump_object_epochs_for_decoration not in bpy.app.handlers.depsgraph_update_post:
|
||||
bpy.app.handlers.depsgraph_update_post.append(_bump_object_epochs_for_decoration)
|
||||
for hook in _decoration_invalidation_hooks():
|
||||
if _clear_decoration_caches_globally not in hook:
|
||||
hook.append(_clear_decoration_caches_globally)
|
||||
|
||||
|
||||
def uninstall_decoration_cache_handlers() -> None:
|
||||
try:
|
||||
bpy.app.handlers.depsgraph_update_post.remove(_bump_object_epochs_for_decoration)
|
||||
except ValueError:
|
||||
pass
|
||||
for hook in _decoration_invalidation_hooks():
|
||||
try:
|
||||
hook.remove(_clear_decoration_caches_globally)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
# Per-object world-space draw payload: line_verts (dissolved or ios_edges-filtered),
|
||||
# verts (full mesh, indexed by loop_triangles), edges_indices, tris. Entries are
|
||||
# (epoch, payload) tuples; lookup compares epoch to _object_epochs[uid], so a
|
||||
# stale entry for an object that didn't change since the last build still hits.
|
||||
_world_draw_data_cache: dict[
|
||||
int,
|
||||
tuple[
|
||||
int,
|
||||
tuple[
|
||||
list[tuple[float, float, float]],
|
||||
list[tuple[float, float, float]],
|
||||
list[tuple[int, int]],
|
||||
list[tuple[int, ...]],
|
||||
],
|
||||
],
|
||||
] = {}
|
||||
|
||||
|
||||
def _get_cached_world_draw_data(
|
||||
obj: bpy.types.Object,
|
||||
) -> tuple[
|
||||
list[tuple[float, float, float]],
|
||||
list[tuple[float, float, float]],
|
||||
list[tuple[int, int]],
|
||||
list[tuple[int, ...]],
|
||||
]:
|
||||
uid = obj.session_uid
|
||||
epoch = _object_epochs.get(uid, 0)
|
||||
entry = _world_draw_data_cache.get(uid)
|
||||
if entry is not None and entry[0] == epoch:
|
||||
return entry[1]
|
||||
|
||||
mw = obj.matrix_world
|
||||
verts = [tuple(mw @ v.co) for v in obj.data.vertices]
|
||||
obj.data.calc_loop_triangles()
|
||||
tris = [tuple(t.vertices) for t in obj.data.loop_triangles]
|
||||
|
||||
ios_edges_attribute = obj.data.attributes.get("ios_edges")
|
||||
if ios_edges_attribute:
|
||||
# Loader-curated edges: read the attribute aligned with bm.edges order.
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(obj.data)
|
||||
edges_indices = [
|
||||
tuple(v.index for v in e.verts) for i, e in enumerate(bm.edges) if ios_edges_attribute.data[i].value
|
||||
]
|
||||
bm.free()
|
||||
line_verts = verts
|
||||
else:
|
||||
dissolved, edges_indices = _get_cached_dissolved_edges(obj.data)
|
||||
line_verts = [tuple(mw @ v) for v in dissolved]
|
||||
|
||||
result = (line_verts, verts, edges_indices, tris)
|
||||
_world_draw_data_cache[uid] = (epoch, result)
|
||||
return result
|
||||
|
||||
|
||||
# GPUBatch cache: skip per-frame batch_for_shader. Entries are (epoch, batch);
|
||||
# lookup compares epoch to _object_epochs[uid] so other objects' batches stay
|
||||
# alive when one object's depsgraph delta bumps only its own epoch. The cached
|
||||
# batches reference GPU-side buffers tied to Blender's built-in shaders, which
|
||||
# are themselves cached by name (gpu.shader.from_builtin returns the same
|
||||
# handle each call), so they stay drawable across frames.
|
||||
_batch_cache: dict[tuple[int, str], tuple[int, "gpu.types.GPUBatch"]] = {}
|
||||
|
||||
|
||||
def _get_cached_batch_or_none(cache_key: tuple[int, str]) -> "gpu.types.GPUBatch | None":
|
||||
uid = cache_key[0]
|
||||
epoch = _object_epochs.get(uid, 0)
|
||||
entry = _batch_cache.get(cache_key)
|
||||
if entry is not None and entry[0] == epoch:
|
||||
return entry[1]
|
||||
return None
|
||||
|
||||
|
||||
def _store_batch_in_cache(cache_key: tuple[int, str], batch: "gpu.types.GPUBatch") -> None:
|
||||
uid = cache_key[0]
|
||||
epoch = _object_epochs.get(uid, 0)
|
||||
_batch_cache[cache_key] = (epoch, batch)
|
||||
|
||||
|
||||
class FilledOpeningGenerator:
|
||||
def generate(
|
||||
@@ -50,9 +229,15 @@ 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()
|
||||
@@ -74,7 +259,7 @@ class FilledOpeningGenerator:
|
||||
should_set_z_level = False
|
||||
|
||||
# Sometimes, the voided_obj may be an aggregate, which won't have any representation.
|
||||
if voided_obj.data:
|
||||
if not preserve_placement and 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()
|
||||
@@ -558,6 +743,29 @@ 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"
|
||||
@@ -941,7 +1149,6 @@ class SelectBoolean(Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
# TODO: merge with ProfileDecorator?
|
||||
class DecorationsHandler:
|
||||
installed = None
|
||||
|
||||
@@ -951,6 +1158,7 @@ class DecorationsHandler:
|
||||
cls.uninstall()
|
||||
handler = cls()
|
||||
cls.installed = SpaceView3D.draw_handler_add(handler, (context,), "WINDOW", "POST_VIEW")
|
||||
install_decoration_cache_handlers()
|
||||
|
||||
@classmethod
|
||||
def uninstall(cls):
|
||||
@@ -959,15 +1167,46 @@ class DecorationsHandler:
|
||||
except ValueError:
|
||||
pass
|
||||
cls.installed = None
|
||||
uninstall_decoration_cache_handlers()
|
||||
|
||||
def draw_batch(self, shader_type, content_pos, color, indices=None):
|
||||
def _get_or_build_batch(self, shader, shader_type, content_pos, indices=None, cache_key=None):
|
||||
if cache_key is not None:
|
||||
cached = _get_cached_batch_or_none(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
|
||||
return
|
||||
shader = self.line_shader if shader_type == "LINES" else self.shader
|
||||
return None
|
||||
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
|
||||
if cache_key is not None:
|
||||
_store_batch_in_cache(cache_key, batch)
|
||||
return batch
|
||||
|
||||
def draw_batch(self, shader_type, content_pos, color, indices=None, cache_key=None):
|
||||
shader = self.line_shader if shader_type == "LINES" else self.shader
|
||||
batch = self._get_or_build_batch(shader, shader_type, content_pos, indices, cache_key=cache_key)
|
||||
if batch is None:
|
||||
return
|
||||
shader.uniform_float("color", color)
|
||||
batch.draw(shader)
|
||||
|
||||
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
|
||||
original_depth_test = gpu.state.depth_test_get()
|
||||
gpu.state.depth_test_set("LESS_EQUAL")
|
||||
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):
|
||||
props = tool.Model.get_model_props()
|
||||
if not props.openings:
|
||||
@@ -1039,23 +1278,20 @@ 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)
|
||||
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:
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(obj.data)
|
||||
|
||||
verts = [tuple(obj.matrix_world @ v.co) for v in bm.verts]
|
||||
if ios_edges_attribute := obj.data.attributes.get("ios_edges"):
|
||||
edges = [e for i, e in enumerate(bm.edges) if ios_edges_attribute.data[i].value]
|
||||
else:
|
||||
edges = bm.edges
|
||||
edges_indices = [tuple([v.index for v in e.verts]) for e in edges]
|
||||
|
||||
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
|
||||
self.draw_batch("LINES", verts, color, edges_indices)
|
||||
|
||||
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)
|
||||
self._draw_lines_with_occlusion(line_verts, color, edges_indices, cache_key=(obj.session_uid, "lines"))
|
||||
self.draw_batch(
|
||||
"TRIS",
|
||||
verts,
|
||||
transparent_color(special_elements_color),
|
||||
tris,
|
||||
cache_key=(obj.session_uid, "tris"),
|
||||
)
|
||||
|
||||
if "HalfSpaceSolid" in obj.name:
|
||||
# Arrow shape
|
||||
@@ -1069,7 +1305,4 @@ class DecorationsHandler:
|
||||
]
|
||||
edges = [(0, 1), (1, 2), (1, 3), (1, 4), (1, 5)]
|
||||
color = selected_elements_color if obj in context.selected_objects else special_elements_color
|
||||
self.draw_batch("LINES", verts, color, edges)
|
||||
|
||||
if obj.mode != "EDIT":
|
||||
bm.free()
|
||||
self._draw_lines_with_occlusion(verts, color, edges, cache_key=(obj.session_uid, "arrow"))
|
||||
|
||||
@@ -75,6 +75,7 @@ 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,8 +60,12 @@ 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."""
|
||||
preview = getattr(context.scene, "BIMPreviewProperties", None)
|
||||
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)
|
||||
return getattr(preview, attr, None) if preview is not None else None
|
||||
|
||||
|
||||
@@ -74,6 +78,17 @@ 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
|
||||
|
||||
@@ -103,8 +103,12 @@ def update_type_page(self: "BIMModelProperties", context: bpy.types.Context) ->
|
||||
|
||||
|
||||
def update_relating_array_from_object(self: "BIMArrayProperties", context: bpy.types.Context) -> None:
|
||||
bpy.ops.bim.enable_editing_array(item=self.is_editing)
|
||||
return
|
||||
# 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)
|
||||
|
||||
|
||||
def is_object_array_applicable(self: "BIMArrayProperties", obj: bpy.types.Object) -> bool:
|
||||
@@ -397,8 +401,13 @@ class BIMModelProperties(PropertyGroup):
|
||||
|
||||
|
||||
class BIMArrayProperties(PropertyGroup):
|
||||
is_editing: bpy.props.IntProperty(
|
||||
default=-1, description="Currently edited array index. -1 if not in array editing mode."
|
||||
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.",
|
||||
)
|
||||
count: bpy.props.IntProperty(name="Count", default=0, min=0)
|
||||
x: bpy.props.FloatProperty(name="X", default=0, subtype="DISTANCE")
|
||||
@@ -414,6 +423,15 @@ 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",
|
||||
@@ -422,13 +440,15 @@ class BIMArrayProperties(PropertyGroup):
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
is_editing: int
|
||||
is_editing: bool
|
||||
editing_item_index: 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]
|
||||
|
||||
@@ -1902,3 +1922,65 @@ 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 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``."""
|
||||
|
||||
wall_fillet: bpy.props.PointerProperty(type=BIMWallFilletPreviewProperties)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
wall_fillet: BIMWallFilletPreviewProperties
|
||||
|
||||
@@ -415,7 +415,7 @@ class _RailingEditMixin(PathPreservingEditMixin):
|
||||
|
||||
@classmethod
|
||||
def _is_element_type(cls, element):
|
||||
return tool.Blender.Modifier.is_railing(element)
|
||||
return tool.Parametric.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 cos, pi, radians, tan
|
||||
from typing import Any, Literal, Union
|
||||
from math import atan2, cos, degrees, pi, radians, tan
|
||||
from typing import Any, ClassVar, Literal, Union
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
@@ -32,9 +32,11 @@ 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 PathPreservingEditMixin
|
||||
from bonsai.bim.parametric_lifecycle import CycleTypeMixin, PathPreservingEditMixin
|
||||
|
||||
# reference:
|
||||
# https://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcRoof.htm
|
||||
@@ -211,7 +213,13 @@ 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]
|
||||
new_faces = [bm.faces.new([new_verts[vi] for vi in face]) for face in faces]
|
||||
# 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)]
|
||||
|
||||
if mode == "HEIGHT": # Calculate the angle we ended up with.
|
||||
new_faces[0].normal_update()
|
||||
@@ -397,6 +405,11 @@ 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
|
||||
|
||||
|
||||
@@ -618,7 +631,7 @@ class _RoofEditMixin(PathPreservingEditMixin):
|
||||
|
||||
@classmethod
|
||||
def _is_element_type(cls, element):
|
||||
return tool.Blender.Modifier.is_roof(element)
|
||||
return tool.Parametric.is_roof(element)
|
||||
|
||||
@classmethod
|
||||
def _get_props(cls, obj: bpy.types.Object):
|
||||
@@ -636,32 +649,142 @@ 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)
|
||||
|
||||
class EnableEditingRoof(_RoofEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.enable_editing_roof"
|
||||
bl_label = "Enable Editing Roof"
|
||||
|
||||
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"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def _execute(self, context):
|
||||
return self._enable_targets(context)
|
||||
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)
|
||||
|
||||
|
||||
class CancelEditingRoof(_RoofEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.cancel_editing_roof"
|
||||
bl_label = "Cancel Editing Roof"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
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"}
|
||||
|
||||
def _execute(self, context):
|
||||
return self._cancel_targets(context)
|
||||
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"
|
||||
|
||||
# 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.
|
||||
),
|
||||
]
|
||||
|
||||
class FinishEditingRoof(_RoofEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.finish_editing_roof"
|
||||
bl_label = "Finish Editing Roof"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
props_getter = tool.Model.get_roof_props
|
||||
gizmo_pref_name = "roof"
|
||||
|
||||
def _execute(self, context):
|
||||
return self._finish_targets(context)
|
||||
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)
|
||||
|
||||
|
||||
class EnableEditingRoofPath(bpy.types.Operator, tool.Ifc.Operator):
|
||||
|
||||
@@ -31,7 +31,13 @@ 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 DimensionGizmoConfig
|
||||
from bonsai.bim.module.drawing.gizmos import (
|
||||
COLOR_GREEN,
|
||||
COLOR_RED,
|
||||
DimensionGizmoConfig,
|
||||
IconSlot,
|
||||
)
|
||||
from bonsai.bim.parametric_lifecycle import IntegerInputDialogMixin, PickTypeMixin
|
||||
from bonsai.tool.numeric_input import (
|
||||
IntegerInputState,
|
||||
run_integer_input_modal,
|
||||
@@ -39,7 +45,7 @@ from bonsai.tool.numeric_input import (
|
||||
)
|
||||
|
||||
V_ = tool.Blender.V_
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from bmesh.types import BMVert
|
||||
from bpy.props import IntProperty
|
||||
@@ -378,6 +384,20 @@ 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."""
|
||||
|
||||
@@ -423,20 +443,20 @@ class SetStairTreads(bpy.types.Operator):
|
||||
return f"Number of Treads: {input_str}_{validity} | Enter to confirm, Esc to cancel"
|
||||
|
||||
|
||||
class CycleStairType(bpy.types.Operator, gizmo.CycleTypeMixin):
|
||||
"""Cycle through stair types. Shift+click to cycle in reverse."""
|
||||
class PickStairType(bpy.types.Operator, PickTypeMixin):
|
||||
"""Pick a stair type from a popup menu."""
|
||||
|
||||
bl_idname = "bim.cycle_stair_type"
|
||||
bl_label = "Cycle Stair Type"
|
||||
bl_idname = "bim.pick_stair_type"
|
||||
bl_label = "Pick Stair Type"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
props_getter = "get_stair_props"
|
||||
props_getter = tool.Model.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._cycle_type(context)
|
||||
return self._pick_type(context)
|
||||
|
||||
|
||||
# Tread run accessors - callbacks that delegate to BIMStairProperties methods
|
||||
@@ -462,20 +482,47 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
bl_region_type = "WINDOW"
|
||||
bl_options = {"3D", "PERSISTENT"}
|
||||
|
||||
# === 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
|
||||
# === 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.
|
||||
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"
|
||||
cycle_type_operator = "bim.cycle_stair_type"
|
||||
pick_type_operator = "bim.pick_stair_type"
|
||||
|
||||
def get_icon_y_extent(self, props: "BIMStairProperties") -> tuple[float, float]:
|
||||
"""Get Y extents for stair icon positioning.
|
||||
@@ -580,35 +627,36 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
]
|
||||
|
||||
# Metadata-driven dispatch for props and preferences
|
||||
props_getter = "get_stair_props"
|
||||
props_getter = tool.Model.get_stair_props
|
||||
gizmo_pref_name = "stair"
|
||||
|
||||
@classmethod
|
||||
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
return tool.Blender.Modifier.is_stair(element)
|
||||
return tool.Parametric.is_stair(element)
|
||||
|
||||
def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None:
|
||||
"""Create stair-specific icon gizmos (lock, plus, minus)."""
|
||||
self.lock_gizmo = self.create_icon_gizmo(
|
||||
"VIEW3D_GT_lock",
|
||||
self.COLOR_BLUE,
|
||||
"""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",
|
||||
prop_path="BIMStairProperties.total_length_lock",
|
||||
self.COLOR_BLUE,
|
||||
property_name="total_length_lock",
|
||||
)
|
||||
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
|
||||
)
|
||||
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")
|
||||
|
||||
def _refresh_element_specific(
|
||||
self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties" # noqa: ARG002
|
||||
@@ -620,30 +668,43 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
self.update_tread_count_gizmos(props)
|
||||
|
||||
def update_lock_gizmo(self, props: "BIMStairProperties") -> None:
|
||||
"""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
|
||||
"""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
|
||||
|
||||
def update_tread_lock_gizmo(self, props: "BIMStairProperties") -> None:
|
||||
"""Update visibility of tread lock gizmo. Positioning is handled in _update_editing_icon_positions."""
|
||||
if not hasattr(self, "tread_lock_gizmo"):
|
||||
"""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"):
|
||||
return
|
||||
gizmo_prefs = self.get_gizmo_prefs()
|
||||
self.update_gizmo_visibility(self.tread_lock_gizmo, props.is_editing, gizmo_prefs.lock)
|
||||
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
|
||||
|
||||
def update_tread_count_gizmos(self, props: "BIMStairProperties") -> None:
|
||||
"""Update visibility of +/- tread count gizmos. Positioning is handled in _update_editing_icon_positions."""
|
||||
"""Update visibility of the +/- tread count gizmos and the ``xN``
|
||||
label. Positioning is handled in ``_update_editing_icon_positions``."""
|
||||
if not hasattr(self, "plus_gizmo") or not hasattr(self, "minus_gizmo"):
|
||||
return
|
||||
gizmo_prefs = self.get_gizmo_prefs()
|
||||
self.update_gizmo_visibility(self.plus_gizmo, props.is_editing, gizmo_prefs.plus)
|
||||
self.update_gizmo_visibility(self.plus_gizmo, props.is_editing)
|
||||
# Minus has additional condition: number_of_treads > 1
|
||||
self.update_gizmo_visibility(
|
||||
self.minus_gizmo, props.is_editing and props.number_of_treads > 1, gizmo_prefs.minus
|
||||
)
|
||||
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)
|
||||
|
||||
def _update_dimension_gizmo_positions(
|
||||
self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties" # noqa: ARG002
|
||||
@@ -721,10 +782,12 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
billboard_rot: Matrix,
|
||||
total_run: float,
|
||||
) -> None:
|
||||
"""Update lock gizmo position based on Y view direction."""
|
||||
"""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."""
|
||||
y_pos = self.get_y_position_for_view(props, viewing_from_negative_y, use_offset=True)
|
||||
self.set_icon_gizmo_position(
|
||||
"lock_gizmo",
|
||||
self.set_icon_gizmo_pair_position(
|
||||
"total_length_lock_open_gizmo",
|
||||
"total_length_lock_closed_gizmo",
|
||||
mw,
|
||||
total_run + self.ICON_Z_OFFSET,
|
||||
y_pos,
|
||||
@@ -736,30 +799,47 @@ 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:
|
||||
"""Update editing icon positions, flipping Y based on viewing angle."""
|
||||
"""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."""
|
||||
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_position(
|
||||
"tread_lock_gizmo",
|
||||
self.set_icon_gizmo_pair_position(
|
||||
"tread_lock_open_gizmo",
|
||||
"tread_lock_closed_gizmo",
|
||||
mw,
|
||||
self.ICON_TREAD_LOCK_X,
|
||||
slot_x["tread_lock"],
|
||||
y_pos,
|
||||
icon_z - self.EDITING_ICON_SCALE / 2,
|
||||
billboard_rot,
|
||||
scale=self.EDITING_ICON_SCALE,
|
||||
)
|
||||
self.set_icon_gizmo_position(
|
||||
"plus_gizmo", mw, self.ICON_PLUS_X, y_pos, icon_z, billboard_rot, scale=self.ICON_PLUS_MINUS_SCALE
|
||||
"plus_gizmo", mw, slot_x["plus"], y_pos, icon_z, billboard_rot, scale=self.ICON_PLUS_MINUS_SCALE
|
||||
)
|
||||
self.set_icon_gizmo_position(
|
||||
"minus_gizmo", mw, self.ICON_MINUS_X, y_pos, icon_z, billboard_rot, scale=self.ICON_PLUS_MINUS_SCALE
|
||||
"minus_gizmo", mw, slot_x["minus"], 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,
|
||||
)
|
||||
|
||||
@@ -24,6 +24,7 @@ from typing import TYPE_CHECKING, Any
|
||||
import bpy
|
||||
from bpy.types import Panel
|
||||
|
||||
import ifcopenshell.util.unit
|
||||
import bonsai.bim
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.helper import prop_with_search
|
||||
@@ -237,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.is_editing == i:
|
||||
if props.editing_item_index == i:
|
||||
row = box.row(align=True)
|
||||
row.prop(props, "count", icon="MOD_ARRAY")
|
||||
row.operator("bim.edit_array", icon="CHECKMARK", text="").item = i
|
||||
row.operator("bim.disable_editing_array", icon="CANCEL", text="")
|
||||
row.operator("bim.finish_editing_array", icon="CHECKMARK", text="")
|
||||
row.operator("bim.cancel_editing_array", icon="CANCEL", text="")
|
||||
row = box.row(align=True)
|
||||
row.prop(props, "method")
|
||||
row = box.row(align=True)
|
||||
@@ -303,6 +304,8 @@ class BIM_PT_stair(bpy.types.Panel):
|
||||
row = self.layout.row(align=True)
|
||||
row.label(text="Stair parameters", icon="IPO_CONSTANT")
|
||||
|
||||
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
|
||||
if props.is_editing:
|
||||
calculated_params = tool.Model.get_active_stair_calculated_params()
|
||||
row = self.layout.row(align=True)
|
||||
@@ -322,16 +325,25 @@ class BIM_PT_stair(bpy.types.Panel):
|
||||
row.label(text=f"{prop_name}:")
|
||||
row = self.layout.row(align=True)
|
||||
for prop_value_item in prop_value:
|
||||
row.label(text=str(prop_value_item))
|
||||
if isinstance(prop_value_item, float):
|
||||
row.label(text=tool.Unit.format_distance(prop_value_item * si_conversion))
|
||||
else:
|
||||
row.label(text=str(prop_value_item))
|
||||
else:
|
||||
row.label(text=prop_name)
|
||||
row.label(text=str(prop_value))
|
||||
if isinstance(prop_value, float):
|
||||
row.label(text=tool.Unit.format_distance(prop_value * si_conversion))
|
||||
else:
|
||||
row.label(text=str(prop_value))
|
||||
|
||||
# calculated properties
|
||||
for prop_name, prop_value in calculated_params.items():
|
||||
row = self.layout.row(align=True)
|
||||
row.label(text=prop_name)
|
||||
row.label(text=str(prop_value))
|
||||
if isinstance(prop_value, float):
|
||||
row.label(text=tool.Unit.format_distance(prop_value * si_conversion))
|
||||
else:
|
||||
row.label(text=str(prop_value))
|
||||
else:
|
||||
row = self.layout.row()
|
||||
row.label(text="No Stair Found")
|
||||
@@ -353,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.Blender.Modifier.is_wall(element)
|
||||
return bool(element) and tool.Parametric.is_wall(element)
|
||||
|
||||
def draw(self, context):
|
||||
obj = context.active_object
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,279 @@
|
||||
# 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,7 +39,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.parametric_lifecycle import FeatureModifierEditMixin
|
||||
from bonsai.bim.module.model.wall_offset_gizmos import WALL_OFFSET_GIZMO_CONFIGS
|
||||
from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin, PickTypeMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bonsai.bim.module.model.prop import BIMWindowProperties
|
||||
@@ -491,7 +492,7 @@ class _WindowEditMixin(FeatureModifierEditMixin):
|
||||
|
||||
@classmethod
|
||||
def _is_element_type(cls, element):
|
||||
return tool.Blender.Modifier.is_window(element)
|
||||
return tool.Parametric.is_window(element)
|
||||
|
||||
@classmethod
|
||||
def _get_props(cls, obj: bpy.types.Object):
|
||||
@@ -551,20 +552,20 @@ class RemoveWindow(bpy.types.Operator, tool.Ifc.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class CycleWindowType(bpy.types.Operator, tool.Ifc.Operator, gizmo.CycleTypeMixin):
|
||||
"""Cycle through available window types. Shift+click to cycle in reverse."""
|
||||
class PickWindowType(bpy.types.Operator, tool.Ifc.Operator, PickTypeMixin):
|
||||
"""Pick a window type from a popup menu."""
|
||||
|
||||
bl_idname = "bim.cycle_window_type"
|
||||
bl_label = "Cycle Window Type"
|
||||
bl_idname = "bim.pick_window_type"
|
||||
bl_label = "Pick Window Type"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
element_checker = "is_window"
|
||||
props_getter = "get_window_props"
|
||||
element_checker = tool.Parametric.is_window
|
||||
props_getter = tool.Model.get_window_props
|
||||
type_literal = tool.Model.WindowType
|
||||
type_attr = "window_type"
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
return self._cycle_type(context)
|
||||
return self._pick_type(context)
|
||||
|
||||
|
||||
# Frame accessor factory - creates callbacks that delegate to BIMWindowProperties methods
|
||||
@@ -602,7 +603,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"
|
||||
cycle_type_operator = "bim.cycle_window_type"
|
||||
pick_type_operator = "bim.pick_window_type"
|
||||
|
||||
# matrix_position lambdas replace the get_dimension_matrix_* methods
|
||||
dimension_gizmo_props = [
|
||||
@@ -743,14 +744,15 @@ 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 = "get_window_props"
|
||||
props_getter = tool.Model.get_window_props
|
||||
gizmo_pref_name = "window"
|
||||
|
||||
@classmethod
|
||||
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
return tool.Blender.Modifier.is_window(element)
|
||||
return tool.Parametric.is_window(element)
|
||||
|
||||
def get_icon_y_extent(self, props: "BIMWindowProperties") -> tuple[float, float]:
|
||||
"""Get Y extents for window icon positioning.
|
||||
|
||||
@@ -969,7 +969,9 @@ 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):
|
||||
@@ -1294,9 +1296,15 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bpy.ops.bim.generate_space()
|
||||
return
|
||||
if self.active_material_usage == "LAYER2":
|
||||
bpy.ops.bim.recalculate_wall()
|
||||
if element and tool.Model.has_underside_connection(element):
|
||||
bpy.ops.bim.regenerate_wall_to_underside()
|
||||
else:
|
||||
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":
|
||||
@@ -1436,10 +1444,7 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bpy.ops.bim.enable_editing_extrusion_axis()
|
||||
|
||||
def hotkey_A_O(self):
|
||||
if tool.Model.get_model_props().openings:
|
||||
bpy.ops.bim.edit_openings(apply_all=True)
|
||||
else:
|
||||
bpy.ops.bim.show_openings()
|
||||
bpy.ops.bim.toggle_host_openings()
|
||||
|
||||
def hotkey_C_E(self):
|
||||
if not bpy.context.selected_objects:
|
||||
|
||||
@@ -63,6 +63,7 @@ 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
|
||||
@@ -963,11 +964,11 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
|
||||
use_relative_path: bpy.props.BoolProperty(
|
||||
name="Use Relative Path",
|
||||
description="Store the IFC project path relative to the .blend file. Requires .blend file to be saved",
|
||||
default=False,
|
||||
default=True,
|
||||
)
|
||||
should_start_fresh_session: bpy.props.BoolProperty(
|
||||
name="Should Start Fresh Session",
|
||||
description="Clear current Blender session before loading IFC. Not supported with 'Use Relative Path' option",
|
||||
description="Clear current Blender session before loading IFC",
|
||||
default=True,
|
||||
)
|
||||
import_without_ifc_data: bpy.props.BoolProperty(
|
||||
@@ -1075,9 +1076,6 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
|
||||
bpy.app.handlers.load_post.remove(load_handler)
|
||||
self.finish_loading_project(context)
|
||||
|
||||
if self.use_relative_path:
|
||||
self.should_start_fresh_session = False
|
||||
|
||||
if self.should_start_fresh_session:
|
||||
# WARNING: wm.read_homefile clears context which could lead to some
|
||||
# operators to fail:
|
||||
@@ -1142,8 +1140,6 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
|
||||
return ImportHelper.invoke(self, context, event)
|
||||
|
||||
def draw(self, context):
|
||||
if self.use_relative_path:
|
||||
self.should_start_fresh_session = False
|
||||
self.layout.prop(self, "is_advanced")
|
||||
self.layout.prop(self, "should_start_fresh_session")
|
||||
self.layout.prop(self, "import_without_ifc_data")
|
||||
@@ -1874,7 +1870,7 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
|
||||
json_version: bpy.props.EnumProperty(items=[("4", "4", ""), ("5a", "5a", "")], name="IFC JSON Version")
|
||||
json_compact: bpy.props.BoolProperty(name="Export Compact IFCJSON", default=False)
|
||||
should_save_as: bpy.props.BoolProperty(name="Should Save As", default=False, options={"HIDDEN"})
|
||||
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False)
|
||||
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=True)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
filter_glob: str
|
||||
@@ -1936,6 +1932,10 @@ 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).
|
||||
|
||||
@@ -444,7 +444,7 @@ class BIMProjectProperties(PropertyGroup):
|
||||
items=get_parent_libaries,
|
||||
)
|
||||
|
||||
use_relative_project_path: BoolProperty(name="Use Relative Project Path", default=False)
|
||||
use_relative_project_path: BoolProperty(name="Use Relative Project Path", default=True)
|
||||
should_save_metadata_for_this_file: BoolProperty(
|
||||
name="Save Session Data for This File",
|
||||
description="Enable saving session data (window layout, settings) to a metadata blend file for this specific IFC file",
|
||||
|
||||
@@ -302,6 +302,15 @@ class SelectSimilarContainer(bpy.types.Operator):
|
||||
is_recursive=self.is_recursive,
|
||||
)
|
||||
self.is_recursive = True # <-- forcibly reset
|
||||
|
||||
element = tool.Ifc.get_entity(context.active_object)
|
||||
if element:
|
||||
container = tool.Spatial.get_container(element)
|
||||
if container:
|
||||
result = f'location="{container.Name}"'
|
||||
bpy.context.window_manager.clipboard = result
|
||||
self.report({"INFO"}, f"({result}) was copied to the clipboard.")
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
|
||||
@@ -151,7 +151,8 @@ class BIM_PT_type_attributes(Panel):
|
||||
row = layout.row(align=True)
|
||||
row.label(text=attribute["name"])
|
||||
value = get_display_value(attribute["value"])
|
||||
row.label(text=value)
|
||||
op = row.operator("bim.select_similar", text=value, icon="NONE", emboss=False)
|
||||
op.key = "type." + attribute["name"]
|
||||
|
||||
|
||||
def add_object_button(self, context):
|
||||
|
||||
@@ -36,9 +36,17 @@ 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."
|
||||
"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."
|
||||
)
|
||||
|
||||
# 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:
|
||||
@@ -46,6 +54,10 @@ 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]
|
||||
@@ -68,7 +80,12 @@ 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)
|
||||
FilledOpeningGenerator().generate(
|
||||
obj2,
|
||||
obj1,
|
||||
target=obj2.matrix_world.translation,
|
||||
preserve_placement=self.preserve_placement,
|
||||
)
|
||||
continue
|
||||
elif element1.is_a("IfcOpeningElement") or element2.is_a("IfcOpeningElement"):
|
||||
if element1.is_a("IfcOpeningElement"): # Reassign an opening to another element.
|
||||
|
||||
@@ -219,7 +219,7 @@ class SelectIfcFile(bpy.types.Operator, IFCFileSelector, ImportHelper):
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = f"Select a different IFC file.\n{tool.Blender.operator_invoke_filepath_hotkeys_description}"
|
||||
filter_glob: bpy.props.StringProperty(default="*.ifc;*.ifczip;*.ifcxml", options={"HIDDEN"})
|
||||
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False)
|
||||
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=True)
|
||||
filename_ext = ".ifc"
|
||||
|
||||
def execute(self, context):
|
||||
|
||||
@@ -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 in `test/bim/test_parametric_registry.py`.
|
||||
contract tests.
|
||||
|
||||
This module hosts operator-side mixins that import ``bonsai.tool`` freely.
|
||||
The lightweight parametric registry consumed at addon-enable time must stay
|
||||
@@ -71,18 +71,16 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
from typing import ClassVar, get_args
|
||||
|
||||
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.
|
||||
@@ -379,6 +377,202 @@ 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``
|
||||
|
||||
+26
-240
@@ -94,7 +94,10 @@ class IFCFileSelector:
|
||||
filepath = self.get_filepath_abs()
|
||||
|
||||
if self.use_relative_path:
|
||||
filepath = filepath.relative_to(bpy.path.abspath("//"))
|
||||
try:
|
||||
filepath = filepath.relative_to(bpy.path.abspath("//"))
|
||||
except ValueError:
|
||||
pass # IFC file is not under the blend directory; keep absolute path
|
||||
return filepath.as_posix().replace("\\", "/")
|
||||
|
||||
def draw(self, context: bpy.types.Context) -> None:
|
||||
@@ -275,202 +278,33 @@ 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):
|
||||
"""Property group for all gizmo visibility settings."""
|
||||
"""Aggregator for parametric gizmo visibility settings. One flat bool per
|
||||
parametric feature; controls whether that feature's gizmo group polls
|
||||
visible in the viewport."""
|
||||
|
||||
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: bpy.props.PointerProperty(type=GizmoPreferencesDoor)
|
||||
window: bpy.props.PointerProperty(type=GizmoPreferencesWindow)
|
||||
stair: bpy.props.PointerProperty(type=GizmoPreferencesStair)
|
||||
wall: bpy.props.PointerProperty(type=GizmoPreferencesWall)
|
||||
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)
|
||||
wall: BoolProperty(name="Wall", default=True)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
draw_gizmos_in_3d_viewport: bool
|
||||
door: GizmoPreferencesDoor
|
||||
window: GizmoPreferencesWindow
|
||||
stair: GizmoPreferencesStair
|
||||
wall: GizmoPreferencesWall
|
||||
door: bool
|
||||
window: bool
|
||||
stair: bool
|
||||
railing: bool
|
||||
roof: bool
|
||||
array: bool
|
||||
wall: bool
|
||||
|
||||
|
||||
class DocPreferences(bpy.types.PropertyGroup):
|
||||
@@ -918,61 +752,13 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
|
||||
)
|
||||
|
||||
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()
|
||||
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"}),
|
||||
)
|
||||
annotations = type(self.gizmos).__annotations__
|
||||
for feature in tool.Parametric.EDIT_TYPES:
|
||||
if feature.name in annotations:
|
||||
box.prop(self.gizmos, feature.name)
|
||||
|
||||
def draw_model_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
|
||||
layout.prop(self, "occurrence_name_style")
|
||||
|
||||
@@ -93,6 +93,8 @@ def enter_aggregate_mode(
|
||||
aggregator: type[tool.Aggregate],
|
||||
obj: bpy.types.Object,
|
||||
):
|
||||
if not aggregator.get_aggregate_props().in_aggregate_mode:
|
||||
aggregator.save_previous_selection()
|
||||
aggregator.update_previous_aggregate_mode_state()
|
||||
if aggregator.get_higher_aggregate():
|
||||
aggregator.disable_aggregate_mode()
|
||||
@@ -107,6 +109,7 @@ def exit_aggregate_mode(aggregator: type[tool.Aggregate]):
|
||||
aggregator.enable_aggregate_mode(new_obj)
|
||||
else:
|
||||
aggregator.disable_aggregate_mode()
|
||||
aggregator.restore_previous_selection()
|
||||
|
||||
|
||||
class IncompatibleAggregateError(Exception):
|
||||
|
||||
@@ -161,23 +161,73 @@ 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_obj: bpy.types.Object,
|
||||
slab_objs: list[bpy.types.Object],
|
||||
wall_objs: list[bpy.types.Object],
|
||||
) -> None:
|
||||
if not (clip := model.get_slab_clipping_bmesh(slab_obj)):
|
||||
return # Nothing to clip?
|
||||
slab = ifc.get_entity(slab_obj)
|
||||
# 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 = []
|
||||
for obj in wall_objs:
|
||||
if ifc.is_moved(obj):
|
||||
geometry.run_edit_object_placement(obj=obj)
|
||||
wall = ifc.get_entity(obj)
|
||||
model.clip_wall_to_slab(wall, clip)
|
||||
model.connect_wall_to_slab(wall, slab)
|
||||
model.reload_body_representation(wall_objs)
|
||||
# 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)
|
||||
|
||||
|
||||
class RequireTwoWallsError(Exception):
|
||||
|
||||
@@ -20,8 +20,6 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
import ifcopenshell.util.element
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
@@ -58,31 +56,12 @@ 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 _has_material_styles(ifc, new):
|
||||
if not root.has_material_styles(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,10 +64,11 @@ 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, root_element)]:
|
||||
if products := [e for e in root_elements if spatial.can_contain(container, e)]:
|
||||
ifc.run("spatial.assign_container", products=products, relating_structure=container)
|
||||
for element in all_elements:
|
||||
collector.assign(ifc.get_object(element))
|
||||
if obj := ifc.get_object(element):
|
||||
collector.assign(obj)
|
||||
|
||||
|
||||
def enable_editing_container(spatial: type[tool.Spatial], obj: bpy.types.Object) -> None:
|
||||
|
||||
@@ -459,7 +459,6 @@ 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
|
||||
@@ -681,6 +680,9 @@ 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
|
||||
@@ -696,6 +698,7 @@ 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
|
||||
|
||||
|
||||
@@ -792,7 +795,7 @@ class Profile:
|
||||
@interface
|
||||
class Parametric:
|
||||
def get_geom_generation(cls) -> int: pass
|
||||
def refresh_post_commit(cls) -> None: pass
|
||||
def refresh_post_commit(cls, operator) -> None: pass
|
||||
|
||||
|
||||
@interface
|
||||
@@ -884,6 +887,7 @@ 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
|
||||
|
||||
@@ -205,6 +205,27 @@ class Aggregate(bonsai.core.tool.Aggregate):
|
||||
props.in_aggregate_mode = True
|
||||
return {"FINISHED"}
|
||||
|
||||
@classmethod
|
||||
def save_previous_selection(cls) -> None:
|
||||
props = cls.get_aggregate_props()
|
||||
props.previously_selected_objects.clear()
|
||||
for obj in bpy.context.selected_objects:
|
||||
entry = props.previously_selected_objects.add()
|
||||
entry.obj = obj
|
||||
|
||||
@classmethod
|
||||
def restore_previous_selection(cls) -> None:
|
||||
props = cls.get_aggregate_props()
|
||||
for obj in bpy.context.selected_objects:
|
||||
obj.select_set(False)
|
||||
for entry in props.previously_selected_objects:
|
||||
if entry.obj:
|
||||
try:
|
||||
entry.obj.select_set(True)
|
||||
except Exception:
|
||||
pass
|
||||
props.previously_selected_objects.clear()
|
||||
|
||||
@classmethod
|
||||
def disable_aggregate_mode(cls):
|
||||
context = bpy.context
|
||||
|
||||
@@ -229,15 +229,22 @@ class Blender(bonsai.core.tool.Blender):
|
||||
|
||||
@classmethod
|
||||
def get_active_object(cls, is_selected: bool = False) -> Union[bpy.types.Object, None]:
|
||||
"""Gets the active object
|
||||
"""Return the active object, or ``None`` when the current context
|
||||
exposes neither ``active_object`` nor a ``view_layer`` (stripped
|
||||
operator contexts).
|
||||
|
||||
:param is_selected: If true, the active object also needs to be selected.
|
||||
"""
|
||||
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
|
||||
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
|
||||
|
||||
@classmethod
|
||||
def get_selected_objects(cls, include_active: bool = True) -> set[bpy.types.Object]:
|
||||
@@ -880,19 +887,57 @@ 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": 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,
|
||||
"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),
|
||||
}
|
||||
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:
|
||||
@@ -1332,74 +1377,6 @@ 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
|
||||
@@ -2018,6 +1995,18 @@ 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]
|
||||
|
||||
@@ -135,6 +135,7 @@ class Collector(bonsai.core.tool.Collector):
|
||||
|
||||
if element.is_a("IfcFeatureElementSubtraction"):
|
||||
obj.display_type = "WIRE"
|
||||
obj.display.show_shadows = False
|
||||
|
||||
@classmethod
|
||||
def _create_project_child_collection(cls, name: str) -> bpy.types.Collection:
|
||||
|
||||
@@ -257,7 +257,13 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
break
|
||||
mesh = obj.data
|
||||
assert isinstance(mesh, bpy.types.Mesh)
|
||||
item = tool.Ifc.get().by_id(tool.Geometry.get_mesh_props(mesh).ifc_definition_id)
|
||||
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
|
||||
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)
|
||||
@@ -421,6 +427,29 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
bm.free()
|
||||
del mesh["ios_edges"]
|
||||
|
||||
@classmethod
|
||||
def get_dissolved_edges(
|
||||
cls,
|
||||
mesh: bpy.types.Mesh,
|
||||
angle_limit: float = radians(1.0),
|
||||
) -> tuple[list[Vector], list[tuple[int, int]]]:
|
||||
# Read-only on `mesh`: builds a throwaway bmesh, dissolves coplanar
|
||||
# edges while preserving material seams, returns wire-overlay data.
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(mesh)
|
||||
bmesh.ops.dissolve_limit(
|
||||
bm,
|
||||
angle_limit=angle_limit,
|
||||
verts=bm.verts,
|
||||
edges=bm.edges,
|
||||
delimit={"MATERIAL"},
|
||||
)
|
||||
bm.verts.index_update()
|
||||
verts = [v.co.copy() for v in bm.verts]
|
||||
edges = [(e.verts[0].index, e.verts[1].index) for e in bm.edges]
|
||||
bm.free()
|
||||
return verts, edges
|
||||
|
||||
@classmethod
|
||||
def apply_item_ids_as_vertex_groups(cls, obj: bpy.types.Object) -> None:
|
||||
"""Save mesh-object item_ids as vertex groups in format 'ios_item_id_xxxx'.
|
||||
@@ -820,15 +849,6 @@ 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
|
||||
@@ -1134,11 +1154,16 @@ 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 (
|
||||
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"))
|
||||
):
|
||||
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"):
|
||||
return item
|
||||
return None
|
||||
|
||||
@@ -1312,6 +1337,8 @@ 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.Blender.Modifier.is_railing(element):
|
||||
if tool.Parametric.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.Blender.Modifier.is_railing(element):
|
||||
if tool.Parametric.is_railing(element):
|
||||
return False
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -227,10 +227,8 @@ 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] = min_z
|
||||
new_origin[2] = tool.Blender.get_object_world_bounding_box(obj)["min_z"]
|
||||
assert isinstance(obj.data, bpy.types.Mesh)
|
||||
obj.data.transform(
|
||||
Matrix.Translation(
|
||||
@@ -249,11 +247,8 @@ class Misc(bonsai.core.tool.Misc):
|
||||
|
||||
@classmethod
|
||||
def scale_object_to_height(cls, obj: bpy.types.Object, height: float) -> None:
|
||||
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
|
||||
bbox = tool.Blender.get_object_world_bounding_box(obj)
|
||||
scale_factor = height / (bbox["max_z"] - bbox["min_z"])
|
||||
obj.matrix_world @= Matrix.Scale(
|
||||
scale_factor, 4, obj.matrix_world.inverted().to_quaternion() @ Vector((0, 0, 1))
|
||||
)
|
||||
|
||||
+124
-21
@@ -351,6 +351,8 @@ 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"):
|
||||
@@ -843,6 +845,57 @@ 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
|
||||
@@ -855,7 +908,8 @@ class Model(bonsai.core.tool.Model):
|
||||
representation = tool.Geometry.get_body_representation(element)
|
||||
if not representation:
|
||||
return []
|
||||
booleans = [b for b in cls.get_booleans(element, representation) if b.id() in boolean_ids]
|
||||
all_chain_booleans = cls.get_booleans(element, representation)
|
||||
booleans = [b for b in all_chain_booleans if b.id() in boolean_ids]
|
||||
return booleans
|
||||
|
||||
@classmethod
|
||||
@@ -2557,12 +2611,15 @@ 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
|
||||
if (obj.matrix_world @ normal).z >= -0.5:
|
||||
world_normal_z = (obj.matrix_world @ normal).z
|
||||
if 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)):
|
||||
@@ -2575,6 +2632,7 @@ 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
|
||||
@@ -2588,17 +2646,53 @@ class Model(bonsai.core.tool.Model):
|
||||
min_z = min(zs)
|
||||
max_z = max(zs)
|
||||
|
||||
operand = None
|
||||
if (z := max_z - min_z) and not np.isclose(z, 0.0):
|
||||
builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get())
|
||||
ifc_file = tool.Ifc.get()
|
||||
builder = ifcopenshell.util.shape_builder.ShapeBuilder(ifc_file)
|
||||
|
||||
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))
|
||||
# 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()
|
||||
|
||||
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)
|
||||
# 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)
|
||||
|
||||
for extrusion in ifcopenshell.util.shape.get_base_extrusions(wall) or []:
|
||||
if extrusion.Position:
|
||||
@@ -2615,10 +2709,9 @@ class Model(bonsai.core.tool.Model):
|
||||
|
||||
extrusion.Depth = max_z / direction[2]
|
||||
|
||||
if operand:
|
||||
booleans = ifcopenshell.api.geometry.add_boolean(
|
||||
tool.Ifc.get(), first_item=extrusion, second_items=[operand]
|
||||
)
|
||||
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)
|
||||
tool.Model.mark_manual_booleans(wall, booleans)
|
||||
|
||||
@classmethod
|
||||
@@ -2850,7 +2943,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 not usage.is_a("IfcMaterialLayerSetUsage"):
|
||||
if usage is None or not usage.is_a("IfcMaterialLayerSetUsage"):
|
||||
return
|
||||
layer_set = usage.ForLayerSet
|
||||
if baseline == "CENTER":
|
||||
@@ -2871,10 +2964,20 @@ class Model(bonsai.core.tool.Model):
|
||||
|
||||
@classmethod
|
||||
def recreate_wall(cls, element: ifcopenshell.entity_instance, obj: bpy.types.Object) -> None:
|
||||
# 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.
|
||||
# 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
|
||||
rep = ifcopenshell.api.geometry.regenerate_wall_representation(tool.Ifc.get(), element)
|
||||
bonsai.core.geometry.switch_representation(
|
||||
tool.Ifc,
|
||||
@@ -2909,7 +3012,7 @@ class Model(bonsai.core.tool.Model):
|
||||
if not wall:
|
||||
continue
|
||||
is_layer2_usage = tool.Model.get_usage_type(element) == "LAYER2"
|
||||
is_fillet_corner = bool(ifcopenshell.util.element.get_pset(element, "BBIM_Wall", "IsFilletCorner"))
|
||||
is_fillet_corner = tool.Parametric.is_fillet_corner_wall(element)
|
||||
if not (is_layer2_usage or is_fillet_corner):
|
||||
continue
|
||||
if is_layer2_usage:
|
||||
|
||||
@@ -147,17 +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.
|
||||
# FIXME(PR5): pipe_segment / duct_segment land with their finish/cancel
|
||||
# operators in the MEP slice of PR5 (PR5d). Until then they stay out of
|
||||
# EDIT_TYPES so auto-commit-on-save doesn't try to dispatch a
|
||||
# non-existent operator.
|
||||
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("wall"),
|
||||
]
|
||||
|
||||
@@ -169,6 +169,7 @@ class Parametric(bonsai.core.tool.Parametric):
|
||||
STAIR: ClassVar[ParametricObject]
|
||||
RAILING: ClassVar[ParametricObject]
|
||||
ROOF: ClassVar[ParametricObject]
|
||||
ARRAY: ClassVar[ParametricObject]
|
||||
WALL: ClassVar[ParametricObject]
|
||||
|
||||
_geom_generation: int = 0
|
||||
@@ -178,16 +179,24 @@ class Parametric(bonsai.core.tool.Parametric):
|
||||
return cls._geom_generation
|
||||
|
||||
@classmethod
|
||||
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.*
|
||||
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.
|
||||
|
||||
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]:
|
||||
@@ -262,6 +271,18 @@ 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
|
||||
@@ -382,29 +403,6 @@ 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
|
||||
@@ -487,6 +485,13 @@ 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,26 +373,38 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
except:
|
||||
loc = Vector((0, 0, 0))
|
||||
|
||||
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
|
||||
|
||||
snap_obj._ensure_bvh()
|
||||
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 = 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]]
|
||||
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])
|
||||
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:
|
||||
@@ -404,10 +416,16 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
|
||||
snap_threshold = 10.0
|
||||
|
||||
for i, point in enumerate(verts_2d):
|
||||
if not point:
|
||||
continue
|
||||
distance = (Vector(mouse_pos) - point).length
|
||||
# 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
|
||||
if distance <= snap_threshold:
|
||||
snap_point = {
|
||||
"object": snap_obj.obj,
|
||||
@@ -799,6 +817,30 @@ 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,
|
||||
@@ -813,35 +855,43 @@ 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
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
if closest_wf_point:
|
||||
hit_obj = closest_wf_point["object"]
|
||||
hit = closest_wf_point["point"]
|
||||
face_index = None
|
||||
# Rough distance - object origin to ray origin
|
||||
solid_objs.sort(key=lambda so: (so.obj.matrix_world.translation - ray_origin).length_squared)
|
||||
|
||||
else:
|
||||
# Solid objects
|
||||
# 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:
|
||||
hit_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, snap_obj.obj)
|
||||
|
||||
if hit:
|
||||
@@ -855,14 +905,45 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
}
|
||||
closest_snaps.append(snap_point)
|
||||
|
||||
# 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
|
||||
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
|
||||
|
||||
# Label snaps from the closest object
|
||||
if closest_obj is not None:
|
||||
@@ -936,12 +1017,19 @@ class SnapObj:
|
||||
def __init__(self, obj: bpy.types.Object):
|
||||
self.__class__.all.append(self)
|
||||
self.obj = obj
|
||||
self.root = self._create_root_node()
|
||||
self.root.edges = [e.index for e in obj.data.edges]
|
||||
self.split_box(self.root, 0)
|
||||
self.root = None
|
||||
self._bvh_built = False
|
||||
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,6 +71,18 @@ 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
|
||||
@@ -381,7 +393,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.Blender.Modifier.Array.get_all_children_objects(new[0])
|
||||
array_children = tool.Array.get_all_children_objects(new[0])
|
||||
for obj in array_children:
|
||||
bonsai.core.aggregate.assign_object(
|
||||
tool.Ifc,
|
||||
|
||||
@@ -687,8 +687,10 @@ 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 "2.5"
|
||||
And the variable "saved_height" equals "2500.0"
|
||||
|
||||
Scenario: Saving with no parametric edits in progress leaves the door pset unchanged
|
||||
Given an empty IFC project
|
||||
@@ -705,14 +707,10 @@ 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 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 load the demo construction library
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
|
||||
And the variable "cube" is "{ifc}.by_type('IfcWallType')[0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{cube}"
|
||||
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 I press "bim.add_occurrence"
|
||||
And the object "IfcWall/Wall" is selected
|
||||
And I press "bim.enable_editing_wall()"
|
||||
@@ -722,21 +720,18 @@ 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 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 load the demo construction library
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
|
||||
And the variable "cube" is "{ifc}.by_type('IfcWallType')[0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{cube}"
|
||||
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 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 "len(list({ifc}))" is "{entity_count_before}"
|
||||
And the variable "entity_count_after" is "len(list({ifc}))"
|
||||
And the variable "entity_count_after" equals "{entity_count_before}"
|
||||
|
||||
Scenario: Cancelling a wall edit clears is_editing
|
||||
Given an empty IFC project
|
||||
@@ -756,14 +751,10 @@ Scenario: Cancelling a wall edit clears is_editing
|
||||
|
||||
Scenario: Wall parametric edit works on IFC2X3 projects
|
||||
Given an empty IFC2X3 project
|
||||
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 load the demo construction library
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
|
||||
And the variable "cube" is "{ifc}.by_type('IfcWallType')[0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{cube}"
|
||||
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 I press "bim.add_occurrence"
|
||||
And the object "IfcWall/Wall" is selected
|
||||
When I press "bim.enable_editing_wall()"
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
# 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
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import ifcopenshell
|
||||
import pytest
|
||||
|
||||
from bonsai import tool
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1,219 @@
|
||||
# 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."""
|
||||
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
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 _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)
|
||||
@@ -0,0 +1,96 @@
|
||||
# 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 types
|
||||
|
||||
import bpy
|
||||
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 _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
|
||||
@@ -0,0 +1,463 @@
|
||||
# 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
|
||||
@@ -0,0 +1,521 @@
|
||||
# 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 tool.Geometry.get_dissolved_edges and the opening-decoration cache
|
||||
layers. The dissolve helper's contract:
|
||||
|
||||
- Read-only on the input mesh.
|
||||
- Returns (verts_local, edge_indices) indexed into the dissolved bmesh.
|
||||
- Material seams survive (delimit=MATERIAL).
|
||||
- Default angle threshold is 1°."""
|
||||
|
||||
from math import radians
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import pytest
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim import decorator_cache
|
||||
from bonsai.bim.module.model import opening as opening_module
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_decoration_caches():
|
||||
# Tests share module-global state (dissolve cache + token, world-draw-data
|
||||
# cache, batch cache, per-object epochs). Reset every layer so a previous
|
||||
# test can't poison hit/miss assertions.
|
||||
decorator_cache.reset_for_test()
|
||||
opening_module._dissolved_edges_cache.clear()
|
||||
opening_module._dissolved_edges_cache_token = -1
|
||||
opening_module._world_draw_data_cache.clear()
|
||||
opening_module._batch_cache.clear()
|
||||
opening_module._object_epochs.clear()
|
||||
yield
|
||||
decorator_cache.reset_for_test()
|
||||
opening_module._dissolved_edges_cache.clear()
|
||||
opening_module._world_draw_data_cache.clear()
|
||||
opening_module._batch_cache.clear()
|
||||
opening_module._object_epochs.clear()
|
||||
|
||||
|
||||
def _make_mesh(name: str, verts: list[tuple[float, float, float]], faces: list[tuple[int, ...]]) -> bpy.types.Mesh:
|
||||
mesh = bpy.data.meshes.new(name)
|
||||
mesh.from_pydata(verts, [], faces)
|
||||
mesh.update()
|
||||
return mesh
|
||||
|
||||
|
||||
def _edge_count(mesh: bpy.types.Mesh) -> int:
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(mesh)
|
||||
n = len(bm.edges)
|
||||
bm.free()
|
||||
return n
|
||||
|
||||
|
||||
def test_collapses_coplanar_diagonal_on_triangulated_quad():
|
||||
# Triangulated unit quad in the XY plane: 4 verts, 2 tris share a diagonal.
|
||||
# Raw bmesh has 5 edges (4 quad sides + 1 diagonal). Dissolve must drop the
|
||||
# diagonal because both triangles are perfectly coplanar.
|
||||
mesh = _make_mesh(
|
||||
"quad_tri",
|
||||
verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)],
|
||||
faces=[(0, 1, 2), (0, 2, 3)],
|
||||
)
|
||||
assert _edge_count(mesh) == 5
|
||||
|
||||
verts, edges = tool.Geometry.get_dissolved_edges(mesh)
|
||||
|
||||
assert len(verts) == 4
|
||||
assert len(edges) == 4
|
||||
# Every returned edge index must point into the returned verts list.
|
||||
for a, b in edges:
|
||||
assert 0 <= a < len(verts)
|
||||
assert 0 <= b < len(verts)
|
||||
assert a != b
|
||||
|
||||
|
||||
def test_preserves_real_edges_on_cube():
|
||||
# Default cube has 8 verts / 12 edges / 6 quad faces. There are no coplanar
|
||||
# internal splits to dissolve, so the helper must return the cube intact.
|
||||
mesh = bpy.data.meshes.new("cube")
|
||||
bm = bmesh.new()
|
||||
bmesh.ops.create_cube(bm, size=1.0)
|
||||
bm.to_mesh(mesh)
|
||||
bm.free()
|
||||
|
||||
verts, edges = tool.Geometry.get_dissolved_edges(mesh)
|
||||
|
||||
assert len(verts) == 8
|
||||
assert len(edges) == 12
|
||||
|
||||
|
||||
def test_preserves_material_seam_on_coplanar_split():
|
||||
# Two coplanar triangles sharing an edge but each with a different
|
||||
# material_index. delimit=MATERIAL must keep the shared edge alive.
|
||||
mesh = _make_mesh(
|
||||
"split_mat",
|
||||
verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)],
|
||||
faces=[(0, 1, 2), (0, 2, 3)],
|
||||
)
|
||||
mat_a = bpy.data.materials.new("mat_a")
|
||||
mat_b = bpy.data.materials.new("mat_b")
|
||||
mesh.materials.append(mat_a)
|
||||
mesh.materials.append(mat_b)
|
||||
mesh.polygons[0].material_index = 0
|
||||
mesh.polygons[1].material_index = 1
|
||||
mesh.update()
|
||||
|
||||
verts, edges = tool.Geometry.get_dissolved_edges(mesh)
|
||||
|
||||
# The 4 perimeter edges plus the shared diagonal: 5 total survive.
|
||||
assert len(verts) == 4
|
||||
assert len(edges) == 5
|
||||
|
||||
bpy.data.materials.remove(mat_a)
|
||||
bpy.data.materials.remove(mat_b)
|
||||
|
||||
|
||||
def test_does_not_mutate_input_mesh():
|
||||
# The helper must be read-only: viewport draw handlers call it every frame
|
||||
# and any obj.data mutation would race the depsgraph and trigger redraws.
|
||||
mesh = _make_mesh(
|
||||
"ro_quad",
|
||||
verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)],
|
||||
faces=[(0, 1, 2), (0, 2, 3)],
|
||||
)
|
||||
edges_before = _edge_count(mesh)
|
||||
verts_before = len(mesh.vertices)
|
||||
|
||||
tool.Geometry.get_dissolved_edges(mesh)
|
||||
|
||||
assert _edge_count(mesh) == edges_before
|
||||
assert len(mesh.vertices) == verts_before
|
||||
|
||||
|
||||
def test_accepts_explicit_angle_limit():
|
||||
# Smoke: the angle_limit kwarg must be honored end-to-end (not silently
|
||||
# ignored). With a near-zero threshold, even sub-degree coplanar splits
|
||||
# survive; with a generous threshold, they collapse.
|
||||
mesh = _make_mesh(
|
||||
"quad_tri",
|
||||
verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)],
|
||||
faces=[(0, 1, 2), (0, 2, 3)],
|
||||
)
|
||||
|
||||
_, edges_zero = tool.Geometry.get_dissolved_edges(mesh, angle_limit=0.0)
|
||||
_, edges_default = tool.Geometry.get_dissolved_edges(mesh)
|
||||
|
||||
assert len(edges_zero) > len(edges_default), "angle_limit=0 must preserve more edges than the default 1° dissolve"
|
||||
|
||||
|
||||
def test_cache_serves_identical_object_on_repeat_call():
|
||||
# Without caching, the helper rebuilds verts/edges every viewport redraw.
|
||||
# Identity (`is`) — not equality — proves the second call hit the cache
|
||||
# rather than recomputing identical content.
|
||||
mesh = _make_mesh(
|
||||
"cached",
|
||||
verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)],
|
||||
faces=[(0, 1, 2), (0, 2, 3)],
|
||||
)
|
||||
first = opening_module._get_cached_dissolved_edges(mesh)
|
||||
second = opening_module._get_cached_dissolved_edges(mesh)
|
||||
|
||||
assert first is second
|
||||
|
||||
|
||||
def test_cache_invalidates_on_decorator_token_bump():
|
||||
# depsgraph_update_post / undo / redo / load all bump the shared decorator
|
||||
# token; this cache must clear when the token changes so a downstream
|
||||
# depsgraph edit (mesh content changed) is reflected on the next call.
|
||||
mesh = _make_mesh(
|
||||
"bumped",
|
||||
verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)],
|
||||
faces=[(0, 1, 2), (0, 2, 3)],
|
||||
)
|
||||
first = opening_module._get_cached_dissolved_edges(mesh)
|
||||
decorator_cache._DECORATOR_CACHE_TOKEN += 1
|
||||
second = opening_module._get_cached_dissolved_edges(mesh)
|
||||
|
||||
assert first is not second, "token bump must invalidate the cache entry"
|
||||
assert len(first[0]) == len(second[0])
|
||||
assert len(first[1]) == len(second[1])
|
||||
|
||||
|
||||
def test_cache_partitions_entries_by_mesh_identity():
|
||||
# Two distinct meshes share the same epoch; both must coexist in the cache
|
||||
# so multi-opening frames don't thrash.
|
||||
mesh_a = _make_mesh(
|
||||
"a",
|
||||
verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)],
|
||||
faces=[(0, 1, 2), (0, 2, 3)],
|
||||
)
|
||||
mesh_b = _make_mesh(
|
||||
"b",
|
||||
verts=[(0, 0, 0), (2, 0, 0), (2, 2, 0), (0, 2, 0)],
|
||||
faces=[(0, 1, 2), (0, 2, 3)],
|
||||
)
|
||||
|
||||
a_first = opening_module._get_cached_dissolved_edges(mesh_a)
|
||||
b_first = opening_module._get_cached_dissolved_edges(mesh_b)
|
||||
a_second = opening_module._get_cached_dissolved_edges(mesh_a)
|
||||
|
||||
assert a_first is a_second, "mesh_a entry must survive an interleaved mesh_b call"
|
||||
assert a_first is not b_first
|
||||
|
||||
|
||||
def test_cache_partitions_entries_by_angle_limit():
|
||||
# Same mesh, different angle_limit → different cached results. Hardens
|
||||
# against a future caller introducing a per-opening threshold override.
|
||||
mesh = _make_mesh(
|
||||
"partitioned",
|
||||
verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)],
|
||||
faces=[(0, 1, 2), (0, 2, 3)],
|
||||
)
|
||||
tight = opening_module._get_cached_dissolved_edges(mesh, angle_limit=0.0)
|
||||
loose = opening_module._get_cached_dissolved_edges(mesh, angle_limit=radians(1.0))
|
||||
tight_again = opening_module._get_cached_dissolved_edges(mesh, angle_limit=0.0)
|
||||
|
||||
assert tight is tight_again
|
||||
assert tight is not loose
|
||||
|
||||
|
||||
# --- world-data cache (_get_cached_world_draw_data) ---------------------------
|
||||
|
||||
|
||||
def _make_object(name: str, mesh: bpy.types.Mesh) -> bpy.types.Object:
|
||||
obj = bpy.data.objects.new(name, mesh)
|
||||
bpy.context.scene.collection.objects.link(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def _make_triangulated_quad_obj(name: str) -> bpy.types.Object:
|
||||
mesh = _make_mesh(
|
||||
name,
|
||||
verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)],
|
||||
faces=[(0, 1, 2), (0, 2, 3)],
|
||||
)
|
||||
return _make_object(name, mesh)
|
||||
|
||||
|
||||
def test_world_data_cache_returns_four_tuple_with_expected_shapes():
|
||||
obj = _make_triangulated_quad_obj("shape")
|
||||
line_verts, verts, edges_indices, tris = opening_module._get_cached_world_draw_data(obj)
|
||||
|
||||
assert len(verts) == 4 # full mesh vert count
|
||||
assert len(line_verts) == 4 # dissolved (diagonal collapsed → 4 surviving verts)
|
||||
assert len(edges_indices) == 4 # quad outline, no diagonal
|
||||
assert len(tris) == 2 # two triangles
|
||||
assert all(len(t) == 3 for t in tris)
|
||||
|
||||
|
||||
def test_world_data_cache_hit_returns_identical_tuple_on_repeat_call():
|
||||
obj = _make_triangulated_quad_obj("hit")
|
||||
first = opening_module._get_cached_world_draw_data(obj)
|
||||
second = opening_module._get_cached_world_draw_data(obj)
|
||||
|
||||
assert first is second
|
||||
|
||||
|
||||
def test_world_data_cache_invalidates_on_object_epoch_bump():
|
||||
# depsgraph_update_post bumps per-object epochs (one per Object whose
|
||||
# transform or geometry changed). After bumping this object's epoch the
|
||||
# next lookup must miss and recompute.
|
||||
obj = _make_triangulated_quad_obj("bumped")
|
||||
first = opening_module._get_cached_world_draw_data(obj)
|
||||
opening_module._object_epochs[obj.session_uid] = opening_module._object_epochs.get(obj.session_uid, 0) + 1
|
||||
second = opening_module._get_cached_world_draw_data(obj)
|
||||
|
||||
assert first is not second
|
||||
|
||||
|
||||
def test_world_data_cache_partitions_entries_by_object_identity():
|
||||
a = _make_triangulated_quad_obj("a")
|
||||
b = _make_triangulated_quad_obj("b")
|
||||
|
||||
a_first = opening_module._get_cached_world_draw_data(a)
|
||||
b_first = opening_module._get_cached_world_draw_data(b)
|
||||
a_second = opening_module._get_cached_world_draw_data(a)
|
||||
|
||||
assert a_first is a_second
|
||||
assert a_first is not b_first
|
||||
|
||||
|
||||
def test_world_data_cache_reflects_new_matrix_after_epoch_bump():
|
||||
# The cache stores world-space verts. A transform without an epoch bump
|
||||
# would serve stale coordinates — but transform updates bump the object's
|
||||
# epoch via the depsgraph handler, so after bump + recompute the new
|
||||
# matrix must be reflected.
|
||||
obj = _make_triangulated_quad_obj("moved")
|
||||
before = opening_module._get_cached_world_draw_data(obj)
|
||||
obj.matrix_world = obj.matrix_world @ Matrix.Translation((5.0, 0.0, 0.0))
|
||||
opening_module._object_epochs[obj.session_uid] = opening_module._object_epochs.get(obj.session_uid, 0) + 1
|
||||
after = opening_module._get_cached_world_draw_data(obj)
|
||||
|
||||
# Each vert in `after` is 5 units shifted on X relative to `before`.
|
||||
for a_co, b_co in zip(after[1], before[1]):
|
||||
assert a_co[0] - b_co[0] == pytest.approx(5.0)
|
||||
assert a_co[1] == pytest.approx(b_co[1])
|
||||
assert a_co[2] == pytest.approx(b_co[2])
|
||||
|
||||
|
||||
def test_world_data_cache_ios_edges_path_returns_curated_edges():
|
||||
# When the mesh has an ios_edges attribute, line_verts must equal the full
|
||||
# verts (no dissolve), and edges_indices must include only entries where
|
||||
# the attribute is True.
|
||||
obj = _make_triangulated_quad_obj("curated")
|
||||
attr = obj.data.attributes.new(name="ios_edges", type="BOOLEAN", domain="EDGE")
|
||||
# 5 edges total (quad + diagonal). Mark only the 4 quad sides as real.
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(obj.data)
|
||||
real_edges_count = 0
|
||||
for i, edge in enumerate(bm.edges):
|
||||
is_diagonal = (
|
||||
abs(edge.verts[0].co[0] - edge.verts[1].co[0]) > 0 and abs(edge.verts[0].co[1] - edge.verts[1].co[1]) > 0
|
||||
)
|
||||
attr.data[i].value = not is_diagonal
|
||||
if not is_diagonal:
|
||||
real_edges_count += 1
|
||||
bm.free()
|
||||
obj.data.update()
|
||||
|
||||
line_verts, verts, edges_indices, _ = opening_module._get_cached_world_draw_data(obj)
|
||||
|
||||
assert line_verts is verts, "ios_edges path must reuse the full-verts list as line_verts"
|
||||
assert len(edges_indices) == real_edges_count
|
||||
|
||||
|
||||
def test_world_data_cache_dissolve_path_drops_diagonal():
|
||||
# Without ios_edges, the cache falls through to dissolve. The 5th edge
|
||||
# (diagonal) must be gone from edges_indices.
|
||||
obj = _make_triangulated_quad_obj("dissolved")
|
||||
line_verts, verts, edges_indices, _ = opening_module._get_cached_world_draw_data(obj)
|
||||
|
||||
assert len(edges_indices) == 4
|
||||
assert len(line_verts) == 4
|
||||
assert len(verts) == 4
|
||||
|
||||
|
||||
# --- batch cache (_get_cached_batch_or_none / _store_batch_in_cache) ---------
|
||||
|
||||
|
||||
def test_batch_cache_returns_none_on_cold_lookup():
|
||||
assert opening_module._get_cached_batch_or_none((123, "lines")) is None
|
||||
|
||||
|
||||
def test_batch_cache_returns_stored_batch_on_hit():
|
||||
# Sentinel stands in for a GPUBatch — the cache treats it opaquely, so
|
||||
# this test pins lookup/store correctness without needing a real shader.
|
||||
sentinel = object()
|
||||
opening_module._store_batch_in_cache((42, "lines"), sentinel)
|
||||
|
||||
assert opening_module._get_cached_batch_or_none((42, "lines")) is sentinel
|
||||
|
||||
|
||||
def test_batch_cache_invalidates_on_object_epoch_bump():
|
||||
sentinel = object()
|
||||
opening_module._store_batch_in_cache((42, "lines"), sentinel)
|
||||
opening_module._object_epochs[42] = opening_module._object_epochs.get(42, 0) + 1
|
||||
|
||||
assert opening_module._get_cached_batch_or_none((42, "lines")) is None
|
||||
|
||||
|
||||
def test_batch_cache_partitions_entries_by_kind():
|
||||
# Same object, different batch kinds (LINES vs TRIS vs arrow) coexist —
|
||||
# required so the same opening's three batches don't evict each other.
|
||||
lines_batch = object()
|
||||
tris_batch = object()
|
||||
opening_module._store_batch_in_cache((42, "lines"), lines_batch)
|
||||
opening_module._store_batch_in_cache((42, "tris"), tris_batch)
|
||||
|
||||
assert opening_module._get_cached_batch_or_none((42, "lines")) is lines_batch
|
||||
assert opening_module._get_cached_batch_or_none((42, "tris")) is tris_batch
|
||||
|
||||
|
||||
def test_batch_cache_partitions_entries_by_object_uid():
|
||||
a_batch = object()
|
||||
b_batch = object()
|
||||
opening_module._store_batch_in_cache((1, "lines"), a_batch)
|
||||
opening_module._store_batch_in_cache((2, "lines"), b_batch)
|
||||
|
||||
assert opening_module._get_cached_batch_or_none((1, "lines")) is a_batch
|
||||
assert opening_module._get_cached_batch_or_none((2, "lines")) is b_batch
|
||||
|
||||
|
||||
# --- per-object epoch invalidation (granularity contract) --------------------
|
||||
|
||||
|
||||
def test_world_data_cache_per_object_epoch_invalidates_only_target():
|
||||
# Core contract for the granular-invalidation feature: bumping one object's
|
||||
# epoch must not evict another object's cached payload. This is what makes
|
||||
# dragging a single object in a 50-opening scene affordable.
|
||||
a = _make_triangulated_quad_obj("granular_a")
|
||||
b = _make_triangulated_quad_obj("granular_b")
|
||||
|
||||
a_first = opening_module._get_cached_world_draw_data(a)
|
||||
b_first = opening_module._get_cached_world_draw_data(b)
|
||||
|
||||
opening_module._object_epochs[a.session_uid] = opening_module._object_epochs.get(a.session_uid, 0) + 1
|
||||
|
||||
a_second = opening_module._get_cached_world_draw_data(a)
|
||||
b_second = opening_module._get_cached_world_draw_data(b)
|
||||
|
||||
assert a_first is not a_second, "a's epoch bump must invalidate a's entry"
|
||||
assert b_first is b_second, "a's epoch bump must NOT touch b's entry"
|
||||
|
||||
|
||||
def test_batch_cache_per_object_epoch_invalidates_only_target():
|
||||
a_lines = object()
|
||||
b_lines = object()
|
||||
opening_module._store_batch_in_cache((1, "lines"), a_lines)
|
||||
opening_module._store_batch_in_cache((2, "lines"), b_lines)
|
||||
|
||||
opening_module._object_epochs[1] = opening_module._object_epochs.get(1, 0) + 1
|
||||
|
||||
assert opening_module._get_cached_batch_or_none((1, "lines")) is None
|
||||
assert opening_module._get_cached_batch_or_none((2, "lines")) is b_lines
|
||||
|
||||
|
||||
def test_global_clear_handler_wipes_everything():
|
||||
# undo/redo/load can't be modeled as per-object deltas — the global handler
|
||||
# must wipe every layer (epochs + both caches) so we can never serve state
|
||||
# that pre-dates the undo/load.
|
||||
a = _make_triangulated_quad_obj("wipe_a")
|
||||
opening_module._get_cached_world_draw_data(a)
|
||||
opening_module._store_batch_in_cache((a.session_uid, "lines"), object())
|
||||
assert a.session_uid in opening_module._world_draw_data_cache
|
||||
assert (a.session_uid, "lines") in opening_module._batch_cache
|
||||
|
||||
opening_module._clear_decoration_caches_globally()
|
||||
|
||||
assert opening_module._world_draw_data_cache == {}
|
||||
assert opening_module._batch_cache == {}
|
||||
assert opening_module._object_epochs == {}
|
||||
|
||||
|
||||
class _FakeDepsgraphUpdate:
|
||||
def __init__(self, id_, transform: bool = False, geometry: bool = False):
|
||||
self.id = id_
|
||||
self.is_updated_transform = transform
|
||||
self.is_updated_geometry = geometry
|
||||
|
||||
|
||||
class _FakeDepsgraph:
|
||||
def __init__(self, updates):
|
||||
self.updates = updates
|
||||
|
||||
|
||||
def test_depsgraph_handler_bumps_epoch_for_updated_object():
|
||||
# Synthesised depsgraph delta: one Object with a transform update. The
|
||||
# handler must increment that object's epoch.
|
||||
obj = _make_triangulated_quad_obj("bumped_via_handler")
|
||||
before = opening_module._object_epochs.get(obj.session_uid, 0)
|
||||
|
||||
deps = _FakeDepsgraph([_FakeDepsgraphUpdate(obj, transform=True)])
|
||||
opening_module._bump_object_epochs_for_decoration(None, deps)
|
||||
|
||||
assert opening_module._object_epochs[obj.session_uid] == before + 1
|
||||
|
||||
|
||||
def test_depsgraph_handler_ignores_non_object_updates():
|
||||
# Updates whose .id isn't a bpy.types.Object (Mesh, Material, NodeTree…)
|
||||
# must not affect any object's epoch.
|
||||
obj = _make_triangulated_quad_obj("untouched")
|
||||
deps = _FakeDepsgraph([_FakeDepsgraphUpdate(obj.data, geometry=True)])
|
||||
opening_module._bump_object_epochs_for_decoration(None, deps)
|
||||
|
||||
assert obj.session_uid not in opening_module._object_epochs
|
||||
|
||||
|
||||
def test_depsgraph_handler_ignores_updates_without_transform_or_geometry():
|
||||
# An Object update flagged only for shading must not bump the epoch —
|
||||
# shading changes don't move the wire overlay.
|
||||
obj = _make_triangulated_quad_obj("shading_only")
|
||||
deps = _FakeDepsgraph([_FakeDepsgraphUpdate(obj)])
|
||||
opening_module._bump_object_epochs_for_decoration(None, deps)
|
||||
|
||||
assert obj.session_uid not in opening_module._object_epochs
|
||||
|
||||
|
||||
def test_depsgraph_handler_resolves_cow_original():
|
||||
# For non-evaluated Blender objects, obj.original returns obj itself, so
|
||||
# the .original-resolution path keys the SAME uid the draw handler reads.
|
||||
# Pinning this prevents a future refactor that drops the .original lookup
|
||||
# from silently regressing the COW-boundary case (the decorator failing to
|
||||
# follow a moved object).
|
||||
obj = _make_triangulated_quad_obj("cow")
|
||||
deps = _FakeDepsgraph([_FakeDepsgraphUpdate(obj, transform=True)])
|
||||
opening_module._bump_object_epochs_for_decoration(None, deps)
|
||||
|
||||
assert obj.original.session_uid in opening_module._object_epochs
|
||||
|
||||
|
||||
def test_depsgraph_handler_tolerates_missing_depsgraph():
|
||||
# Some Blender event paths may call the handler without a depsgraph; the
|
||||
# handler must short-circuit instead of raising AttributeError.
|
||||
opening_module._bump_object_epochs_for_decoration()
|
||||
opening_module._bump_object_epochs_for_decoration(None)
|
||||
opening_module._bump_object_epochs_for_decoration(None, None)
|
||||
|
||||
assert opening_module._object_epochs == {}
|
||||
@@ -0,0 +1,178 @@
|
||||
# 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
|
||||
|
||||
|
||||
@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 _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 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."
|
||||
)
|
||||
@@ -0,0 +1,306 @@
|
||||
# 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")
|
||||
@@ -133,3 +133,75 @@ 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
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
# 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
|
||||
import types
|
||||
from unittest.mock import patch
|
||||
|
||||
import bpy
|
||||
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 _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)
|
||||
@@ -179,3 +179,126 @@ 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")]
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
# 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,21 +18,20 @@
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""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.
|
||||
"""Regression tests for the post-IFC-commit refresh path.
|
||||
|
||||
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."""
|
||||
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."""
|
||||
|
||||
import types
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
@@ -46,17 +45,42 @@ def _require_real_bpy():
|
||||
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
|
||||
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()``."""
|
||||
from bonsai import tool
|
||||
|
||||
before = tool.Parametric.get_geom_generation()
|
||||
with patch.object(handler, "update_bim_tool_props") as mock_resync:
|
||||
tool.Parametric.refresh_post_commit()
|
||||
tool.Parametric.refresh_post_commit(MagicMock(bl_idname="bim.append_library_element"))
|
||||
assert tool.Parametric.get_geom_generation() == before + 1
|
||||
mock_resync.assert_called_once()
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
def test_geom_generation_invalidates_wall_geom_cache():
|
||||
@@ -75,7 +99,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(wall_mod, "_read_wall_geometry", side_effect=[sentinel_a, sentinel_b]):
|
||||
with patch.object(tool.Wall, "read_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).
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,79 @@
|
||||
# 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
|
||||
|
||||
|
||||
@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_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)
|
||||
@@ -1764,7 +1764,17 @@ 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():
|
||||
lib_path = "./bonsai/bim/data/libraries/IFC4 Demo Library.ifc"
|
||||
# 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}"
|
||||
bpy.ops.bim.select_library_file(filepath=lib_path, append_all=True)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
# 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."
|
||||
)
|
||||
@@ -0,0 +1,74 @@
|
||||
# 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,14 +22,15 @@
|
||||
|
||||
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<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.
|
||||
``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.
|
||||
|
||||
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.Blender.Modifier`."""
|
||||
predicate exists on `tool.Parametric`."""
|
||||
|
||||
import types
|
||||
|
||||
@@ -81,11 +82,11 @@ def test_every_entry_has_property_group_attached(registry):
|
||||
)
|
||||
|
||||
|
||||
def test_every_entry_has_modifier_predicate(registry):
|
||||
def test_every_entry_has_parametric_predicate(registry):
|
||||
from bonsai import tool
|
||||
|
||||
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}"
|
||||
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}"
|
||||
|
||||
|
||||
def test_every_predicate_does_not_raise_on_non_matching_element(registry):
|
||||
@@ -108,7 +109,7 @@ def test_every_predicate_does_not_raise_on_non_matching_element(registry):
|
||||
|
||||
raised = []
|
||||
for feature in registry:
|
||||
predicate = getattr(tool.Blender.Modifier, f"is_{feature.name}", None)
|
||||
predicate = getattr(tool.Parametric, f"is_{feature.name}", None)
|
||||
if predicate is None:
|
||||
continue
|
||||
try:
|
||||
@@ -122,19 +123,14 @@ def test_every_predicate_does_not_raise_on_non_matching_element(registry):
|
||||
)
|
||||
|
||||
|
||||
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.
|
||||
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>``.
|
||||
|
||||
Checks ``__annotations__`` rather than ``hasattr`` because Blender's
|
||||
PropertyGroup syntax (``field: bpy.props.PointerProperty(...)``) is an
|
||||
PropertyGroup syntax (``field: bpy.props.BoolProperty(...)``) 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__``
|
||||
@@ -142,16 +138,9 @@ def test_gizmo_preferences_attached_when_class_exists(registry):
|
||||
from bonsai.bim import ui
|
||||
|
||||
annotations = getattr(ui.GizmoPreferences, "__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))
|
||||
missing = [feature.name for feature in registry if feature.name not in annotations]
|
||||
assert not missing, (
|
||||
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``"
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -56,6 +56,7 @@ 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")
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
ISO-10303-21;
|
||||
HEADER;
|
||||
FILE_DESCRIPTION(('ViewDefinition[DesignTransferView]'),'2;1');
|
||||
FILE_NAME('snap-target.ifc','2026-06-05T17:45:10-03:00',(''),(''),'IfcOpenShell 0.0.0','Bonsai 0.8.6-alpha260605-24a241a','Nobody');
|
||||
FILE_SCHEMA(('IFC4'));
|
||||
ENDSEC;
|
||||
DATA;
|
||||
#1=IFCPROJECT('2pZygwkcb1Au$5kgwmW6ZC',$,'My Project',$,$,$,$,(#10,#22),#5);
|
||||
#2=IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.);
|
||||
#3=IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.);
|
||||
#4=IFCSIUNIT(*,.VOLUMEUNIT.,$,.CUBIC_METRE.);
|
||||
#5=IFCUNITASSIGNMENT((#4,#2,#3));
|
||||
#6=IFCCARTESIANPOINT((0.,0.,0.));
|
||||
#7=IFCDIRECTION((0.,0.,1.));
|
||||
#8=IFCDIRECTION((1.,0.,0.));
|
||||
#9=IFCAXIS2PLACEMENT3D(#6,#7,#8);
|
||||
#10=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#9,$);
|
||||
#11=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Model',*,*,*,*,#10,$,.MODEL_VIEW.,$);
|
||||
#12=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Axis','Model',*,*,*,*,#10,$,.GRAPH_VIEW.,$);
|
||||
#13=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Box','Model',*,*,*,*,#10,$,.MODEL_VIEW.,$);
|
||||
#14=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Annotation','Model',*,*,*,*,#10,$,.SECTION_VIEW.,$);
|
||||
#15=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Annotation','Model',*,*,*,*,#10,$,.ELEVATION_VIEW.,$);
|
||||
#16=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Annotation','Model',*,*,*,*,#10,$,.MODEL_VIEW.,$);
|
||||
#17=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Annotation','Model',*,*,*,*,#10,$,.PLAN_VIEW.,$);
|
||||
#18=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Profile','Model',*,*,*,*,#10,$,.ELEVATION_VIEW.,$);
|
||||
#19=IFCCARTESIANPOINT((0.,0.));
|
||||
#20=IFCDIRECTION((1.,0.));
|
||||
#21=IFCAXIS2PLACEMENT2D(#19,#20);
|
||||
#22=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Plan',2,1.E-05,#21,$);
|
||||
#23=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Axis','Plan',*,*,*,*,#22,$,.GRAPH_VIEW.,$);
|
||||
#24=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Plan',*,*,*,*,#22,$,.PLAN_VIEW.,$);
|
||||
#25=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Annotation','Plan',*,*,*,*,#22,$,.PLAN_VIEW.,$);
|
||||
#26=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Annotation','Plan',*,*,*,*,#22,$,.REFLECTED_PLAN_VIEW.,$);
|
||||
#27=IFCSITE('1Lvnr1aSn2OP70jevISY_C',$,'My Site',$,$,#50,$,$,$,$,$,$,$,$);
|
||||
#33=IFCBUILDING('1NaNtC8Wf6YPU7CZRG8Z8Q',$,'My Building',$,$,#56,$,$,$,$,$,$);
|
||||
#39=IFCBUILDINGSTOREY('1k9kfaMOr2cvyNvxvoll27',$,'My Storey',$,$,#62,$,$,$,$);
|
||||
#45=IFCRELAGGREGATES('1plJGwDIHDzwuc7i7gcQQD',$,$,$,#1,(#27));
|
||||
#46=IFCCARTESIANPOINT((0.,0.,0.));
|
||||
#47=IFCDIRECTION((0.,0.,1.));
|
||||
#48=IFCDIRECTION((1.,0.,0.));
|
||||
#49=IFCAXIS2PLACEMENT3D(#46,#47,#48);
|
||||
#50=IFCLOCALPLACEMENT($,#49);
|
||||
#51=IFCRELAGGREGATES('13KnzD8ZT8bAvAyOQAWiUj',$,$,$,#27,(#33));
|
||||
#52=IFCCARTESIANPOINT((0.,0.,0.));
|
||||
#53=IFCDIRECTION((0.,0.,1.));
|
||||
#54=IFCDIRECTION((1.,0.,0.));
|
||||
#55=IFCAXIS2PLACEMENT3D(#52,#53,#54);
|
||||
#56=IFCLOCALPLACEMENT(#50,#55);
|
||||
#57=IFCRELAGGREGATES('2Hf4WQLJvE4O43wq$0AZeu',$,$,$,#33,(#39));
|
||||
#58=IFCCARTESIANPOINT((0.,0.,0.));
|
||||
#59=IFCDIRECTION((0.,0.,1.));
|
||||
#60=IFCDIRECTION((1.,0.,0.));
|
||||
#61=IFCAXIS2PLACEMENT3D(#58,#59,#60);
|
||||
#62=IFCLOCALPLACEMENT(#56,#61);
|
||||
#63=IFCPROPERTYSINGLEVALUE('Symbol',$,IFCLABEL('setout-point'),$);
|
||||
#64=IFCPROPERTYSET('27lmSbeAXC08EWkEq8XdUG',$,'EPset_Annotation',$,(#63));
|
||||
#65=IFCTYPEPRODUCT('0UrP0fLdD5OwzwD41aRKao',$,'SETOUT-POINT',$,'IfcAnnotation/SYMBOL',(#64),$,$);
|
||||
#66=IFCCARTESIANPOINTLIST3D(((-8999.9990234375,-8999.9990234375,0.),(8999.9990234375,-8999.9990234375,0.),(-8999.9990234375,8999.9990234375,0.),(8999.9990234375,8999.9990234375,0.)));
|
||||
#67=IFCINDEXEDPOLYGONALFACE((1,2,4,3));
|
||||
#68=IFCPOLYGONALFACESET(#66,$,(#67),$);
|
||||
#69=IFCSHAPEREPRESENTATION(#11,'Body','Tessellation',(#68));
|
||||
#70=IFCBUILDINGELEMENTPROXY('3Avrn7zrPBiA7RUh91ENg9',$,'Plane',$,$,#142,#72,$,.COMPLEX.);
|
||||
#71=IFCRELCONTAINEDINSPATIALSTRUCTURE('0ftVF1Vf1Dmg$p62mtpWVF',$,$,$,(#123,#70,#108),#39);
|
||||
#72=IFCPRODUCTDEFINITIONSHAPE($,$,(#69));
|
||||
#108=IFCBUILDINGELEMENTPROXY('1lwURx5YX5WAXD5ExqQt31',$,'Plane',$,$,#122,#117,$,.COMPLEX.);
|
||||
#114=IFCCARTESIANPOINTLIST3D(((-5999.99951171875,-2999.99975585938,0.),(5999.99951171875,-2999.99975585938,0.)));
|
||||
#115=IFCINDEXEDPOLYCURVE(#114,(IFCLINEINDEX((1,2))),$);
|
||||
#116=IFCSHAPEREPRESENTATION(#11,'Body','Curve3D',(#115));
|
||||
#117=IFCPRODUCTDEFINITIONSHAPE($,$,(#116));
|
||||
#118=IFCCARTESIANPOINT((0.,0.,0.));
|
||||
#119=IFCDIRECTION((0.,0.,1.));
|
||||
#120=IFCDIRECTION((1.,0.,0.));
|
||||
#121=IFCAXIS2PLACEMENT3D(#118,#119,#120);
|
||||
#122=IFCLOCALPLACEMENT(#62,#121);
|
||||
#123=IFCBUILDINGELEMENTPROXY('2X4YMFxHjANvnkl1Hzqjlq',$,'Plane',$,$,#137,#132,$,.COMPLEX.);
|
||||
#129=IFCCARTESIANPOINTLIST3D(((3000.,-5999.99951171875,0.),(2999.99951171875,5999.99951171875,0.)));
|
||||
#130=IFCINDEXEDPOLYCURVE(#129,(IFCLINEINDEX((1,2))),$);
|
||||
#131=IFCSHAPEREPRESENTATION(#11,'Body','Curve3D',(#130));
|
||||
#132=IFCPRODUCTDEFINITIONSHAPE($,$,(#131));
|
||||
#133=IFCCARTESIANPOINT((0.,0.,0.));
|
||||
#134=IFCDIRECTION((0.,0.,1.));
|
||||
#135=IFCDIRECTION((1.,0.,0.));
|
||||
#136=IFCAXIS2PLACEMENT3D(#133,#134,#135);
|
||||
#137=IFCLOCALPLACEMENT(#62,#136);
|
||||
#138=IFCCARTESIANPOINT((0.,0.,0.));
|
||||
#139=IFCDIRECTION((0.,0.,1.));
|
||||
#140=IFCDIRECTION((1.,0.,0.));
|
||||
#141=IFCAXIS2PLACEMENT3D(#138,#139,#140);
|
||||
#142=IFCLOCALPLACEMENT(#62,#141);
|
||||
ENDSEC;
|
||||
END-ISO-10303-21;
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,451 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026 Bruno Perdigão <contact@brunopo.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/>.
|
||||
|
||||
|
||||
import inspect
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import pytest
|
||||
|
||||
from bonsai import tool as tool
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
from bonsai.bim.module.model.data import AuthoringData as Model
|
||||
|
||||
GREEN = "\033[32m"
|
||||
RED = "\033[31m"
|
||||
RESET = "\033[0m"
|
||||
|
||||
|
||||
def _assert_pass(message: str) -> None:
|
||||
caller_name = inspect.stack()[1].function
|
||||
print(f"{GREEN}{caller_name} PASSED: {message}{RESET}")
|
||||
|
||||
|
||||
def _handle_error(e: Exception, on_done) -> None:
|
||||
print(f"{RED}Assertion failed: {e}{RESET}")
|
||||
if on_done:
|
||||
on_done()
|
||||
|
||||
|
||||
def run_iter_from_timer(event_iter, on_complete=None, on_error=None):
|
||||
i = iter(event_iter)
|
||||
done = False
|
||||
|
||||
def event_step():
|
||||
nonlocal done, on_complete
|
||||
try:
|
||||
ret = next(i, "STOP")
|
||||
if ret in (None, "STOP", "FINISHED"):
|
||||
done = True
|
||||
if on_complete:
|
||||
on_complete()
|
||||
return None
|
||||
except StopIteration:
|
||||
done = True
|
||||
if on_complete:
|
||||
on_complete()
|
||||
return None
|
||||
except Exception as e:
|
||||
done = True
|
||||
print(f"Exception: {e}")
|
||||
if on_error:
|
||||
on_error(e)
|
||||
elif on_complete:
|
||||
on_complete()
|
||||
return None
|
||||
return 0.0
|
||||
|
||||
bpy.app.timers.register(event_step, first_interval=0.0)
|
||||
|
||||
|
||||
def preset_event_simulate(window, event_type, value, x, y):
|
||||
if value == "TAP":
|
||||
yield window.event_simulate(event_type, "PRESS", x=x, y=y)
|
||||
yield window.event_simulate(event_type, "RELEASE", x=x, y=y)
|
||||
else:
|
||||
yield window.event_simulate(event_type, value, x=x, y=y)
|
||||
|
||||
|
||||
def cleanup():
|
||||
bpy.app.use_event_simulate = False
|
||||
bpy.ops.wm.quit_blender()
|
||||
|
||||
|
||||
def _get_valid_window() -> bpy.types.Window:
|
||||
win = bpy.context.window
|
||||
if win is not None:
|
||||
return win
|
||||
wm = getattr(bpy.context, "window_manager", None)
|
||||
if wm and wm.windows:
|
||||
return wm.windows[0]
|
||||
raise RuntimeError("Unable to locate a Blender UI window.")
|
||||
|
||||
|
||||
def new_project():
|
||||
IfcStore.purge()
|
||||
bpy.ops.wm.read_homefile(app_template="", use_factory_startup=True)
|
||||
if len(bpy.data.objects) > 0:
|
||||
bpy.data.batch_remove(bpy.data.objects)
|
||||
bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True)
|
||||
if len(bpy.data.materials) > 0:
|
||||
bpy.data.batch_remove(bpy.data.materials)
|
||||
bpy.context.scene.unit_settings.system = "METRIC"
|
||||
bpy.context.scene.unit_settings.length_unit = "MILLIMETERS"
|
||||
props = tool.Project.get_project_props()
|
||||
props.template_file = "0"
|
||||
tool.Blender.get_addon_preferences().should_play_chaching_sound = False
|
||||
|
||||
|
||||
def get_area_and_region(window):
|
||||
area = next(area for area in window.screen.areas if area.type == "VIEW_3D")
|
||||
region = next(region for region in area.regions if region.type == "WINDOW")
|
||||
return area, region
|
||||
|
||||
|
||||
def test_snap_object_detection(window):
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", 0, 0)
|
||||
area, region = get_area_and_region(window)
|
||||
x = round(area.width * 0.5 + area.x)
|
||||
y = round(area.height * 0.54 + area.y)
|
||||
|
||||
yield from preset_event_simulate(window, "ESC", "TAP", x, y)
|
||||
|
||||
measure_settings = tool.Project.get_measure_tool_settings()
|
||||
measure_settings.measurement_type = "POLYLINE"
|
||||
for obj in tool.Blender.get_selected_objects():
|
||||
obj.select_set(False)
|
||||
with bpy.context.temp_override(area=area, region=region, space_data=area.spaces[0]):
|
||||
bpy.ops.bim.measure_tool("INVOKE_DEFAULT", measure_type="POLYLINE")
|
||||
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y)
|
||||
yield from preset_event_simulate(window, "LEFTMOUSE", "TAP", x, y)
|
||||
snap_point = tool.Model.get_polyline_props().snap_mouse_point[0]
|
||||
assert_msg = "First click should have a snap_object"
|
||||
assert snap_point.snap_object, assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "snap_object should be a string with the object name"
|
||||
assert type(snap_point.snap_object) == str, assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "Object should be an IfcWall"
|
||||
assert snap_point.snap_object.split("/")[0] == "IfcWall", assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
|
||||
offset = 200
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x - offset, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x - offset, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x - offset, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x - offset, y)
|
||||
yield from preset_event_simulate(window, "LEFTMOUSE", "TAP", x - offset, y)
|
||||
snap_point = tool.Model.get_polyline_props().snap_mouse_point[0]
|
||||
assert_msg = "Second click should not have a snap_object"
|
||||
assert not snap_point.snap_object, assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
|
||||
yield from preset_event_simulate(window, "RET", "TAP", x, y)
|
||||
yield "FINISHED"
|
||||
|
||||
|
||||
def test_snap_partially_behind_camera(window):
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", 0, 0)
|
||||
area, region = get_area_and_region(window)
|
||||
x = round(area.width * 0.20 + area.x)
|
||||
y = round(area.height * 0.15 + area.y)
|
||||
|
||||
yield from preset_event_simulate(window, "ESC", "TAP", x, y)
|
||||
|
||||
measure_settings = tool.Project.get_measure_tool_settings()
|
||||
measure_settings.measurement_type = "POLYLINE"
|
||||
for obj in tool.Blender.get_selected_objects():
|
||||
obj.select_set(False)
|
||||
with bpy.context.temp_override(area=area, region=region, space_data=area.spaces[0]):
|
||||
bpy.ops.bim.measure_tool("INVOKE_DEFAULT", measure_type="POLYLINE")
|
||||
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y)
|
||||
yield from preset_event_simulate(window, "LEFTMOUSE", "TAP", x, y)
|
||||
snap_point = tool.Model.get_polyline_props().snap_mouse_point[0]
|
||||
assert_msg = "First click should have a snap_object"
|
||||
assert snap_point.snap_object, assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "snap_object should be a string with the object name"
|
||||
assert type(snap_point.snap_object) == str, assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "snap_type should be 'Edge'"
|
||||
assert snap_point.snap_type == "Edge", assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "Object should be an IfcSlab"
|
||||
assert snap_point.snap_object.split("/")[0] == "IfcSlab", assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
|
||||
offset = 200
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x + offset, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x + offset, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x + offset, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x + offset, y)
|
||||
yield from preset_event_simulate(window, "LEFTMOUSE", "TAP", x - offset, y)
|
||||
snap_point = tool.Model.get_polyline_props().snap_mouse_point[0]
|
||||
assert_msg = "Second click should have a snap_object"
|
||||
assert snap_point.snap_object, assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "snap_object should be a string with the object name"
|
||||
assert type(snap_point.snap_object) == str, assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "snap_type should be 'Face'"
|
||||
assert snap_point.snap_type == "Face", assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "Object should be an IfcSlab"
|
||||
assert snap_point.snap_object.split("/")[0] == "IfcSlab", assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
|
||||
yield from preset_event_simulate(window, "RET", "TAP", x, y)
|
||||
yield "FINISHED"
|
||||
|
||||
|
||||
def test_snap_in_xray_mode(window):
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", 0, 0)
|
||||
area, region = get_area_and_region(window)
|
||||
x = round(area.width * 0.68 + area.x)
|
||||
y = round(area.height * 0.54 + area.y)
|
||||
|
||||
area.spaces[0].shading.show_xray = True
|
||||
|
||||
yield from preset_event_simulate(window, "ESC", "TAP", x, y)
|
||||
|
||||
measure_settings = tool.Project.get_measure_tool_settings()
|
||||
measure_settings.measurement_type = "POLYLINE"
|
||||
for obj in tool.Blender.get_selected_objects():
|
||||
obj.select_set(False)
|
||||
with bpy.context.temp_override(area=area, region=region, space_data=area.spaces[0]):
|
||||
bpy.ops.bim.measure_tool("INVOKE_DEFAULT", measure_type="POLYLINE")
|
||||
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y)
|
||||
yield from preset_event_simulate(window, "LEFTMOUSE", "TAP", x, y)
|
||||
snap_point = tool.Model.get_polyline_props().snap_mouse_point[0]
|
||||
assert_msg = "First click should have a snap_object"
|
||||
assert snap_point.snap_object, assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "snap_object should be a string with the object name"
|
||||
assert type(snap_point.snap_object) == str, assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "Object should be an IfcFurniture"
|
||||
assert snap_point.snap_object.split("/")[0] == "IfcFurniture", assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
|
||||
yield from preset_event_simulate(window, "RET", "TAP", x, y)
|
||||
yield "FINISHED"
|
||||
|
||||
|
||||
def test_snap_far_from_origin(window):
|
||||
bpy.context.view_layer.objects.active = None
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", 0, 0)
|
||||
area, region = get_area_and_region(window)
|
||||
x = round(area.width * 0.155 + area.x)
|
||||
y = round(area.height * 0.18 + area.y)
|
||||
|
||||
yield from preset_event_simulate(window, "ESC", "TAP", x, y)
|
||||
|
||||
bpy.data.objects["IfcBuildingElementProxy/Cube"].select_set(True)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y)
|
||||
with bpy.context.temp_override(area=area, region=region, space_data=area.spaces[0]):
|
||||
bpy.ops.view3d.view_selected()
|
||||
|
||||
measure_settings = tool.Project.get_measure_tool_settings()
|
||||
measure_settings.measurement_type = "POLYLINE"
|
||||
for obj in tool.Blender.get_selected_objects():
|
||||
obj.select_set(False)
|
||||
with bpy.context.temp_override(area=area, region=region, space_data=area.spaces[0]):
|
||||
bpy.ops.bim.measure_tool("INVOKE_DEFAULT", measure_type="POLYLINE")
|
||||
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y)
|
||||
yield from preset_event_simulate(window, "LEFTMOUSE", "TAP", x, y)
|
||||
snap_point = tool.Model.get_polyline_props().snap_mouse_point[0]
|
||||
assert_msg = "First click should have a snap_object"
|
||||
assert snap_point.snap_object, assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "snap_object should be a string with the object name"
|
||||
assert type(snap_point.snap_object) == str, assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "snap_type should be 'Vertex'"
|
||||
assert snap_point.snap_type == "Vertex", assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "x should be 1000000"
|
||||
assert round(snap_point.x, 3) == 1000.0, assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "y should be 1000000"
|
||||
assert round(snap_point.y, 3) == 1000.0, assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
|
||||
yield from preset_event_simulate(window, "RET", "TAP", x, y)
|
||||
yield "FINISHED"
|
||||
|
||||
|
||||
def test_snap_targets(window):
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", 0, 0)
|
||||
area, region = get_area_and_region(window)
|
||||
x = round(area.width * 0.44 + area.x)
|
||||
y = round(area.height * 0.73 + area.y)
|
||||
|
||||
yield from preset_event_simulate(window, "ESC", "TAP", x, y)
|
||||
|
||||
options = []
|
||||
props = tool.Snap.get_snap_props()
|
||||
try:
|
||||
annotations = props.__annotations__
|
||||
except AttributeError:
|
||||
annotations = type(props).__annotations__
|
||||
for prop in annotations.keys():
|
||||
if getattr(props, prop):
|
||||
options.append((prop, props.rna_type.properties[prop].name))
|
||||
|
||||
for prop, name in options:
|
||||
any(setattr(props, prop2, prop2 == prop) for prop2, _ in options) # set prop to true and others to false
|
||||
measure_settings = tool.Project.get_measure_tool_settings()
|
||||
measure_settings.measurement_type = "POLYLINE"
|
||||
for obj in tool.Blender.get_selected_objects():
|
||||
obj.select_set(False)
|
||||
with bpy.context.temp_override(area=area, region=region, space_data=area.spaces[0]):
|
||||
bpy.ops.bim.measure_tool("INVOKE_DEFAULT", measure_type="POLYLINE")
|
||||
snap_types = []
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y)
|
||||
yield from preset_event_simulate(window, "LEFTMOUSE", "TAP", x, y)
|
||||
snap_type = tool.Model.get_polyline_props().snap_mouse_point[0].snap_type
|
||||
snap_types.append(snap_type)
|
||||
|
||||
new_x = x + 200
|
||||
new_y = y - 55
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", new_x, new_y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", new_x, new_y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", new_x, new_y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", new_x, new_y)
|
||||
yield from preset_event_simulate(window, "LEFTMOUSE", "TAP", x, y)
|
||||
snap_type = tool.Model.get_polyline_props().snap_mouse_point[0].snap_type
|
||||
snap_types.append(snap_type)
|
||||
|
||||
new_x = x + 130
|
||||
new_y = y - 358
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", new_x, new_y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", new_x, new_y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", new_x, new_y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", new_x, new_y)
|
||||
yield from preset_event_simulate(window, "LEFTMOUSE", "TAP", x, y)
|
||||
snap_type = tool.Model.get_polyline_props().snap_mouse_point[0].snap_type
|
||||
snap_types.append(snap_type)
|
||||
|
||||
yield from preset_event_simulate(window, "ESC", "TAP", x, y)
|
||||
assert_msg = f"{name} should be in snap_types: {snap_types}"
|
||||
assert name in snap_types
|
||||
_assert_pass(assert_msg)
|
||||
|
||||
|
||||
def test_draw_polyline_wall(window, x, y):
|
||||
yield from preset_event_simulate(window, "ESC", "TAP", x, y)
|
||||
area, region = get_area_and_region(window)
|
||||
|
||||
for obj in tool.Blender.get_selected_objects():
|
||||
obj.select_set(False)
|
||||
with bpy.context.temp_override(area=area, region=region, space_data=area.spaces[0]):
|
||||
props = tool.Model.get_model_props()
|
||||
ifc = tool.Ifc.get()
|
||||
relating_type = ifc.by_type("IfcWallType")[0]
|
||||
|
||||
if tool.Model.get_usage_type(relating_type) == "LAYER2":
|
||||
props.ifc_class = "IfcWallType"
|
||||
props.relating_type_id = str(relating_type.id())
|
||||
|
||||
bpy.ops.bim.draw_polyline_wall("INVOKE_DEFAULT")
|
||||
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y)
|
||||
yield from preset_event_simulate(window, "LEFTMOUSE", "TAP", x, y)
|
||||
yield from preset_event_simulate(window, "X", "TAP", x, y)
|
||||
|
||||
offset = 200
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x + offset, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x + offset, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x + offset, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x + offset, y)
|
||||
yield from preset_event_simulate(window, "LEFTMOUSE", "TAP", x + offset, y)
|
||||
|
||||
yield from preset_event_simulate(window, "RET", "TAP", x, y)
|
||||
element = tool.Ifc.get_entity(bpy.context.selected_objects[0])
|
||||
|
||||
assert_msg = "Created object should be IfcWall"
|
||||
assert element.is_a() == "IfcWall"
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "Created object should be typed by IfcWallType"
|
||||
assert ifcopenshell.util.element.get_type(element).is_a() == "IfcWallType"
|
||||
_assert_pass(assert_msg)
|
||||
# TODO Asset the axis has the same X value
|
||||
|
||||
yield "FINISHED"
|
||||
|
||||
|
||||
def run_tests():
|
||||
module_name = os.getenv("MODULE", "snap")
|
||||
if module_name == "wall":
|
||||
filepath = f"./test/files/wall.ifc"
|
||||
bpy.ops.bim.load_project(filepath=filepath)
|
||||
window = _get_valid_window()
|
||||
test_queue = [lambda w=window: test_draw_polyline_wall(w, 960, 540)]
|
||||
elif module_name == "snap":
|
||||
filepath = f"./test/files/snap.ifc"
|
||||
bpy.ops.bim.load_project(filepath=filepath)
|
||||
window = _get_valid_window()
|
||||
test_queue = [
|
||||
lambda w=window: test_snap_object_detection(w),
|
||||
lambda w=window: test_snap_partially_behind_camera(w),
|
||||
lambda w=window: test_snap_in_xray_mode(w),
|
||||
lambda w=window: test_snap_far_from_origin(w),
|
||||
]
|
||||
elif module_name == "snap-target":
|
||||
filepath = f"./test/files/snap-target.ifc"
|
||||
bpy.ops.bim.load_project(filepath=filepath)
|
||||
window = _get_valid_window()
|
||||
test_queue = [
|
||||
lambda w=window: test_snap_targets(w),
|
||||
]
|
||||
else:
|
||||
cleanup()
|
||||
|
||||
def _next():
|
||||
if not test_queue:
|
||||
cleanup()
|
||||
return
|
||||
test_fn = test_queue.pop(0)
|
||||
# use the shared timer infrastructure
|
||||
run_iter_from_timer(
|
||||
test_fn(),
|
||||
on_complete=_next,
|
||||
on_error=lambda e: _handle_error(e, _next),
|
||||
)
|
||||
|
||||
_next()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
new_project()
|
||||
run_tests()
|
||||
@@ -588,6 +588,10 @@ class TestGenerateStair2DProfile(NewFile):
|
||||
|
||||
|
||||
class TestUsingArrays(NewFile):
|
||||
@staticmethod
|
||||
def _array_objects() -> list[bpy.types.Object]:
|
||||
return [o for o in bpy.data.objects if (e := tool.Ifc.get_entity(o)) and e.is_a("IfcActuator")]
|
||||
|
||||
def setup_array(self, add_second_layer=False, sync_children=False):
|
||||
tool.Project.get_project_props().template_file = "0"
|
||||
bpy.ops.bim.create_project()
|
||||
@@ -605,7 +609,7 @@ class TestUsingArrays(NewFile):
|
||||
props.count = 4
|
||||
props.x = 4
|
||||
props.sync_children = sync_children
|
||||
bpy.ops.bim.edit_array(item=0)
|
||||
bpy.ops.bim.finish_editing_array()
|
||||
|
||||
if add_second_layer:
|
||||
bpy.ops.bim.add_array()
|
||||
@@ -614,14 +618,14 @@ class TestUsingArrays(NewFile):
|
||||
props.count = 3
|
||||
props.y = 4
|
||||
props.sync_children = sync_children
|
||||
bpy.ops.bim.edit_array(item=1)
|
||||
bpy.ops.bim.finish_editing_array()
|
||||
|
||||
def test_remove_array_last_to_first(self):
|
||||
self.setup_array(add_second_layer=True)
|
||||
bpy.ops.bim.remove_array(item=1)
|
||||
assert len(bpy.context.selected_objects) == 4
|
||||
assert len(self._array_objects()) == 4
|
||||
bpy.ops.bim.remove_array(item=0)
|
||||
assert len(bpy.context.selected_objects) == 1
|
||||
assert len(self._array_objects()) == 1
|
||||
|
||||
def test_remove_array_first_to_last(self):
|
||||
self.setup_array(add_second_layer=True)
|
||||
@@ -647,7 +651,7 @@ class TestUsingArrays(NewFile):
|
||||
bpy.ops.bim.apply_array() # apply second layer
|
||||
bpy.ops.bim.apply_array() # apply first layer
|
||||
|
||||
objs = bpy.context.selected_objects
|
||||
objs = self._array_objects()
|
||||
assert len(objs) == 12
|
||||
|
||||
# check BBIM_Array psets are removed
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
|
||||
|
||||
|
||||
#let bill_of_quantities_table = table(
|
||||
#let bill_of_quantities_table(currency: "") = table(
|
||||
columns: (18mm,54mm, 12mm,12mm,12mm,12mm, 20mm, 20mm, 25mm),
|
||||
rows: (6mm, 248mm),
|
||||
align: (center, left, center, center, center, center, center, center, center),
|
||||
@@ -36,12 +36,12 @@
|
||||
top: 1pt,
|
||||
bottom: 1pt
|
||||
),
|
||||
[Hierarchy], [Description], [n°],[l],[w],[h/w], [Quantity], [Rate], [Total]
|
||||
[Hierarchy], [Description], [n°],[l],[w],[h/w], [Quantity], [Rate (#currency)], [Total (#currency)]
|
||||
)
|
||||
|
||||
|
||||
|
||||
#let schedule_of_rates_table = table(
|
||||
#let schedule_of_rates_table(currency: "") = table(
|
||||
columns: (30mm,130mm, 25mm),
|
||||
rows: (6mm, 248mm),
|
||||
align: (center, left, center),
|
||||
@@ -51,12 +51,12 @@
|
||||
top: 1pt,
|
||||
bottom: 1pt
|
||||
),
|
||||
[Identification], [Description], [Rate]
|
||||
[Identification], [Description], [Rate (#currency)]
|
||||
)
|
||||
|
||||
|
||||
|
||||
#let summary_table = table(
|
||||
#let summary_table(currency: "") = table(
|
||||
columns: (18mm,107mm, 30mm, 30mm),
|
||||
rows: (6mm, 248mm),
|
||||
align: (center, left, center, center, center, center, center, center, center),
|
||||
@@ -67,9 +67,9 @@
|
||||
bottom: 1pt
|
||||
),
|
||||
text(size: 8pt)[Hierarchy],
|
||||
text(size: 8pt)[Description],
|
||||
text(size: 8pt)[Sub Total],
|
||||
text(size: 8pt)[Total]
|
||||
text(size: 8pt)[Description],
|
||||
text(size: 8pt)[Sub Total (#currency)],
|
||||
text(size: 8pt)[Total (#currency)]
|
||||
)
|
||||
|
||||
|
||||
@@ -127,7 +127,6 @@
|
||||
#let arrange_summary_row(row, options) = {
|
||||
let name = strong(upper(row.at("Name")))
|
||||
let description = [#par(justify: true, text(8pt, row.at("Description", default: "")))]
|
||||
let total = if row.at("RateSubtotal") == "" {0.0} else {float(row.at("RateSubtotal"))}
|
||||
if row.at("ItemIsASum") == "True" {
|
||||
if row.at("Index") == "1" {
|
||||
// ROOT COST
|
||||
@@ -216,7 +215,8 @@
|
||||
format-decimal(float(row.at("Quantity")))}
|
||||
let rate = if row.at("RateSubtotal") == "" {0.0} else {
|
||||
format-decimal(float(row.at("RateSubtotal")))}
|
||||
let total = if row.at("Quantity") == "" {0.0} else {
|
||||
let total = if row.at("Quantity") == "" or row.at("RateSubtotal") == "" {
|
||||
format-decimal(0.0, places: 2)} else {
|
||||
format-decimal(float(row.at("Quantity")) * float(row.at("RateSubtotal")), places: 2)}
|
||||
|
||||
(
|
||||
@@ -281,18 +281,18 @@
|
||||
#let arrange_schedule_of_rates_row(row, options) = {
|
||||
let name = strong(upper(row.at("Name")))
|
||||
let description = [#par(justify: true, text(8pt, row.at("Description", default: "")))]
|
||||
let unit = table.cell(align: right)[#unit_map.at(row.at("Unit"), default: "")]
|
||||
let unit = table.cell(align: right + bottom)[#unit_map.at(row.at("Unit"), default: "")]
|
||||
let rate = if row.at("RateSubtotal") == "" {0.0} else {
|
||||
format-decimal(float(row.at("RateSubtotal")))}
|
||||
if row.at("ItemIsASum") == "True" {return ()} //skip sections in schedule of rates
|
||||
(
|
||||
row.at("Identification"),
|
||||
if row.at("Identification") == "" {name + linebreak() + description} else {name + linebreak() + description},
|
||||
name + linebreak() + description,
|
||||
[]
|
||||
)
|
||||
(
|
||||
[],
|
||||
table.cell(align: right+bottom)[#unit],
|
||||
unit,
|
||||
table.cell(align: right+bottom)[#rate],
|
||||
)
|
||||
(
|
||||
@@ -342,8 +342,12 @@
|
||||
) = {
|
||||
let data = csv(path, delimiter: delimiter, row-type: dictionary)
|
||||
let new_rows = data.map(item => arrange_summary_row(item, options))
|
||||
let general_total = data.filter(row => row.at("ItemIsASum") == "False")
|
||||
.map(row => float(row.at("RateSubtotal", default: 0.0))*float(row.at("Quantity", default: 0.0)))
|
||||
let general_total = data.filter(row => row.at("ItemIsASum") == "False")
|
||||
.map(row => {
|
||||
let qty = if row.at("Quantity", default: "") == "" { 0.0 } else { float(row.at("Quantity")) }
|
||||
let rate = if row.at("RateSubtotal", default: "") == "" { 0.0 } else { float(row.at("RateSubtotal")) }
|
||||
qty * rate
|
||||
})
|
||||
.sum(default: 0.00)
|
||||
|
||||
set text(size: 10pt)
|
||||
@@ -477,9 +481,9 @@
|
||||
[#counter(page).display("1/1", both: true)]
|
||||
)
|
||||
],
|
||||
background:
|
||||
background:
|
||||
place( top + left, dx: 15mm, dy: 25mm,
|
||||
format_table.at(schedule_type, default: bill_of_quantities_table)
|
||||
(format_table.at(schedule_type, default: bill_of_quantities_table))(currency: project_currency)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -522,9 +526,9 @@
|
||||
set page(
|
||||
background:
|
||||
place( top + left, dx: 15mm, dy: 25mm,
|
||||
format_table.at("SUMMARY")
|
||||
(format_table.at("SUMMARY"))(currency: project_currency)
|
||||
)
|
||||
)
|
||||
create-summary(schedule_path, options)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,8 +149,12 @@ namespace {
|
||||
|
||||
template <>
|
||||
struct dispatch_conversion<ifcopenshell::geometry::taxonomy::type_by_kind::max> {
|
||||
static bool dispatch(ifcopenshell::geometry::kernels::AbstractKernel*, ifcopenshell::geometry::taxonomy::kinds, const ifcopenshell::geometry::taxonomy::ptr& item, IfcGeom::ConversionResults&) {
|
||||
Logger::Error("No conversion for " + std::to_string(item->kind()));
|
||||
static bool dispatch(ifcopenshell::geometry::kernels::AbstractKernel* kernel, ifcopenshell::geometry::taxonomy::kinds, const ifcopenshell::geometry::taxonomy::ptr& item, IfcGeom::ConversionResults&) {
|
||||
std::string created_from;
|
||||
if (item->instance) {
|
||||
created_from = " (created from " + item->instance->declaration().name() + ")";
|
||||
}
|
||||
Logger::Error("No support for " + ifcopenshell::geometry::taxonomy::kind_to_string(item->kind()) + created_from + " in kernel " + kernel->geometry_library());
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -169,8 +173,12 @@ namespace {
|
||||
|
||||
template <>
|
||||
struct dispatch_with_upgrade<ifcopenshell::geometry::taxonomy::upgrades::max> {
|
||||
static bool dispatch(ifcopenshell::geometry::kernels::AbstractKernel*, const ifcopenshell::geometry::taxonomy::ptr& item, IfcGeom::ConversionResults&) {
|
||||
Logger::Error("No conversion with upgrade for " + std::to_string(item->kind()));
|
||||
static bool dispatch(ifcopenshell::geometry::kernels::AbstractKernel* kernel, const ifcopenshell::geometry::taxonomy::ptr& item, IfcGeom::ConversionResults&) {
|
||||
std::string created_from;
|
||||
if (item->instance) {
|
||||
created_from = " (created from " + item->instance->declaration().name() + ")";
|
||||
}
|
||||
Logger::Error("No support (after considering item upgrade) for " + ifcopenshell::geometry::taxonomy::kind_to_string(item->kind()) + created_from + " in kernel " + kernel->geometry_library());
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -35,13 +35,25 @@ namespace IfcGeom {
|
||||
class Transformation {
|
||||
private:
|
||||
ifcopenshell::geometry::Settings settings_;
|
||||
ifcopenshell::geometry::taxonomy::matrix4::ptr matrix_;
|
||||
ifcopenshell::geometry::taxonomy::matrix4::ptr matrix_, matrix_orig_units_;
|
||||
public:
|
||||
Transformation(const ifcopenshell::geometry::Settings& settings, const ifcopenshell::geometry::taxonomy::matrix4::ptr& matrix)
|
||||
: settings_(settings)
|
||||
, matrix_(matrix)
|
||||
{}
|
||||
Transformation(const ifcopenshell::geometry::Settings& settings, const ifcopenshell::geometry::taxonomy::matrix4::ptr& matrix)
|
||||
: settings_(settings), matrix_(matrix)
|
||||
{
|
||||
const bool convert = settings.get<ifcopenshell::geometry::settings::ConvertBackUnits>().get();
|
||||
auto unit_magnitude = settings.get<ifcopenshell::geometry::settings::LengthUnit>().get();
|
||||
if (matrix_ && convert && unit_magnitude != 1.0) {
|
||||
matrix_orig_units_ = ifcopenshell::geometry::taxonomy::make<ifcopenshell::geometry::taxonomy::matrix4>(*matrix);
|
||||
// only multiple the translation components of the matrix with the unit magnitude, not the rotation/scaling components
|
||||
matrix_orig_units_->components().col(3).head<3>() /= unit_magnitude;
|
||||
} else {
|
||||
matrix_orig_units_ = nullptr;
|
||||
}
|
||||
}
|
||||
const ifcopenshell::geometry::taxonomy::matrix4::ptr& data() const {
|
||||
if (matrix_orig_units_) {
|
||||
return matrix_orig_units_;
|
||||
}
|
||||
if (matrix_) {
|
||||
return matrix_;
|
||||
}
|
||||
|
||||
@@ -116,6 +116,16 @@ template <typename Kernel>
|
||||
using plane_map = std::map<typename Kernel::Plane_3, typename Kernel::Plane_3, PlaneLess<Kernel>>;
|
||||
// using plane_map = std::unordered_map<typename Kernel::Plane_3, typename Kernel::Plane_3, PlaneHash<Kernel>>;
|
||||
|
||||
// Lexicographic comparator for CGAL Point_d (operator< is deleted in CGAL 6.x)
|
||||
struct Point_d_4d_Less {
|
||||
using Point_d = CGAL::Epick_d<CGAL::Dimension_tag<4>>::Point_d;
|
||||
bool operator()(const Point_d& a, const Point_d& b) const {
|
||||
return std::lexicographical_compare(
|
||||
a.cartesian_begin(), a.cartesian_end(),
|
||||
b.cartesian_begin(), b.cartesian_end());
|
||||
}
|
||||
};
|
||||
|
||||
// Snap halfspace planes
|
||||
// search_radius: max cartesian distance in plane equation parameters as 4d points in space
|
||||
template <typename Kernel>
|
||||
@@ -131,8 +141,8 @@ plane_map<Kernel> snap_halfspaces(const std::list<CGAL::Plane_3<Kernel>>& planes
|
||||
|
||||
plane_map<Kernel> result;
|
||||
|
||||
std::map<Point_d, std::set<Point_d>> neighbours;
|
||||
std::map<Point_d, std::list<CGAL::Plane_3<Kernel>>> originals;
|
||||
std::map<Point_d, std::set<Point_d, Point_d_4d_Less>, Point_d_4d_Less> neighbours;
|
||||
std::map<Point_d, std::list<CGAL::Plane_3<Kernel>>, Point_d_4d_Less> originals;
|
||||
std::vector<Point_d> planes_as_point;
|
||||
|
||||
for (auto& p : planes) {
|
||||
@@ -205,7 +215,7 @@ plane_map<Kernel> snap_halfspaces_2(const std::list<CGAL::Plane_3<Kernel>>& plan
|
||||
plane_map<Kernel> result;
|
||||
|
||||
std::vector<Point_d> planes_as_point;
|
||||
std::map<Point_d, CGAL::Plane_3<Kernel>> normalized_to_original;
|
||||
std::map<Point_d, CGAL::Plane_3<Kernel>, Point_d_4d_Less> normalized_to_original;
|
||||
|
||||
for (auto& p : planes_fixed) {
|
||||
// @todo can we skip normalization (simply divide by largest component perhaps)
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
#include <stack>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <cstdint>
|
||||
#include <BRepExtrema_TriangleSet.hxx>
|
||||
#include <BRepLProp_SLProps.hxx>
|
||||
#include <BVH_BinaryTree.hxx>
|
||||
|
||||
@@ -136,17 +136,39 @@ bool IfcGeom::OpenCascadeKernel::convert_openings(const IfcUtil::IfcBaseEntity*
|
||||
for (auto entity_part : parts) {
|
||||
bool is_manifold = util::is_manifold(entity_part);
|
||||
|
||||
if (!is_manifold) {
|
||||
// force sewing, edge identity might have been mudied by FixAdvFace.FixOrientation.MSG5 to fix interior loop winding order
|
||||
TopTools_ListOfShape list;
|
||||
IfcGeom::util::shape_to_face_list(entity_part, list);
|
||||
IfcGeom::util::create_solid_from_faces(list, entity_part, settings_.get<settings::Precision>().get(), true);
|
||||
is_manifold = util::is_manifold(entity_part);
|
||||
if (is_manifold) {
|
||||
Logger::Warning("Successfully sewed non-manifold first operand");
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_manifold) {
|
||||
if (settings_.get<settings::MakeVolume>().get()) {
|
||||
BOPAlgo_MakerVolume mv;
|
||||
mv.AddArgument(entity_part);
|
||||
mv.SetAvoidInternalShapes(true);
|
||||
// mv.SetFuzzyValue(settings_.get<settings::Precision>().get());
|
||||
std::optional<std::string> failure;
|
||||
try {
|
||||
mv.Perform();
|
||||
entity_part = mv.Shape();
|
||||
Logger::Warning("Sucessfully detected exterior volume to non-manifold first operand");
|
||||
auto entity_part_2 = mv.Shape();
|
||||
if (IfcGeom::util::count(entity_part_2, TopAbs_FACE) == 0) {
|
||||
failure = "Empty result (no faces) for BOPAlgo_MakerVolume; original was " + std::to_string(IfcGeom::util::count(entity_part, TopAbs_FACE));
|
||||
} else {
|
||||
is_manifold = util::is_manifold(entity_part_2);
|
||||
Logger::Warning(std::string("Sucessfully detected exterior volume to non-manifold first operand; shape is now ") + (is_manifold ? std::string("manifold") : std::string("non-manifold")));
|
||||
entity_part = entity_part_2;
|
||||
}
|
||||
} catch (const Standard_Failure& e) {
|
||||
Logger::Warning("MakeVolume failed: " + std::string(e.GetMessageString()), entity);
|
||||
failure.emplace(e.GetMessageString());
|
||||
}
|
||||
if (failure) {
|
||||
Logger::Warning("MakeVolume failed: " + *failure, entity);
|
||||
}
|
||||
} else {
|
||||
Logger::Warning("Non-manifold first operand, use --make-volume to try and make manifold");
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "clash_utils.h"
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
#include <cfloat>
|
||||
|
||||
#define GU_CULLING_EPSILON_RAY_TRIANGLE FLT_EPSILON*FLT_EPSILON
|
||||
#define PX_MAX_F32 3.4028234663852885981170418348452e+38F
|
||||
|
||||
@@ -154,7 +154,13 @@ IfcGeom::OpenCascadeKernel::faceset_helper::faceset_helper(
|
||||
|
||||
typedef std::array<int, 2> edge_t;
|
||||
typedef std::set<edge_t> edge_set_t;
|
||||
std::set<edge_set_t> edge_sets;
|
||||
// When a single face fills an interior loop, their edge_sets (canonicalized edges) will be identical.
|
||||
// We can differentiate in this scenario in two ways:
|
||||
// - std::map<edge_t, bool> retain the edge order from the bool passed to the loop_() lambda
|
||||
// - std::pair<bool, edge_set_t> with pair::first populated from external (FaceBound / OuterBound)
|
||||
// The second has been found more reliable for typical models, because inner bound winding can be wrong.
|
||||
// The can be made more resilient by first checking correct population of external and falling back to approach 1.
|
||||
std::set<std::pair<bool, edge_set_t>> edge_sets;
|
||||
|
||||
for (auto& loop : loops) {
|
||||
std::vector<std::pair<int, int> > segments;
|
||||
@@ -165,12 +171,12 @@ IfcGeom::OpenCascadeKernel::faceset_helper::faceset_helper(
|
||||
segments.push_back(std::make_pair(C, D));
|
||||
});
|
||||
|
||||
if (edge_sets.find(segment_set) != edge_sets.end()) {
|
||||
if (edge_sets.find({loop->external.get_value_or(false), segment_set}) != edge_sets.end()) {
|
||||
duplicate_faces++;
|
||||
duplicates_.insert(loop->identity());
|
||||
continue;
|
||||
}
|
||||
edge_sets.insert(segment_set);
|
||||
edge_sets.insert({loop->external.get_value_or(false), segment_set});
|
||||
|
||||
if (segments.size() >= 3) {
|
||||
for (auto& p : segments) {
|
||||
|
||||
@@ -300,7 +300,11 @@ bool OpenCascadeKernel::convert(const taxonomy::sweep_along_curve::ptr scs, Topo
|
||||
|
||||
if (applied_temporary_offset) {
|
||||
gp_Trsf trsf;
|
||||
trsf.SetTranslation(gp_Vec(-mean.x(), -mean.y(), -mean.z()));
|
||||
// Restore original position: add back the mean subtracted from the
|
||||
// directrix points above. Previously negated, which placed the swept
|
||||
// solid at -mean instead of its original location for geometry far
|
||||
// from the origin.
|
||||
trsf.SetTranslation(gp_Vec(mean.x(), mean.y(), mean.z()));
|
||||
result.Move(trsf);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "../../ifcparse/IfcLogger.h"
|
||||
|
||||
#include <mutex>
|
||||
#include <cstdint>
|
||||
|
||||
#define INCLUDE_SCHEMA(x) STRINGIFY(../../ifcparse/x.h)
|
||||
#include INCLUDE_SCHEMA(IfcSchema)
|
||||
|
||||
+14
-7
@@ -17,6 +17,13 @@
|
||||
#include <tuple>
|
||||
#include <exception>
|
||||
#include <numeric>
|
||||
#include <cstdint>
|
||||
#include <cmath>
|
||||
#include <array>
|
||||
#include <limits>
|
||||
#include <functional>
|
||||
#include <algorithm>
|
||||
#include <stdexcept>
|
||||
|
||||
#ifndef TAXONOMY_USE_UNIQUE_PTR
|
||||
#ifndef TAXONOMY_USE_NAKED_PTR
|
||||
@@ -1625,19 +1632,19 @@ typedef item const* ptr;
|
||||
// @todo Sad... now that we have templated collection members,
|
||||
// we can't generally use collection_base anymore as a cast target.
|
||||
if (auto s = std::dynamic_pointer_cast<taxonomy::collection>(i)) {
|
||||
visit<taxonomy::collection>(s, fn);
|
||||
ifcopenshell::geometry::visit<taxonomy::collection>(s, fn);
|
||||
} else if (auto s = std::dynamic_pointer_cast<taxonomy::loop>(i)) {
|
||||
visit<taxonomy::loop>(s, fn);
|
||||
ifcopenshell::geometry::visit<taxonomy::loop>(s, fn);
|
||||
} else if (auto s = std::dynamic_pointer_cast<taxonomy::face>(i)) {
|
||||
visit<taxonomy::face>(s, fn);
|
||||
ifcopenshell::geometry::visit<taxonomy::face>(s, fn);
|
||||
} else if (auto s = std::dynamic_pointer_cast<taxonomy::shell>(i)) {
|
||||
visit<taxonomy::shell>(s, fn);
|
||||
ifcopenshell::geometry::visit<taxonomy::shell>(s, fn);
|
||||
} else if (auto s = std::dynamic_pointer_cast<taxonomy::solid>(i)) {
|
||||
visit<taxonomy::solid>(s, fn);
|
||||
ifcopenshell::geometry::visit<taxonomy::solid>(s, fn);
|
||||
} else if (auto s = std::dynamic_pointer_cast<taxonomy::loft>(i)) {
|
||||
visit<taxonomy::loft>(s, fn);
|
||||
ifcopenshell::geometry::visit<taxonomy::loft>(s, fn);
|
||||
} else if (auto s = std::dynamic_pointer_cast<taxonomy::boolean_result>(i)) {
|
||||
visit<taxonomy::boolean_result>(s, fn);
|
||||
ifcopenshell::geometry::visit<taxonomy::boolean_result>(s, fn);
|
||||
}
|
||||
else {
|
||||
fn(i);
|
||||
|
||||
@@ -81,6 +81,13 @@ def validate_type(
|
||||
if not preferred_item and remaining_items:
|
||||
preferred_item = remaining_items[0]
|
||||
|
||||
# preferred_item must not appear in remaining_items — if it was selected from
|
||||
# that list, leaving it in causes add_boolean to union it with itself, and the
|
||||
# subsequent Items filter then removes ALL items (including preferred_item),
|
||||
# leaving Items=[] which guess_type maps to "MappedRepresentation".
|
||||
if preferred_item in remaining_items:
|
||||
remaining_items = [i for i in remaining_items if i != preferred_item]
|
||||
|
||||
if remaining_items:
|
||||
ifcopenshell.api.geometry.add_boolean(file, preferred_item, remaining_items, "UNION")
|
||||
representation.Items = [i for i in representation.Items if i not in remaining_items]
|
||||
|
||||
@@ -916,7 +916,7 @@ class FacetTransformer(lark.Transformer):
|
||||
if self.elements:
|
||||
self.results.append(self.elements)
|
||||
self.elements = set()
|
||||
self.has_additive_facet_in_current_list = False
|
||||
self.has_additive_facet_in_current_list = False
|
||||
|
||||
def instance(self, args):
|
||||
self.has_additive_facet_in_current_list = True
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
#include "utils.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <boost/shared_ptr.hpp>
|
||||
|
||||
class aggregate_of_instance;
|
||||
|
||||
@@ -201,7 +201,7 @@ namespace {
|
||||
if (character >= 0x20 && character <= 0x7e) {
|
||||
stream.put((char)character);
|
||||
} else {
|
||||
stream << "\\u" << character;
|
||||
stream << "\\u" << static_cast<uint32_t>(character);
|
||||
}
|
||||
});
|
||||
return stream.str();
|
||||
|
||||
@@ -36,6 +36,9 @@
|
||||
|
||||
#endif
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
#include <boost/optional.hpp>
|
||||
#include <boost/shared_ptr.hpp>
|
||||
#include <boost/logic/tribool.hpp>
|
||||
|
||||
@@ -4,9 +4,12 @@
|
||||
#ifdef IFOPSH_WITH_ROCKSDB
|
||||
#include <rocksdb/table.h>
|
||||
#include <rocksdb/convenience.h>
|
||||
#include <rocksdb/version.h>
|
||||
#endif
|
||||
|
||||
#include <fstream>
|
||||
#include <memory>
|
||||
#include <utility>
|
||||
#include <sys/types.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
@@ -410,10 +413,8 @@ IfcUtil::IfcBaseClass* IfcParse::impl::rocks_db_file_storage::assert_existance(s
|
||||
}
|
||||
|
||||
namespace {
|
||||
rocksdb::DB* init_db(const std::string& filepath, bool readonly) {
|
||||
rocksdb::DB* db = nullptr;
|
||||
std::unique_ptr<rocksdb::DB> init_db(const std::string& filepath, bool readonly) {
|
||||
#ifdef IFOPSH_WITH_ROCKSDB
|
||||
|
||||
rocksdb::Options options;
|
||||
// options.disable_auto_compactions = true;
|
||||
options.create_if_missing = true;
|
||||
@@ -445,16 +446,31 @@ namespace {
|
||||
options.table_factory.reset(rocksdb::NewBlockBasedTableFactory(tbo));
|
||||
|
||||
rocksdb::Status status;
|
||||
std::unique_ptr<rocksdb::DB> db;
|
||||
if (readonly) {
|
||||
#if ROCKSDB_MAJOR > 9 || (ROCKSDB_MAJOR == 9 && ROCKSDB_MINOR >= 11)
|
||||
status = rocksdb::DB::OpenForReadOnly(options, filepath, &db);
|
||||
#else
|
||||
rocksdb::DB* raw = nullptr;
|
||||
status = rocksdb::DB::OpenForReadOnly(options, filepath, &raw);
|
||||
db.reset(raw);
|
||||
#endif
|
||||
} else {
|
||||
#if ROCKSDB_MAJOR > 9 || (ROCKSDB_MAJOR == 9 && ROCKSDB_MINOR >= 11)
|
||||
status = rocksdb::DB::Open(options, filepath, &db);
|
||||
#else
|
||||
rocksdb::DB* raw = nullptr;
|
||||
status = rocksdb::DB::Open(options, filepath, &raw);
|
||||
db.reset(raw);
|
||||
#endif
|
||||
}
|
||||
if (!status.ok()) {
|
||||
return nullptr;
|
||||
}
|
||||
#endif // IFOPSH_WITH_ROCKSDB#
|
||||
return db;
|
||||
#else
|
||||
return nullptr;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,12 +479,12 @@ IfcParse::impl::rocks_db_file_storage::rocks_db_file_storage(const std::string&
|
||||
: file(ffile)
|
||||
, db(init_db(filepath, readonly))
|
||||
// @todo streaming serializer does not populate the byguid map
|
||||
, byguid_internal_(db, "g|")
|
||||
, byguid_internal_(db.get(), "g|")
|
||||
, byguid_(&byguid_internal_, [this](size_t v) { return assert_existance(v, entityinstance_ref); }, [](IfcUtil::IfcBaseClass* v) { return v->identity(); })
|
||||
, instance_ids_(db, "i|")
|
||||
, instance_ids_(db.get(), "i|")
|
||||
, instance_by_name_(&instance_ids_, [this](size_t v) { return assert_existance(v, entityinstance_ref); })
|
||||
, bytype_(db, "t|")
|
||||
, byref_excl_(db, "v|")
|
||||
, bytype_(db.get(), "t|")
|
||||
, byref_excl_(db.get(), "v|")
|
||||
// @todo by_identity is probably not correct here, this mapping is Name -> Identity, so Fn should have access to full pair?
|
||||
// , byidentity_(&byid_, [this](size_t v) { return assert_existance(v, by_identity); }, [](IfcUtil::IfcBaseClass* v) { return v->identity(); })
|
||||
{
|
||||
@@ -491,7 +507,6 @@ IfcParse::impl::rocks_db_file_storage::~rocks_db_file_storage()
|
||||
assert(s.ok());
|
||||
|
||||
db->Close();
|
||||
delete db;
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
#include <boost/circular_buffer.hpp>
|
||||
#include <iterator>
|
||||
#include <map>
|
||||
#include <cstdint>
|
||||
|
||||
#ifdef IFOPSH_WITH_ROCKSDB
|
||||
#include <rocksdb/merge_operator.h>
|
||||
|
||||
@@ -25,7 +25,9 @@
|
||||
#include <algorithm>
|
||||
#include <boost/algorithm/string.hpp>
|
||||
#include <cctype>
|
||||
#include <cstdint>
|
||||
#include <iterator>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
#include <boost/shared_ptr.hpp>
|
||||
#include <set>
|
||||
#include <vector>
|
||||
#include <algorithm>
|
||||
|
||||
namespace IfcParse {
|
||||
class declaration;
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
#include <utility>
|
||||
#include <iterator>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
template <typename T>
|
||||
struct is_std_tuple : std::false_type {};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user