Compare commits

..

1 Commits

Author SHA1 Message Date
Ryan Schultz 2cb9ff6946 fix 8119: Fix ReferenceError in snap object cache lookup
Cached SnapObj entries holding references to freed Blender
objects raised ReferenceError on .obj.name access. Evict
stale entries at the start of create_snap_obj.

Generated with the assistance of an AI coding tool.
2026-05-29 07:49:44 -05:00
105 changed files with 2003 additions and 15341 deletions
+3 -7
View File
@@ -258,14 +258,10 @@ if(WITH_ROCKSDB)
set(ROCKSDB_LIBRARIES "IFCOPENSHELL_RocksDB")
target_compile_definitions(IFCOPENSHELL_RocksDB INTERFACE IFOPSH_WITH_ROCKSDB)
set(SWIG_DEFINES ${SWIG_DEFINES} -DIFOPSH_WITH_ROCKSDB)
# Shared binaries for `rocksdb` only support limited API (only `c.h`), but we use `db.h` API.
# So rocksdb supported only as a static library.
# See https://github.com/facebook/rocksdb/issues/981.
if(TARGET RocksDB::rocksdb)
target_link_libraries(IFCOPENSHELL_RocksDB INTERFACE RocksDB::rocksdb)
elseif(TARGET RocksDB::rocksdb-shared)
target_link_libraries(IFCOPENSHELL_RocksDB INTERFACE RocksDB::rocksdb-shared)
else()
message(FATAL_ERROR "RocksDB found but neither RocksDB::rocksdb nor RocksDB::rocksdb-shared target exists")
endif()
target_link_libraries(IFCOPENSHELL_RocksDB INTERFACE RocksDB::rocksdb)
if(WITH_ZSTD)
# @todo do we actually need the zstd include dir or rather just pass
+1 -9
View File
@@ -88,15 +88,7 @@ if(NOT HDF5_INCLUDE_DIR OR NOT HDF5_LIBRARY_DIR)
mark_as_advanced(HDF5_DIR)
if(HDF5_DIR)
message(STATUS "HDF5: found config at '${HDF5_DIR}'.")
if(TARGET hdf5_cpp-static)
set(HDF5_LIBRARIES hdf5_cpp-static)
elseif(TARGET hdf5_cpp-shared)
set(HDF5_LIBRARIES hdf5_cpp-shared)
elseif(TARGET hdf5::hdf5_cpp-shared)
set(HDF5_LIBRARIES hdf5::hdf5_cpp-shared)
else()
find_package(HDF5 REQUIRED COMPONENTS CXX)
endif()
set(HDF5_LIBRARIES hdf5_cpp-static)
else()
# If it failed, still try to find as a module.
# E.g. on Ubuntu `libhdf5-dev` doesn't provie hdf5-config.cmake.
+1 -9
View File
@@ -192,11 +192,7 @@ endif
# Provides networkx graph analysis for project dependency calculations
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download networkx --dest=./wheels
# Required by IFCDiff
# Pinned <9.1: deepdiff 9.1.0 adds cachebox<6,>=5.2 which only ships macOS x86_64
# wheels for macosx_10_12+ and is incompatible with our macos py311 --platform
# macosx_10_10_x86_64 target. Revisit once the macos py311 platform tag is bumped
# to 10_13 (matching py312/py313).
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download "deepdiff<9.1" --dest=./wheels
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download deepdiff --dest=./wheels
# Required by IFCCSV and ifcopenshell.util.selector
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download lark --dest=./wheels
# Required by IFC4D
@@ -360,10 +356,6 @@ else
pytest test/tool/test_$(MODULE).py --maxfail=1
endif
.PHONY: test-modal
test-modal:
blender --enable-event-simulate --python test/modal/test_modal.py --window-maximized
# Reregistering test is not added to the standard test suite because during unregister
# Blender removes all Bonsai dependencies breaking dev-environment symlinks.
.PHONY: test-reregister
+16 -2
View File
@@ -29,6 +29,18 @@ from bpy_extras.io_utils import ExportHelper, ImportHelper
from . import handler, operator, parametric_lifecycle, prop, ui
def _parametric_gizmo_preference_classes() -> list[type]:
"""Resolves the registry-driven ``GizmoPreferences<X>`` classes for the
``classes`` list below. ``import bonsai.tool`` is kept local to surface
the load-order constraint: it relies on ``from . import handler, …``
above having primed the
``tool/ifc.py → bim/ifc.py → bim/handler.py → bonsai.tool`` cycle."""
import bonsai.tool as tool
return tool.Parametric.iter_gizmo_preference_classes(ui)
try:
from bonsai.translations import translations_dict
except ImportError:
@@ -159,6 +171,10 @@ 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
@@ -271,7 +287,6 @@ 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)
@@ -330,7 +345,6 @@ 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
+18 -108
View File
@@ -41,18 +41,13 @@ from bonsai.bim.decorator_cache import (
from bonsai.bim.ifc import IfcStore, get_cache_or_detect_lock
from bonsai.bim.module.aggregate.decorator import AggregateDecorator
from bonsai.bim.module.georeference.decorator import GeoreferenceDecorator
from bonsai.bim.module.model.array import (
ArrayPreviewDecorator,
ArraySelectionHighlightDecorator,
)
from bonsai.bim.module.model.data import AuthoringData
from bonsai.bim.module.model.decorator import (
BoundingBoxDecorator,
SlabDirectionDecorator,
WallAxisDecorator,
WallFilletPreviewDecorator,
)
from bonsai.bim.module.model.wall import WallGizmoPreviewDecorator
from bonsai.bim.module.model.preview_base import discard_pending_previews
from bonsai.bim.module.nest.decorator import NestDecorator
cwd = os.path.dirname(os.path.realpath(__file__))
@@ -120,13 +115,19 @@ def active_object_callback():
def update_bim_tool_props():
"""Selection-driven BIM Tool sync: re-target user-intent enums
(ifc_class, relating_type_id) AND refresh header values
(extrusion_depth, length, x_angle) for the new active object."""
ctx = _resolve_bim_tool_context()
if ctx is None:
"""update BIM Tools props (such as extrusion_depth, length and x_angle) when active object changes"""
obj = bpy.context.active_object
# bunch of checks to see if we're in a valid state
if not obj:
return
mode = bpy.context.mode
current_tool = bpy.context.workspace.tools.from_space_view3d_mode(mode)
if not current_tool or current_tool.idname not in tool.Blender.get_list_of_tools():
return
element = tool.Ifc.get_entity(obj)
if not element:
return
obj, current_tool, element = ctx
props = tool.Model.get_model_props()
aprops = tool.Drawing.get_annotation_props()
@@ -149,14 +150,7 @@ def update_bim_tool_props():
return
if is_bim_tool:
try:
props.ifc_class = element_type.is_a()
except TypeError:
# ifc_class only lists element/space types present in the model, so an
# unsupported type (e.g. a raw IfcTypeProduct) or a stale item list mid-
# rebuild raises `enum "<class>" not found`. Skip rather than crash the
# handler — it re-fires on the next selection and the panel resyncs.
pass
props.ifc_class = element_type.is_a()
# Only assign when the target enum is the one that lists this type — otherwise
# we hit `enum "<id>" not found in (...)` if the user selects an element of a
@@ -176,48 +170,6 @@ def update_bim_tool_props():
if is_annotation_tool:
return
_read_headers_into_props(obj, element)
def refresh_bim_tool_headers():
"""Push the active IFC entity's current header float values
(extrusion_depth, length, x_angle) into ``BIMModelProperties``.
Enum-safe: never writes user-intent enum slots, which are owned by
the selection callback."""
ctx = _resolve_bim_tool_context()
if ctx is None:
return
obj, current_tool, element = ctx
if current_tool.idname not in tool.Blender.get_property_header_tools():
return
_read_headers_into_props(obj, element)
def _resolve_bim_tool_context():
"""Return ``(obj, current_tool, element)`` when an active BIM workspace
tool sees a resolvable IFC element; ``None`` otherwise. Defensive
against stripped operator contexts — a missing ``active_object`` /
``mode`` / ``workspace`` short-circuits to ``None`` instead of raising."""
obj = tool.Blender.get_active_object()
if not obj:
return None
mode = getattr(bpy.context, "mode", None)
workspace = getattr(bpy.context, "workspace", None)
if mode is None or workspace is None:
return None
current_tool = workspace.tools.from_space_view3d_mode(mode)
if not current_tool or current_tool.idname not in tool.Blender.get_list_of_tools():
return None
element = tool.Ifc.get_entity(obj)
if not element:
return None
return obj, current_tool, element
def _read_headers_into_props(obj, element):
"""Populate ``BIMModelProperties`` header values from the active
object's IFC extrusion. Enum-safe: writes only header floats, never
user-intent enum slots, so it is safe to call on the post-commit hook."""
representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
if not representation:
return
@@ -235,7 +187,6 @@ def _read_headers_into_props(obj, element):
if not AuthoringData.is_loaded:
AuthoringData.load()
props = tool.Model.get_model_props()
if AuthoringData.data["active_material_usage"] == "LAYER2":
x_angle = get_x_angle(extrusion)
axis = tool.Model.get_wall_axis(obj)["reference"]
@@ -432,33 +383,10 @@ 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, load-transient parametric state, and the
multi-instance lock probe."""
settings, scene-bound caches, draft-flag healing, multi-instance lock probe,
and previews discarded so saved preview state never resurfaces on reopen."""
global global_subscription_owner
active_object_key = bpy.types.LayerObjects, "active"
bpy.msgbus.subscribe_rna(
@@ -469,7 +397,8 @@ def _apply_save_file_invariants(scene: bpy.types.Scene) -> None:
ifcopenshell.api.owner.settings.get_application = get_application
AuthoringData.type_thumbnails = {}
tool.Parametric.on_load_post(scene)
tool.Parametric.heal_stale_edit_flags()
discard_pending_previews(scene)
if tool.Ifc.get() and bpy.data.is_saved:
props = tool.Blender.get_bim_props()
@@ -533,10 +462,6 @@ 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:
@@ -551,21 +476,6 @@ def _install_viewport_overlays() -> None:
SlabDirectionDecorator.install(bpy.context)
if model_props.show_bounding_box:
BoundingBoxDecorator.install(bpy.context)
# Always-installed: draw() self-polls on Scene.BIMPreviewProperties.
# wall_fillet.is_active, so installation has no cost when no preview
# is open. No corresponding addon-preference toggle.
WallFilletPreviewDecorator.install(bpy.context)
# Always-installed: 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()
+1 -1
View File
@@ -552,7 +552,7 @@ class IfcStore:
BrickStore.end_transaction()
IfcStore.end_transaction(operator)
bonsai.bim.handler.refresh_ui_data()
tool.Parametric.refresh_post_commit(operator)
tool.Parametric.refresh_post_commit()
if method == "MODAL":
cls.modal_in_progress = False
+2 -2
View File
@@ -1219,8 +1219,8 @@ class IfcImporter:
if element not in elements_to_import:
continue
for i in range(len(data)):
tool.Array.set_children_lock_state(element, i, True)
tool.Array.constrain_children_to_parent(element)
tool.Blender.Modifier.Array.set_children_lock_state(element, i, True)
tool.Blender.Modifier.Array.constrain_children_to_parent(element)
def update_linked_aggregates(self):
# TODO Remove this after a while. See commit 17d6b8a
@@ -139,7 +139,6 @@ 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,
@@ -156,6 +155,5 @@ 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
+1 -3
View File
@@ -48,14 +48,12 @@ 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 = key_prefix + attribute["name"]
op.key = attribute["name"]
# TODO: reimplement, see #1222
# if "IfcSite/" in context.active_object.name or "IfcBuilding/" in context.active_object.name:
@@ -138,26 +138,15 @@ classes = (
gizmos.GizmoArrow2D,
gizmos.GizmoCone,
gizmos.GizmoDimension,
gizmos.GizmoLockOpen,
gizmos.GizmoLockClosed,
gizmos.GizmoLock,
gizmos.GizmoArc,
gizmos.GizmoLinkToggle,
gizmos.GizmoFillet,
gizmos.GizmoWallCornerIcon,
gizmos.GizmoWallTeeIcon,
gizmos.GizmoPen,
gizmos.GizmoValidate,
gizmos.GizmoCancel,
gizmos.GizmoPlus,
gizmos.GizmoMinus,
gizmos.GizmoTrash,
gizmos.GizmoArrayParent,
gizmos.GizmoArrayAll,
gizmos.GizmoArrayLayerIndicator,
gizmos.GizmoCountLabel,
gizmos.GizmoMerge,
gizmos.GizmoSplit,
gizmos.GizmoUnjoin,
gizmos.GizmoExtend,
gizmos.GizmoExtendVertical,
gizmos.GizmoOffsetExterior,
@@ -165,7 +154,6 @@ classes = (
gizmos.GizmoOffsetInterior,
gizmos.GizmoAddOpening,
gizmos.GizmoCycle,
gizmos.GizmoMenu,
# Drawing-specific gizmos
gizmos.UglyDotGizmo,
gizmos.ExtrusionGuidesGizmo,
File diff suppressed because it is too large Load Diff
@@ -44,12 +44,8 @@ class ViewportData:
@classmethod
def load(cls):
# Populate data BEFORE flipping is_loaded so a raising ``mode()``
# call doesn't leave the class half-loaded (flag set, dict empty).
# Subsequent items-callback invocations skip load() on a True flag
# and would hit ``cls.data["mode"]`` → KeyError.
cls.data = {"mode": cls.mode()}
cls.is_loaded = True
cls.data = {"mode": cls.mode()}
@classmethod
def mode(cls) -> tool.Blender.BLENDER_ENUM_ITEMS:
@@ -80,9 +76,9 @@ class ViewportData:
modes.append(edit_mode)
elif element.is_a("IfcGridAxis"):
modes.append(edit_mode)
elif tool.Parametric.is_roof(element):
elif tool.Blender.Modifier.is_roof(element):
modes.append(edit_mode)
elif tool.Parametric.is_railing(element):
elif tool.Blender.Modifier.is_railing(element):
modes.append(edit_mode)
elif item_mode not in modes:
modes.append(item_mode)
@@ -60,7 +60,6 @@ import bonsai.core.root
import bonsai.core.spatial
import bonsai.tool as tool
from bonsai.bim.ifc import IfcStore
from bonsai.bim.module.model import preview_base
from bonsai.bim.module.model.decorator import ProfileDecorator
if TYPE_CHECKING:
@@ -1027,10 +1026,10 @@ class OverrideDelete(bpy.types.Operator):
for array_parent in array_parents:
array_parent_obj = tool.Ifc.get_object(array_parent)
data = [(i, data) for i, data in enumerate(tool.Array.get_modifiers_data(array_parent))]
data = [(i, data) for i, data in enumerate(tool.Blender.Modifier.Array.get_modifiers_data(array_parent))]
# NOTE: there is a way to remove arrays more precisely but it's more complex
for i, modifier_data in reversed(data):
children = set(tool.Array.get_children_objects(modifier_data))
children = set(tool.Blender.Modifier.Array.get_children_objects(modifier_data))
if children.issubset(selected_objects):
with context.temp_override(active_object=array_parent_obj):
bpy.ops.bim.remove_array(item=i)
@@ -1289,7 +1288,9 @@ class OverrideDuplicateMove(bpy.types.Operator):
all_objects_to_select.add(part_obj)
# Non-IFC duplicates aren't tracked in old_to_new but are left selected by duplicate_ifc_objects
all_objects_to_select.update(obj for obj in context.selected_objects if not tool.Ifc.get_entity(obj))
all_objects_to_select.update(
obj for obj in context.selected_objects if not tool.Ifc.get_entity(obj)
)
# Deselect everything first
bpy.ops.object.select_all(action="DESELECT")
@@ -2227,8 +2228,6 @@ class OverrideEscape(bpy.types.Operator):
bpy.ops.bim.hide_all_openings()
elif tool.Aggregate.get_aggregate_props().in_aggregate_mode:
bpy.ops.bim.disable_aggregate_mode()
elif preview_base.try_cancel_active_preview(context):
pass
elif active_object := context.active_object:
if tool.Blender.Modifier.try_canceling_editing_modifier_parameters_or_path(active_object):
pass
@@ -2270,8 +2269,6 @@ 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"}
@@ -2358,7 +2355,6 @@ 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)
@@ -2367,7 +2363,6 @@ 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)
@@ -2495,9 +2490,9 @@ class OverrideModeSetObject(bpy.types.Operator, tool.Ifc.Operator):
profile = tool.Ifc.get().by_id(profile_id)
if tool.Ifc.get_object(profile): # We are editing an arbitrary profile
bpy.ops.bim.edit_arbitrary_profile()
elif tool.Parametric.is_railing(element):
elif tool.Blender.Modifier.is_railing(element):
bpy.ops.bim.finish_editing_railing_path()
elif tool.Parametric.is_roof(element):
elif tool.Blender.Modifier.is_roof(element):
bpy.ops.bim.finish_editing_roof_path()
elif tool.Model.get_usage_type(element) == "PROFILE":
bpy.ops.bim.edit_extrusion_axis()
+2 -25
View File
@@ -19,7 +19,6 @@
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
@@ -484,32 +483,10 @@ 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.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.prop(context.active_object, "location", text="Location")
row = self.layout.row()
row.label(text="Rotation:")
for i, axis in enumerate("XYZ"):
self.layout.prop(context.active_object, "rotation_euler", index=i, text=axis)
row.prop(context.active_object, "rotation_euler", text="Rotation")
if props.blender_offset_type != "NONE":
row = self.layout.row(align=True)
+7 -45
View File
@@ -31,7 +31,6 @@ from . import (
external,
grid,
handler,
host_add_opening_gizmo,
mep,
opening,
product,
@@ -51,28 +50,17 @@ from . import (
classes = (
array.AddArray,
array.CancelEditingArray,
array.DisableEditingArray,
array.EditArray,
array.EnableEditingArray,
array.FinishEditingArray,
array.ApplyArray,
array.RegenerateArray,
array.RemoveArray,
array.SelectAllArrayObjects,
array.SelectArrayParent,
array.ArrayParentGizmoClick,
array.EditArrayFromChild,
array.Input3DCursorXArray,
array.Input3DCursorYArray,
array.Input3DCursorZArray,
array.EnableEditingParametric,
array.AddArrayFromFeatureEdit,
array.ArrayGizmoClick,
array.ToggleArrayMethod,
array.RemoveArrayLayerFromEdit,
array.InputArrayCount,
array.AdjustArrayCount,
array.GizmoArrayEdition,
array.GizmoArrayChild,
product.AddDefaultType,
product.AddEmptyType,
product.AddOccurrence,
@@ -95,22 +83,15 @@ classes = (
wall.EnableEditingWall,
wall.ExtendWallHeightToCursor,
wall.ExtendWallsToUnderside,
wall.RegenerateWallToUnderside,
wall.ExtendWallsToWall,
wall.ExtendWallsToPolylinePoint,
wall.ExtendWallToCursor,
wall.FinishEditingWall,
wall.FlipWall,
host_add_opening_gizmo.GizmoHostAddOpening,
host_add_opening_gizmo.GizmoHostToggleOpenings,
wall.GizmoWallAddOpening,
wall.GizmoWallEdition,
wall.GizmoWallExtendVertically,
wall.GizmoWallFilletPreview,
wall.GizmoWallFilletReedit,
wall.GizmoWallFilletToggleOpenings,
wall.GizmoWallJoinIntersection,
wall.GizmoWallLinkToggle,
wall.GizmoWallUnjoinSingle,
wall.JoinWallsIntersection,
wall.MergeWall,
wall.OffsetWalls,
@@ -118,13 +99,8 @@ classes = (
wall.RotateWall90,
wall.SplitWall,
wall.SplitWallAtCursor,
wall.UnjoinWallPathConnection,
wall.ToggleWallOpenings,
wall.UnjoinWalls,
wall.EnableWallFilletPreview,
wall.FinishWallFilletPreview,
wall.CancelWallFilletPreview,
wall.EnableWallFilletPreviewFromCorner,
wall.CreateWallFillet,
opening.AddBoolean,
opening.CloneOpening,
opening.EditOpenings,
@@ -136,7 +112,6 @@ classes = (
opening.RemoveBoolean,
opening.SelectBoolean,
opening.ShowOpenings,
opening.ToggleHostOpenings,
opening.UpdateOpeningsFocus,
profile.ChangeCardinalPoint,
profile.ChangeProfileDepth,
@@ -186,8 +161,6 @@ classes = (
prop.BIMWallProperties,
prop.BIMPolylineProperties,
prop.BIMExternalParametricGeometryProperties,
prop.BIMWallFilletPreviewProperties,
prop.BIMPreviewProperties,
ui.BIM_PT_array,
ui.BIM_PT_stair,
ui.BIM_PT_wall,
@@ -211,8 +184,7 @@ classes = (
stair.ToggleStairProperty,
stair.AdjustStairTreads,
stair.SetStairTreads,
stair.InputStairTreads,
stair.PickStairType,
stair.CycleStairType,
stair.GizmoStairEdition,
sverchok_modifier.CreateNewSverchokGraph,
sverchok_modifier.UpdateDataFromSverchok,
@@ -225,7 +197,7 @@ classes = (
window.FinishEditingWindow,
window.EnableEditingWindow,
window.RemoveWindow,
window.PickWindowType,
window.CycleWindowType,
window.GizmoWindowEdition,
door.BIM_OT_add_door,
door.AddDoor,
@@ -234,7 +206,7 @@ classes = (
door.EnableEditingDoor,
door.RemoveDoor,
door.ToggleDoorSwing,
door.PickDoorType,
door.CycleDoorType,
door.GizmoDoorEdition,
railing.BIM_OT_add_railing,
railing.CopyRailingParameters,
@@ -251,13 +223,11 @@ 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,
@@ -321,7 +291,6 @@ def register():
bpy.types.Object.BIMExternalParametricGeometryProperties = bpy.props.PointerProperty(
type=prop.BIMExternalParametricGeometryProperties
)
bpy.types.Scene.BIMPreviewProperties = bpy.props.PointerProperty(type=prop.BIMPreviewProperties)
bpy.types.VIEW3D_MT_add.prepend(ui.add_menu)
bpy.app.handlers.load_post.append(handler.load_post)
@@ -330,12 +299,6 @@ 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)
@@ -346,7 +309,6 @@ 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
+1 -246
View File
@@ -108,7 +108,7 @@ class ProfileDecorator:
obj = context.active_object
if obj is None or obj.mode != "EDIT":
if obj.mode != "EDIT":
if exit_edit_mode_callback:
ProfileDecorator.uninstall()
exit_edit_mode_callback()
@@ -2029,248 +2029,3 @@ 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
+62 -88
View File
@@ -37,9 +37,8 @@ import bonsai.core.root
import bonsai.tool as tool
from bonsai.bim.module.drawing import gizmos as gizmo
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
from bonsai.bim.module.model.wall_offset_gizmos import WALL_OFFSET_GIZMO_CONFIGS
from bonsai.bim.module.model.window import create_bm_box, create_bm_window
from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin, PickTypeMixin
from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin
if TYPE_CHECKING:
from bonsai.bim.module.model.prop import BIMDoorProperties
@@ -581,7 +580,7 @@ class _DoorEditMixin(FeatureModifierEditMixin):
@classmethod
def _is_element_type(cls, element):
return tool.Parametric.is_door(element)
return tool.Blender.Modifier.is_door(element)
@classmethod
def _get_props(cls, obj: bpy.types.Object):
@@ -630,7 +629,7 @@ class RemoveDoor(bpy.types.Operator, tool.Ifc.Operator):
def remove_door_on_object(self, obj: bpy.types.Object) -> None:
element = tool.Ifc.get_entity(obj)
assert element
if not tool.Parametric.is_door(element):
if not tool.Blender.Modifier.is_door(element):
return
props = tool.Model.get_door_props(obj)
props.is_editing = False
@@ -645,8 +644,12 @@ class RemoveDoor(bpy.types.Operator, tool.Ifc.Operator):
class ToggleDoorSwing(bpy.types.Operator, tool.Ifc.Operator):
"""Toggle door swing direction and optionally flip door geometry.
Shift+Click (when flip_geometry=True): Flip geometry only without changing door direction"""
bl_idname = "bim.toggle_door_swing"
bl_label = "Change Door Swing"
bl_label = "Toggle Door Swing"
bl_options = {"REGISTER", "UNDO"}
flip_geometry: bpy.props.BoolProperty(name="Flip Geometry", default=False)
@@ -657,15 +660,6 @@ class ToggleDoorSwing(bpy.types.Operator, tool.Ifc.Operator):
name="Skip Direction Change", default=False, options={"HIDDEN", "SKIP_SAVE"}
)
@classmethod
def description(cls, context: bpy.types.Context, properties: bpy.types.OperatorProperties) -> str:
if properties.flip_geometry:
return (
"Swing the door from the opposite side of the wall. "
"Shift+click: mirror the door without changing which side it opens to"
)
return "Move the door hinge to the opposite side"
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]:
self.skip_direction_change = event.shift
return self.execute(context)
@@ -692,7 +686,7 @@ class ToggleDoorSwing(bpy.types.Operator, tool.Ifc.Operator):
if not element:
return {"CANCELLED"}
is_door = tool.Parametric.is_door(element)
is_door = tool.Blender.Modifier.is_door(element)
if self.flip_geometry:
tool.Geometry.flip_object(obj, self.flip_local_axes)
@@ -706,20 +700,20 @@ class ToggleDoorSwing(bpy.types.Operator, tool.Ifc.Operator):
return {"FINISHED"}
class PickDoorType(bpy.types.Operator, tool.Ifc.Operator, PickTypeMixin):
"""Pick a door type from a popup menu."""
class CycleDoorType(bpy.types.Operator, tool.Ifc.Operator, gizmo.CycleTypeMixin):
"""Cycle through available door types. Shift+click to cycle in reverse."""
bl_idname = "bim.pick_door_type"
bl_label = "Pick Door Type"
bl_idname = "bim.cycle_door_type"
bl_label = "Cycle Door Type"
bl_options = {"REGISTER", "UNDO"}
element_checker = tool.Parametric.is_door
props_getter = tool.Model.get_door_props
element_checker = "is_door"
props_getter = "get_door_props"
type_literal = tool.Model.DoorType
type_attr = "door_type"
def _execute(self, context: bpy.types.Context) -> set[str]:
return self._pick_type(context)
return self._cycle_type(context)
class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
@@ -732,7 +726,7 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
enable_editing_operator = "bim.enable_editing_door"
finish_editing_operator = "bim.finish_editing_door"
cancel_editing_operator = "bim.cancel_editing_door"
pick_type_operator = "bim.pick_door_type"
cycle_type_operator = "bim.cycle_door_type"
# Declarative dimension gizmo configuration with visibility and position
# matrix_position lambdas replace the get_dimension_matrix_* methods
@@ -839,44 +833,14 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
p.get_transom_window_center_z(),
),
),
*WALL_OFFSET_GIZMO_CONFIGS,
]
# Big quarter-arc hit shapes cover much of the door face — without a
# negative select_bias they would steal clicks from the small dimension
# and edit gizmos drawn on top of them.
SWING_ARC_SELECT_BIAS = -1000.0
swing_arc_operator = "bim.toggle_door_swing"
swing_arc_props = [
gizmo.SwingArcConfig(
name="primary",
visibility_condition=lambda p: p.is_editing and "SLIDING" not in p.door_type,
hinge_x=lambda p: (
p.overall_width if p.door_type.endswith("RIGHT") and "DOUBLE_DOOR" not in p.door_type else 0.0
),
hinge_y=lambda p: p.lining_offset,
panel_width=lambda p: p.overall_width / 2 if "DOUBLE_DOOR" in p.door_type else p.overall_width,
x_mirror=lambda p: p.door_type.endswith("RIGHT") and "DOUBLE_DOOR" not in p.door_type,
),
gizmo.SwingArcConfig(
name="secondary",
visibility_condition=lambda p: p.is_editing
and "DOUBLE_DOOR" in p.door_type
and "SLIDING" not in p.door_type,
hinge_x=lambda p: p.overall_width,
hinge_y=lambda p: p.lining_offset,
panel_width=lambda p: p.overall_width / 2,
x_mirror=lambda _p: True,
),
]
props_getter = tool.Model.get_door_props
props_getter = "get_door_props"
gizmo_pref_name = "door"
@classmethod
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
return tool.Parametric.is_door(element)
return tool.Blender.Modifier.is_door(element)
def get_icon_y_extent(self, props: "BIMDoorProperties") -> tuple[float, float]:
"""Get Y extents for door icon positioning.
@@ -894,20 +858,24 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
return (furthest_y, furthest_y)
def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None:
"""Create one (main, flip) swing-arc pair per ``swing_arc_props`` entry.
Stored as ``self.gizmo_swing_arc_<name>`` and ``self.gizmo_swing_arc_<name>_flip``
and pinned to ``SWING_ARC_SELECT_BIAS`` so other door gizmos win selection."""
"""Create door-specific swing arc gizmos."""
prefs = tool.Blender.get_addon_preferences()
main_color = prefs.decorator_color_special[:3]
flip_color = prefs.decorator_color_background[:3]
for cfg in self.swing_arc_props:
main = self.create_arc_gizmo(main_color, self.swing_arc_operator, flip_geometry=False)
flip = self.create_arc_gizmo(flip_color, self.swing_arc_operator, flip_geometry=True)
for gz in (main, flip):
gz.select_bias = self.SWING_ARC_SELECT_BIAS
setattr(self, f"gizmo_swing_arc_{cfg.name}", main)
setattr(self, f"gizmo_swing_arc_{cfg.name}_flip", flip)
inactive_color = prefs.decorator_color_background[:3]
special_color = prefs.decorator_color_special[:3]
self.gizmo_door_type = self.create_arc_gizmo(
special_color,
"bim.toggle_door_swing",
prop_path="BIMDoorProperties.door_type",
flip_geometry=False,
)
self.gizmo_flip_arc = self.create_arc_gizmo(
inactive_color,
"bim.toggle_door_swing",
prop_path="BIMDoorProperties.door_type",
flip_geometry=True,
flip_local_axes="XY",
)
def _refresh_element_specific(
self, context: bpy.types.Context, mw: Matrix, props: "BIMDoorProperties" # noqa: ARG002
@@ -926,23 +894,29 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
self._update_view_dependent_dimensions(context, mw, props)
def update_swing_gizmos(self, mw: Matrix, props: "BIMDoorProperties") -> None:
"""Position each declared swing-arc pair per its config + props state."""
mirror_y = Matrix.Scale(-1, 4, (0, 1, 0))
for cfg in self.swing_arc_props:
main = getattr(self, f"gizmo_swing_arc_{cfg.name}")
flip = getattr(self, f"gizmo_swing_arc_{cfg.name}_flip")
show = cfg.visibility_condition(props)
main_visible = self.update_gizmo_visibility(main, show)
flip_visible = self.update_gizmo_visibility(flip, show)
if not (main_visible or flip_visible):
continue
x_flip = Matrix.Scale(-1, 4, (1, 0, 0)) if cfg.x_mirror(props) else Matrix.Identity(4)
transform = (
Matrix.Translation(V_(cfg.hinge_x(props), cfg.hinge_y(props), 0))
@ Matrix.Scale(cfg.panel_width(props), 4)
@ x_flip
)
if main_visible:
main.matrix_basis = mw @ transform
if flip_visible:
flip.matrix_basis = mw @ transform @ mirror_y
"""Update swing gizmo position and color based on editing state."""
prefs = self.get_addon_prefs()
door_gizmo_prefs = prefs.gizmos.door
door_type_visible = self.update_gizmo_visibility(
self.gizmo_door_type, props.is_editing, door_gizmo_prefs.swing_arc
)
flip_arc_visible = self.update_gizmo_visibility(
self.gizmo_flip_arc, props.is_editing, door_gizmo_prefs.flip_arc
)
if not door_type_visible and not flip_arc_visible:
return
swing_x_offset = props.overall_width if "RIGHT" in props.door_type else 0.0
base_swing_transform = Matrix.Translation(V_(swing_x_offset, props.lining_offset, 0)) @ Matrix.Scale(
props.overall_width, 4
)
if door_type_visible:
self.gizmo_door_type.matrix_basis = mw @ base_swing_transform
self.gizmo_door_type.color = prefs.decorations_colour[:3]
if flip_arc_visible:
mirror_y = Matrix.Scale(-1, 4, (0, 1, 0))
self.gizmo_flip_arc.matrix_basis = mw @ base_swing_transform @ mirror_y
@@ -1,221 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Generic single-click "Add Opening" gizmo for hosts (walls, slabs, roofs).
One GizmoGroup serves every IFC host type that exposes ``HasOpenings``:
parametric LAYER2 walls, any ``IfcSlab``, and any ``IfcRoof``. The poll
guards host-host pairings so this gizmo never overlaps with the existing
wall-join / extend-vertically gizmos. The positioner dispatches on element
type walls use axis-projection + camera-facing-Y math (which requires the
parametric layer-set); slabs and roofs use a world-Z face bias driven by
the void object's elevation against the host's bounding box."""
import bpy
from mathutils import Vector
import bonsai.tool as tool
from bonsai.bim.module.drawing import gizmos as gizmo
from bonsai.bim.module.model.wall import (
_get_wall_geom_cached,
_wall_camera_facing_icon_y,
_wall_gizmo_poll_gate,
_WallGeomCachedBillboardingMixin,
)
def is_supported_host(element) -> bool:
"""Total predicate (None → False). Walls accept either a parametric
LAYER2 wall OR a fillet-corner wall (both expose a usable axis +
layer-set for the anchor math); slabs and roofs only need the bound
box so any IfcSlab / IfcRoof qualifies regardless of parametric
modifier state."""
if element is None:
return False
return tool.Parametric.is_path_connectable_wall(element) or element.is_a("IfcSlab") or element.is_a("IfcRoof")
def _resolve_active_host(context: bpy.types.Context, n_selected: int):
"""Shared poll prologue: gizmo gate + selection cardinality + active-in-
selected + IFC entity lookup + supported-host predicate. Returns the
active element on success, ``None`` on any failure callers chain their
feature-specific checks past the early-return."""
if not _wall_gizmo_poll_gate(context):
return None
selected = tool.Blender.get_selected_objects()
if len(selected) != n_selected:
return None
active = context.active_object
if active is None or active not in selected:
return None
element = tool.Ifc.get_entity(active)
if not element or not is_supported_host(element):
return None
return element
class GizmoHostAddOpening(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin):
"""Activates when a host element (wall / slab / roof) is the active object
and exactly one other selected object is *not* itself a host.
Renders a single ``VIEW3D_GT_add_opening`` icon at the void object's
projected location on the host. A click dispatches ``bim.add_opening``,
which handles any element exposing the ``HasOpenings`` inverse.
Per-frame positioning keeps the icon facing the camera as the viewport
orbits."""
bl_idname = "OBJECT_GGT_bim_host_add_opening"
bl_label = "Host Add Opening Gizmo"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT"}
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
element = _resolve_active_host(context, n_selected=2)
if element is None:
return False
# The operator itself filters on HasOpenings, but checking here keeps
# the icon from appearing on host classes that can't accept openings
# in the active IFC schema.
if not hasattr(element, "HasOpenings"):
return False
active = context.active_object
other = next(o for o in tool.Blender.get_selected_objects() if o is not active)
# Host + host pairings are claimed by host-specific gizmos (wall-join,
# extend-vertical, …) — suppress here so the add-opening icon never
# stacks on top of them.
if is_supported_host(tool.Ifc.get_entity(other)):
return False
return True
def setup(self, context: bpy.types.Context) -> None:
default_color, highlight_color = self.get_decoration_colors()
self.add_opening_icon = self.setup_icon_gizmo(
"VIEW3D_GT_add_opening", default_color, highlight_color, "bim.add_opening"
)
def position_gizmos(self, context: bpy.types.Context) -> None:
host_obj = context.active_object
if not host_obj:
return
selected = tool.Blender.get_selected_objects()
other = next((o for o in selected if o is not host_obj), None)
if not other:
return
element = tool.Ifc.get_entity(host_obj)
if not element:
return
if tool.Parametric.is_path_connectable_wall(element):
world_pos = wall_anchor(context, self, host_obj, other)
else:
world_pos = layer3_anchor(host_obj, other)
if world_pos is None:
return
self.add_opening_icon.matrix_basis = gizmo.billboarded_at(world_pos, gizmo.get_billboard_rotation(context))
def wall_anchor(
context: bpy.types.Context, group: bpy.types.GizmoGroup, wall_obj: bpy.types.Object, other: bpy.types.Object
) -> Vector | None:
"""World-space anchor for the add-opening icon on a wall host: void origin
projected onto the wall reference-line X (clamped to wall extents), lifted to
the camera-facing wall-local Y."""
geom = _get_wall_geom_cached(group, wall_obj)
if not geom:
return None
mw = wall_obj.matrix_world
wall_local = mw.inverted() @ other.matrix_world.translation
local_x = max(geom["anchor_x"], min(wall_local.x, geom["anchor_x"] + geom["length"]))
icon_y = _wall_camera_facing_icon_y(context, mw, geom)
base_world = mw @ Vector((local_x, icon_y, 0.0))
top_world = mw @ Vector((local_x, icon_y, geom["height"] + gizmo.BaseParametricGizmoGroup.ICON_Z_OFFSET))
return gizmo.BaseParametricGizmoGroup.pick_visible_anchor(context, base_world, top_world)
def layer3_anchor(host_obj: bpy.types.Object, other: bpy.types.Object) -> Vector:
"""World-space anchor for the add-opening icon on a LAYER3 host (slab / roof):
void's world XY, lifted just above the host's top face. Predictable height
regardless of where the void sits vertically clicking the icon places the
opening at the void's XY, and the operator handles the actual cut depth."""
bbox = tool.Blender.get_object_world_bounding_box(host_obj)
anchor_xy = other.matrix_world.translation.xy
top_z = bbox["max_z"] + gizmo.BaseParametricGizmoGroup.ICON_Z_OFFSET
return Vector((anchor_xy.x, anchor_xy.y, top_z))
def host_toggle_anchor(host_obj: bpy.types.Object) -> Vector:
"""Object origin XY, lifted just above the topmost mesh vertex. Tracks
the parametric origin (useful reference even when the mesh extends
asymmetrically) and the visible top face (stays clear of sloped or
stepped bodies)."""
origin = host_obj.matrix_world.translation
top_z = tool.Blender.get_object_world_bounding_box(host_obj)["max_z"] + gizmo.BaseParametricGizmoGroup.ICON_Z_OFFSET
return Vector((origin.x, origin.y, top_z))
class GizmoHostToggleOpenings(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin):
"""Fallback toggle-openings icon for hosts that lack their own
parametric-edit toolbar slabs today, plus any foreign-authored
IfcRoof that carries no BBIM_Roof pset (so ``GizmoRoofEdition`` doesn't
poll for it). Walls and parametric roofs already render an idle-row
toggle next to the pen and are excluded from this poll.
When slab parametric-edit lands the slab branch will pen-row-handle
its own toggle; updating the exclusion predicate here is the only
migration step needed."""
bl_idname = "OBJECT_GGT_bim_host_toggle_openings"
bl_label = "Host Toggle Openings Gizmo"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT"}
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
element = _resolve_active_host(context, n_selected=1)
if element is None:
return False
if not tool.Geometry.has_openings(element):
return False
# Skip when a per-feature parametric-edit gizmo already surfaces
# an idle-row toggle for this element — walls and parametric roofs
# both render their own toggle in the pen row.
if tool.Parametric.is_path_connectable_wall(element):
return False
if tool.Parametric.is_roof(element):
return False
return True
def setup(self, context: bpy.types.Context) -> None:
default_color, highlight_color = self.get_decoration_colors()
self.toggle_openings_icon = self.setup_icon_gizmo(
"VIEW3D_GT_add_opening", default_color, highlight_color, "bim.toggle_host_openings"
)
def position_gizmos(self, context: bpy.types.Context) -> None:
host_obj = context.active_object
if not host_obj:
return
self.toggle_openings_icon.matrix_basis = gizmo.billboarded_at(
host_toggle_anchor(host_obj), gizmo.get_billboard_rotation(context)
)
+24 -257
View File
@@ -41,187 +41,8 @@ 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(
@@ -229,15 +50,9 @@ class FilledOpeningGenerator:
filling_obj: bpy.types.Object,
voided_obj: bpy.types.Object,
target: Optional[Vector] = None,
preserve_placement: bool = False,
) -> Union[None, str]:
"""
:param target: Target opening position. If ommited, cursor position is used.
:param preserve_placement: If True, keep ``filling_obj.matrix_world`` as-is
and skip the snap-to-wall-axis / rl1-rl2 Z-default logic. The opening
is still created at the filling's current world position. Useful
when the caller (e.g. the SHIFT-add-opening gizmo flow) has
already positioned the filling intentionally.
:return: None if there was no errors, otherwise returns a string with error message.
"""
props = tool.Model.get_model_props()
@@ -259,7 +74,7 @@ class FilledOpeningGenerator:
should_set_z_level = False
# Sometimes, the voided_obj may be an aggregate, which won't have any representation.
if not preserve_placement and voided_obj.data:
if voided_obj.data:
raycast = voided_obj.closest_point_on_mesh(voided_obj.matrix_world.inverted() @ target, distance=0.01)
if not raycast[0]:
target = filling_obj.matrix_world.translation.copy()
@@ -743,29 +558,6 @@ class AddBoolean(Operator, tool.Ifc.Operator):
tool.Root.reload_item_decorator()
class ToggleHostOpenings(Operator, tool.Ifc.Operator):
bl_idname = "bim.toggle_host_openings"
bl_label = "Toggle Openings"
bl_description = "Show or hide opening fills (doors and windows) in the viewport\n\nHotkey: Alt+O"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if not tool.Model.has_selected_ifc_objects():
cls.poll_message_set("No IFC objects selected.")
return False
return True
def _execute(self, context: bpy.types.Context) -> set[str]:
# Opening visibility is independent of host geometry — don't commit any
# active parametric edit; the user can keep editing the host.
if tool.Model.get_model_props().openings:
bpy.ops.bim.edit_openings(apply_all=True)
else:
bpy.ops.bim.show_openings()
return {"FINISHED"}
class ShowOpenings(Operator, tool.Ifc.Operator):
bl_idname = "bim.show_openings"
bl_label = "Show Openings"
@@ -1149,6 +941,7 @@ class SelectBoolean(Operator):
return {"FINISHED"}
# TODO: merge with ProfileDecorator?
class DecorationsHandler:
installed = None
@@ -1158,7 +951,6 @@ 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):
@@ -1167,46 +959,15 @@ class DecorationsHandler:
except ValueError:
pass
cls.installed = None
uninstall_decoration_cache_handlers()
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
def draw_batch(self, shader_type, content_pos, color, indices=None):
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
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 = self.line_shader if shader_type == "LINES" else self.shader
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
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:
@@ -1278,20 +1039,23 @@ 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:
line_verts, verts, edges_indices, tris = _get_cached_world_draw_data(obj)
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]
color = selected_elements_color if obj in context.selected_objects else special_elements_color
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"),
)
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)
if "HalfSpaceSolid" in obj.name:
# Arrow shape
@@ -1305,4 +1069,7 @@ 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_lines_with_occlusion(verts, color, edges, cache_key=(obj.session_uid, "arrow"))
self.draw_batch("LINES", verts, color, edges)
if obj.mode != "EDIT":
bm.free()
@@ -75,7 +75,6 @@ class PolylineOperator:
self.is_typing = False
self.snap_angle = None
self.snapping_points = []
self.unit_scale = 1.0
self.instructions = {
"Cycle Input": {"icons": True, "keys": ["EVENT_TAB"]},
"Distance Input": {"icons": True, "keys": ["EVENT_D"]},
@@ -60,12 +60,8 @@ def get_preview_props(context: bpy.types.Context, attr: str):
Returns ``None`` if the umbrella isn't attached yet — true briefly
during addon register and during plug-out, so polls / draw callbacks
must defend against ``None`` rather than assuming the prop is always
available. Also tolerates contexts without a ``scene`` attribute
(test mocks built from ``SimpleNamespace``)."""
scene = getattr(context, "scene", None)
if scene is None:
return None
preview = getattr(scene, "BIMPreviewProperties", None)
available."""
preview = getattr(context.scene, "BIMPreviewProperties", None)
return getattr(preview, attr, None) if preview is not None else None
@@ -78,17 +74,6 @@ def is_preview_active(context: bpy.types.Context, attr: str) -> bool:
return bool(props is not None and props.is_active)
def any_preview_active(context: bpy.types.Context) -> bool:
"""``True`` if any registered preview is currently open. Sister gizmo
polls call this to hide themselves uniformly during ANY preview, so a
new preview registered in ``PREVIEW_CANCEL_OPS`` automatically gates
every parametric gizmo without each one growing a specific check."""
for attr, _op_name in PREVIEW_CANCEL_OPS:
if is_preview_active(context, attr):
return True
return False
# --- Lazy closure factories --------------------------------------------------
#
# Used by preview gizmo groups when wiring ``BIM_GT_gizmo_dimension``'s
+5 -87
View File
@@ -103,12 +103,8 @@ def update_type_page(self: "BIMModelProperties", context: bpy.types.Context) ->
def update_relating_array_from_object(self: "BIMArrayProperties", context: bpy.types.Context) -> None:
# Skip the cleanup-time clear: Finish/Cancel sets relating_array_object back to None,
# which has no source to hydrate from. Only the user-driven pick (None → some array)
# should auto-enter edit on the picked source's layer 0.
if self.relating_array_object is None:
return
bpy.ops.bim.enable_editing_array(item=0)
bpy.ops.bim.enable_editing_array(item=self.is_editing)
return
def is_object_array_applicable(self: "BIMArrayProperties", obj: bpy.types.Object) -> bool:
@@ -401,13 +397,8 @@ class BIMModelProperties(PropertyGroup):
class BIMArrayProperties(PropertyGroup):
is_editing: bpy.props.BoolProperty(
default=False,
description="True while an array layer is in parametric edit mode. The specific layer is in editing_item_index.",
)
editing_item_index: bpy.props.IntProperty(
default=-1,
description="Index of the array layer currently being edited; -1 when not in edit mode.",
is_editing: bpy.props.IntProperty(
default=-1, description="Currently edited array index. -1 if not in array editing mode."
)
count: bpy.props.IntProperty(name="Count", default=0, min=0)
x: bpy.props.FloatProperty(name="X", default=0, subtype="DISTANCE")
@@ -423,15 +414,6 @@ class BIMArrayProperties(PropertyGroup):
name="Method",
default="OFFSET",
)
per_child_opening: bpy.props.BoolProperty(
name="Per-Child Opening",
description=(
"When the array parent fills a wall (or any voidable host), give each array child its own opening + "
"filling pair so the host is cut once per child. Disable to leave the host uncut by the children — "
"only the parent's original opening remains"
),
default=True,
)
relating_array_object: bpy.props.PointerProperty(
type=bpy.types.Object,
name="Copy Array Properties",
@@ -440,15 +422,13 @@ class BIMArrayProperties(PropertyGroup):
)
if TYPE_CHECKING:
is_editing: bool
editing_item_index: int
is_editing: int
count: int
x: float
y: float
z: float
use_local_space: bool
method: Literal["OFFSET", "DISTRIBUTE"]
per_child_opening: bool
sync_children: bool
relating_array_object: Union[bpy.types.Object, None]
@@ -1922,65 +1902,3 @@ class BIMExternalParametricGeometryProperties(bpy.types.PropertyGroup):
geometry_source: Literal["GEONODES", "IFCSVERCHOK"]
geo_nodes: Union[bpy.types.GeometryNodeTree, None]
sverchok_nodes: Union[sverchok.node_tree.SverchCustomTree, None]
class 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.Parametric.is_railing(element)
return tool.Blender.Modifier.is_railing(element)
@classmethod
def _get_props(cls, obj: bpy.types.Object):
+22 -145
View File
@@ -17,8 +17,8 @@
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import json
from math import atan2, cos, degrees, pi, radians, tan
from typing import Any, ClassVar, Literal, Union
from math import cos, pi, radians, tan
from typing import Any, Literal, Union
import bmesh
import bpy
@@ -32,11 +32,9 @@ from mathutils import Quaternion, Vector
import bonsai.core.root
import bonsai.tool as tool
from bonsai.bim.module.drawing import gizmos as gizmo
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig, IconSlot
from bonsai.bim.module.model.data import RoofData, refresh
from bonsai.bim.module.model.decorator import ProfileDecorator
from bonsai.bim.parametric_lifecycle import CycleTypeMixin, PathPreservingEditMixin
from bonsai.bim.parametric_lifecycle import PathPreservingEditMixin
# reference:
# https://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcRoof.htm
@@ -213,13 +211,7 @@ def generate_hipped_roof_bmesh(
new_verts = [bm.verts.new(v) for v in verts]
new_edges = [bm.edges.new([new_verts[vi] for vi in edge]) for edge in edges]
# Skip degenerate faces. ``bpypolyskel.polygonize`` can emit a face whose
# vertex list contains the same index twice on certain footprint /
# slope combinations (the straight-skeleton collapses two ridge events
# onto the same vertex). ``bm.faces.new`` rejects those with
# ``found the same (BMVert) used multiple times``; dropping them keeps
# the rest of the roof intact instead of aborting the whole rebuild.
new_faces = [bm.faces.new([new_verts[vi] for vi in face]) for face in faces if len(set(face)) == len(face)]
new_faces = [bm.faces.new([new_verts[vi] for vi in face]) for face in faces]
if mode == "HEIGHT": # Calculate the angle we ended up with.
new_faces[0].normal_update()
@@ -405,11 +397,6 @@ def generate_hipped_roof_bmesh(
if is_internal:
faces_to_delete.add(face)
bmesh.ops.delete(bm, geom=list(faces_to_delete), context="FACES")
# Final pass: ``remove_doubles`` + internal-face deletion above can leave
# the bottom slab faces flipped at low slopes, where the kernel's
# "outward" inference becomes ambiguous on near-flat geometry. Recompute
# once more on the final topology so the eave plane points down.
bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:])
return bm
@@ -631,7 +618,7 @@ class _RoofEditMixin(PathPreservingEditMixin):
@classmethod
def _is_element_type(cls, element):
return tool.Parametric.is_roof(element)
return tool.Blender.Modifier.is_roof(element)
@classmethod
def _get_props(cls, obj: bpy.types.Object):
@@ -649,142 +636,32 @@ class _RoofEditMixin(PathPreservingEditMixin):
def _update_modifier_bmesh(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
update_roof_modifier_bmesh(obj)
@classmethod
def _restore_viewport_after_cancel(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
"""Rebuild the roof bmesh from the just-restored draft props so the
viewport reverts to the pre-edit geometry. Same helper the modal
edits use, just driven by the cancelled props instead of in-flight
drag values."""
update_roof_modifier_bmesh(obj)
EnableEditingRoof, FinishEditingRoof, CancelEditingRoof = tool.Parametric.build_edit_lifecycle(
"roof",
_RoofEditMixin,
labels=(
("Enable Editing Roof", ""),
("Finish Editing Roof", ""),
("Cancel Editing Roof", ""),
),
module_name=__name__,
)
# Fixed horizontal run for the slope gizmo: the draggable value is the
# vertical rise at this distance from the anchor, in the rise/run convention.
_ROOF_SLOPE_REFERENCE_RUN = 1.0
# One degree shy of vertical; avoids tan() blow-up when the user drags the
# rise handle past the gizmo's anchor.
_ROOF_MAX_SLOPE_ANGLE = pi / 2 - 0.001
def _roof_has_openings() -> bool:
"""``visible_when`` predicate for the toggle_openings idle slot. True iff
the active object's IFC element exposes a non-empty HasOpenings inverse."""
obj = bpy.context.active_object
if obj is None:
return False
element = tool.Ifc.get_entity(obj)
if element is None:
return False
return tool.Geometry.has_openings(element)
class CycleRoofGenerationMethod(bpy.types.Operator, tool.Ifc.Operator, CycleTypeMixin):
"""Cycle the roof generation method (HEIGHT ↔ ANGLE). Shift+click cycles in reverse."""
bl_idname = "bim.cycle_roof_generation_method"
bl_label = "Cycle Roof Generation Method"
class EnableEditingRoof(_RoofEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.enable_editing_roof"
bl_label = "Enable Editing Roof"
bl_options = {"REGISTER", "UNDO"}
element_checker = tool.Parametric.is_roof
props_getter = tool.Model.get_roof_props
type_literal = tool.Model.RoofGenerationMethod
type_attr = "generation_method"
def _execute(self, context: bpy.types.Context) -> set[str]:
return self._cycle_type(context)
def _execute(self, context):
return self._enable_targets(context)
class GizmoRoofEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
bl_idname = "OBJECT_GGT_bim_roof_edition"
bl_label = "Roof Editing Gizmo"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT"}
class CancelEditingRoof(_RoofEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.cancel_editing_roof"
bl_label = "Cancel Editing Roof"
bl_options = {"REGISTER", "UNDO"}
enable_editing_operator = "bim.enable_editing_roof"
finish_editing_operator = "bim.finish_editing_roof"
cancel_editing_operator = "bim.cancel_editing_roof"
cycle_type_operator = "bim.cycle_roof_generation_method"
def _execute(self, context):
return self._cancel_targets(context)
# Positions for all three dimensions are set per-frame by the position
# override below; no static ``matrix_position`` is needed.
dimension_gizmo_props = [
DimensionGizmoConfig(
attr_name="height",
axis=(0, 0, 1),
min_value=0.01,
visibility_condition=lambda p: p.generation_method == "HEIGHT",
),
DimensionGizmoConfig(
attr_name="angle",
axis=(0, 0, 1),
prop_name="Slope",
min_value=0.0,
visibility_condition=lambda p: p.generation_method == "ANGLE",
compute_value=lambda p: tan(p.angle) * _ROOF_SLOPE_REFERENCE_RUN,
apply_value=lambda p, rise: setattr(
p, "angle", min(_ROOF_MAX_SLOPE_ANGLE, max(0.0, atan2(rise, _ROOF_SLOPE_REFERENCE_RUN)))
),
text_formatter=lambda p, rise: (f"{tool.Unit.format_distance(rise)} ({degrees(p.angle):.1f}°)"),
),
DimensionGizmoConfig(
attr_name="roof_thickness",
axis=(0, 0, -1),
min_value=0.001,
# The line shows the perpendicular slab thickness (matching the
# pset value and the drag delta); the true vertical span is
# ``roof_thickness / cos(angle)``, longer than what is drawn.
),
]
props_getter = tool.Model.get_roof_props
gizmo_pref_name = "roof"
class FinishEditingRoof(_RoofEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.finish_editing_roof"
bl_label = "Finish Editing Roof"
bl_options = {"REGISTER", "UNDO"}
idle_slots: ClassVar[tuple[IconSlot, ...]] = (
IconSlot(
name="toggle_openings",
gizmo_idname="VIEW3D_GT_add_opening",
operator="bim.toggle_host_openings",
visible_when=lambda gg: _roof_has_openings(),
),
)
@classmethod
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
return tool.Parametric.is_roof(element)
def _update_dimension_gizmo_positions(self, context: bpy.types.Context, mw, props) -> None: # noqa: ARG002
"""Anchor every dimension gizmo at the object origin. Each gizmo's
declared axis (height/slope along +Z, thickness along -Z) separates
them in 3D so they don't visually collide despite sharing a
position; the height + slope gizmos themselves are mutually
exclusive via ``visibility_condition`` on ``generation_method``."""
origin = Vector((0.0, 0.0, 0.0))
self.set_dimension_gizmo_position("height", mw, origin, (0, 0, 1))
self.set_dimension_gizmo_position("angle", mw, origin, (0, 0, 1))
self.set_dimension_gizmo_position("roof_thickness", mw, origin, (0, 0, -1))
def get_element_height(self, props) -> float: # noqa: ARG002
"""Object-local Z of the mesh's topmost vertex, so the pen / validate /
cancel / cycle row anchors visibly above sloped or stepped roof
bodies rather than at the parametric ``props.height`` which may not
match the rendered apex on ANGLE-generation roofs."""
obj = bpy.context.active_object
if obj is None or not getattr(obj, "bound_box", None):
return 1.0
return max(c[2] for c in obj.bound_box)
def _execute(self, context):
return self._finish_targets(context)
class EnableEditingRoofPath(bpy.types.Operator, tool.Ifc.Operator):
+60 -140
View File
@@ -31,13 +31,7 @@ from mathutils import Matrix, Vector
import bonsai.core.root
import bonsai.tool as tool
from bonsai.bim.module.drawing import gizmos as gizmo
from bonsai.bim.module.drawing.gizmos import (
COLOR_GREEN,
COLOR_RED,
DimensionGizmoConfig,
IconSlot,
)
from bonsai.bim.parametric_lifecycle import IntegerInputDialogMixin, PickTypeMixin
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
from bonsai.tool.numeric_input import (
IntegerInputState,
run_integer_input_modal,
@@ -45,7 +39,7 @@ from bonsai.tool.numeric_input import (
)
V_ = tool.Blender.V_
from typing import TYPE_CHECKING, ClassVar
from typing import TYPE_CHECKING
from bmesh.types import BMVert
from bpy.props import IntProperty
@@ -384,20 +378,6 @@ class AdjustStairTreads(bpy.types.Operator):
return {"FINISHED"}
class InputStairTreads(IntegerInputDialogMixin, bpy.types.Operator):
"""Popup-dialog entry point for typing a new ``number_of_treads`` value.
Bound to the world-space ``xN`` count label in the stair edit row."""
bl_idname = "bim.input_stair_treads"
bl_label = "Set Number of Treads"
bl_description = "Type the number of treads for this stair"
bl_options = {"REGISTER", "UNDO"}
number_of_treads: IntProperty(name="Number of Treads", default=1, min=1)
attr_name = "number_of_treads"
props_getter = staticmethod(tool.Model.get_stair_props)
class SetStairTreads(bpy.types.Operator):
"""Set the number of treads to a specific value."""
@@ -443,20 +423,20 @@ class SetStairTreads(bpy.types.Operator):
return f"Number of Treads: {input_str}_{validity} | Enter to confirm, Esc to cancel"
class PickStairType(bpy.types.Operator, PickTypeMixin):
"""Pick a stair type from a popup menu."""
class CycleStairType(bpy.types.Operator, gizmo.CycleTypeMixin):
"""Cycle through stair types. Shift+click to cycle in reverse."""
bl_idname = "bim.pick_stair_type"
bl_label = "Pick Stair Type"
bl_idname = "bim.cycle_stair_type"
bl_label = "Cycle Stair Type"
bl_options = {"REGISTER", "UNDO"}
props_getter = tool.Model.get_stair_props
props_getter = "get_stair_props"
type_literal = tool.Model.StairType
type_attr = "stair_type"
skip_element_check = True
def execute(self, context: bpy.types.Context) -> set[str]:
return self._pick_type(context)
return self._cycle_type(context)
# Tread run accessors - callbacks that delegate to BIMStairProperties methods
@@ -482,47 +462,20 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT"}
# === Stair-Specific Icon Layout ===
# Row order: [Validate] [Cancel] [Cycle] [TreadLock] [xN] [Plus] [Minus]
# The base class assigns X positions from ``feature_slots`` tuple order —
# adding an icon is a one-line append, no hardcoded X constant.
# === Stair-Specific Icon Layout (meters) ===
# Additional icons for stair editing, positioned after standard icons:
# [Validate] [Cancel] [Cycle] [TreadLock] [Plus] [Minus]
ICON_TREAD_LOCK_X = 1.24 # X position for tread lock toggle icon
ICON_PLUS_X = 1.61 # X position for add tread (+) icon
ICON_MINUS_X = 1.98 # X position for remove tread (-) icon
ICON_PLUS_MINUS_SCALE = 0.24 # Scale for plus/minus icons (slightly larger)
ICON_CYCLE_SCALE = 0.3 # Scale for cycle type icon
ICON_COUNT_LABEL_SCALE = 0.36 # Scale for the xN tread-count label
ICON_Z_OFFSET = 0.5 # Z offset above geometry for editing icons
feature_slots: ClassVar[tuple[IconSlot, ...]] = (
IconSlot(
name="tread_lock",
gizmo_idname="VIEW3D_GT_lock",
variants=("open", "closed"),
operator="bim.toggle_stair_property",
color=(1.0, 1.0, 1.0),
operator_props=(("property_name", "custom_tread_lock"),),
),
IconSlot(name="tread_count_label", placeholder=True),
IconSlot(
name="plus",
gizmo_idname="VIEW3D_GT_plus",
operator="bim.adjust_stair_treads",
scale=ICON_PLUS_MINUS_SCALE,
color=COLOR_GREEN,
operator_props=(("increment", 1),),
),
IconSlot(
name="minus",
gizmo_idname="VIEW3D_GT_minus",
operator="bim.adjust_stair_treads",
scale=ICON_PLUS_MINUS_SCALE,
color=COLOR_RED,
operator_props=(("increment", -1),),
),
)
enable_editing_operator = "bim.enable_editing_stair"
finish_editing_operator = "bim.finish_editing_stair"
cancel_editing_operator = "bim.cancel_editing_stair"
pick_type_operator = "bim.pick_stair_type"
cycle_type_operator = "bim.cycle_stair_type"
def get_icon_y_extent(self, props: "BIMStairProperties") -> tuple[float, float]:
"""Get Y extents for stair icon positioning.
@@ -627,36 +580,35 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
]
# Metadata-driven dispatch for props and preferences
props_getter = tool.Model.get_stair_props
props_getter = "get_stair_props"
gizmo_pref_name = "stair"
@classmethod
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
return tool.Parametric.is_stair(element)
return tool.Blender.Modifier.is_stair(element)
def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None:
"""Create the total-length lock as an open/closed pair plus the
``xN`` tread-count label. Lock click toggles
``props.total_length_lock``; the per-frame update hook picks which
member is visible. Anchored to the stair's far X end (not the edit
row) so it's positioned by ``_update_lock_gizmo_position`` rather
than the toolbar slot system.
The count label binds to ``bim.input_stair_treads`` (popup dialog)
for click-to-type input and sits at the X reserved by the
``tread_count_label`` placeholder slot in ``feature_slots``."""
self.total_length_lock_open_gizmo, self.total_length_lock_closed_gizmo = self.create_icon_gizmo_lock_pair(
"bim.toggle_stair_property",
"""Create stair-specific icon gizmos (lock, plus, minus)."""
self.lock_gizmo = self.create_icon_gizmo(
"VIEW3D_GT_lock",
self.COLOR_BLUE,
"bim.toggle_stair_property",
prop_path="BIMStairProperties.total_length_lock",
property_name="total_length_lock",
)
default_color, highlight_color = self.get_decoration_colors()
self.tread_count_label_gizmo = self.gizmos.new("BIM_GT_count_label")
self.tread_count_label_gizmo.use_draw_scale = False
self.tread_count_label_gizmo.color = default_color
self.tread_count_label_gizmo.color_highlight = highlight_color
self.tread_count_label_gizmo.alpha = 0.8
self.tread_count_label_gizmo.target_set_operator("bim.input_stair_treads")
self.tread_lock_gizmo = self.create_icon_gizmo(
"VIEW3D_GT_lock",
(1.0, 1.0, 1.0),
"bim.toggle_stair_property",
prop_path="BIMStairProperties.custom_tread_lock",
property_name="custom_tread_lock",
)
self.plus_gizmo = self.create_icon_gizmo(
"VIEW3D_GT_plus", self.COLOR_GREEN, "bim.adjust_stair_treads", increment=1
)
self.minus_gizmo = self.create_icon_gizmo(
"VIEW3D_GT_minus", self.COLOR_RED, "bim.adjust_stair_treads", increment=-1
)
def _refresh_element_specific(
self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties" # noqa: ARG002
@@ -668,43 +620,30 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
self.update_tread_count_gizmos(props)
def update_lock_gizmo(self, props: "BIMStairProperties") -> None:
"""Show the open/closed total-length lock variant matching
``props.total_length_lock``. Positioning is handled per-frame by
the dimension-positioning hook."""
if not hasattr(self, "total_length_lock_open_gizmo"):
return
if not props.is_editing:
self.total_length_lock_open_gizmo.hide = True
self.total_length_lock_closed_gizmo.hide = True
return
self.total_length_lock_open_gizmo.hide = props.total_length_lock
self.total_length_lock_closed_gizmo.hide = not props.total_length_lock
"""Update lock gizmo color and visibility. Positioning is handled
per-frame by the dimension-positioning hook."""
gizmo_prefs = self.get_gizmo_prefs()
if not self.update_gizmo_visibility(self.lock_gizmo, props.is_editing, gizmo_prefs.lock):
return # Hidden, skip color update
self.lock_gizmo.color = self.COLOR_RED if props.total_length_lock else self.COLOR_GREEN
def update_tread_lock_gizmo(self, props: "BIMStairProperties") -> None:
"""Show the open/closed lock variant matching ``props.custom_tread_lock``.
Both pair members share an X position (set by the base's slot
positioning); this picks which one is visible per frame so a state
flip can't reveal both at once."""
if not hasattr(self, "tread_lock_open_gizmo"):
"""Update visibility of tread lock gizmo. Positioning is handled in _update_editing_icon_positions."""
if not hasattr(self, "tread_lock_gizmo"):
return
if not props.is_editing:
self.tread_lock_open_gizmo.hide = True
self.tread_lock_closed_gizmo.hide = True
return
self.tread_lock_open_gizmo.hide = props.custom_tread_lock
self.tread_lock_closed_gizmo.hide = not props.custom_tread_lock
gizmo_prefs = self.get_gizmo_prefs()
self.update_gizmo_visibility(self.tread_lock_gizmo, props.is_editing, gizmo_prefs.lock)
def update_tread_count_gizmos(self, props: "BIMStairProperties") -> None:
"""Update visibility of the +/- tread count gizmos and the ``xN``
label. Positioning is handled in ``_update_editing_icon_positions``."""
"""Update visibility of +/- tread count gizmos. Positioning is handled in _update_editing_icon_positions."""
if not hasattr(self, "plus_gizmo") or not hasattr(self, "minus_gizmo"):
return
self.update_gizmo_visibility(self.plus_gizmo, props.is_editing)
gizmo_prefs = self.get_gizmo_prefs()
self.update_gizmo_visibility(self.plus_gizmo, props.is_editing, gizmo_prefs.plus)
# Minus has additional condition: number_of_treads > 1
self.update_gizmo_visibility(self.minus_gizmo, props.is_editing and props.number_of_treads > 1)
if hasattr(self, "tread_count_label_gizmo"):
self.update_gizmo_visibility(self.tread_count_label_gizmo, props.is_editing)
self.update_gizmo_visibility(
self.minus_gizmo, props.is_editing and props.number_of_treads > 1, gizmo_prefs.minus
)
def _update_dimension_gizmo_positions(
self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties" # noqa: ARG002
@@ -782,12 +721,10 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
billboard_rot: Matrix,
total_run: float,
) -> None:
"""Update lock gizmo pair position based on Y view direction. Writes
the matrix on both members so a state flip can't reveal a stale pose."""
"""Update lock gizmo position based on Y view direction."""
y_pos = self.get_y_position_for_view(props, viewing_from_negative_y, use_offset=True)
self.set_icon_gizmo_pair_position(
"total_length_lock_open_gizmo",
"total_length_lock_closed_gizmo",
self.set_icon_gizmo_position(
"lock_gizmo",
mw,
total_run + self.ICON_Z_OFFSET,
y_pos,
@@ -799,47 +736,30 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
def _update_editing_icon_positions(
self, mw: Matrix, props: "BIMStairProperties", viewing_from_negative_y: bool, billboard_rot: Matrix
) -> None:
"""Reposition the editing icons at stair's view-dependent Y. The base
class's update_editing_gizmos already placed them at the default
``get_icon_y_offset`` Y this overrides with the stair-specific
``get_icon_y_for_view`` flip so the icons land on the side the
camera is looking from."""
"""Update editing icon positions, flipping Y based on viewing angle."""
if not props.is_editing:
return
icon_z = props.height + self.ICON_Z_OFFSET
y_pos = self.get_icon_y_for_view(props, viewing_from_negative_y)
slot_x = self._slot_x_positions()
self.set_icon_gizmo_position("validate_gizmo", mw, 0, y_pos, icon_z, billboard_rot)
self.set_icon_gizmo_position("cancel_gizmo", mw, self.ICON_CANCEL_X, y_pos, icon_z, billboard_rot)
self.set_icon_gizmo_position(
"cycle_gizmo", mw, self.ICON_CYCLE_X, y_pos, icon_z, billboard_rot, scale=self.ICON_CYCLE_SCALE
)
self.set_icon_gizmo_pair_position(
"tread_lock_open_gizmo",
"tread_lock_closed_gizmo",
self.set_icon_gizmo_position(
"tread_lock_gizmo",
mw,
slot_x["tread_lock"],
self.ICON_TREAD_LOCK_X,
y_pos,
icon_z - self.EDITING_ICON_SCALE / 2,
billboard_rot,
scale=self.EDITING_ICON_SCALE,
)
self.set_icon_gizmo_position(
"plus_gizmo", mw, slot_x["plus"], y_pos, icon_z, billboard_rot, scale=self.ICON_PLUS_MINUS_SCALE
"plus_gizmo", mw, self.ICON_PLUS_X, y_pos, icon_z, billboard_rot, scale=self.ICON_PLUS_MINUS_SCALE
)
self.set_icon_gizmo_position(
"minus_gizmo", mw, slot_x["minus"], y_pos, icon_z, billboard_rot, scale=self.ICON_PLUS_MINUS_SCALE
"minus_gizmo", mw, self.ICON_MINUS_X, y_pos, icon_z, billboard_rot, scale=self.ICON_PLUS_MINUS_SCALE
)
if hasattr(self, "tread_count_label_gizmo"):
self.tread_count_label_gizmo.set_count(int(props.number_of_treads))
self.set_icon_gizmo_position(
"tread_count_label_gizmo",
mw,
slot_x["tread_count_label"],
y_pos,
icon_z,
billboard_rot,
scale=self.ICON_COUNT_LABEL_SCALE,
)
+7 -19
View File
@@ -24,7 +24,6 @@ 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
@@ -238,11 +237,11 @@ class BIM_PT_array(bpy.types.Panel):
for i, array in enumerate(ArrayData.data["parameters"]["data_dict"]):
box = self.layout.box()
if props.editing_item_index == i:
if props.is_editing == i:
row = box.row(align=True)
row.prop(props, "count", icon="MOD_ARRAY")
row.operator("bim.finish_editing_array", icon="CHECKMARK", text="")
row.operator("bim.cancel_editing_array", icon="CANCEL", text="")
row.operator("bim.edit_array", icon="CHECKMARK", text="").item = i
row.operator("bim.disable_editing_array", icon="CANCEL", text="")
row = box.row(align=True)
row.prop(props, "method")
row = box.row(align=True)
@@ -304,8 +303,6 @@ 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)
@@ -325,25 +322,16 @@ 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:
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))
row.label(text=str(prop_value_item))
else:
row.label(text=prop_name)
if isinstance(prop_value, float):
row.label(text=tool.Unit.format_distance(prop_value * si_conversion))
else:
row.label(text=str(prop_value))
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)
if isinstance(prop_value, float):
row.label(text=tool.Unit.format_distance(prop_value * si_conversion))
else:
row.label(text=str(prop_value))
row.label(text=str(prop_value))
else:
row = self.layout.row()
row.label(text="No Stair Found")
@@ -365,7 +353,7 @@ class BIM_PT_wall(bpy.types.Panel):
if not obj:
return False
element = tool.Ifc.get_entity(obj)
return bool(element) and tool.Parametric.is_wall(element)
return bool(element) and tool.Blender.Modifier.is_wall(element)
def draw(self, context):
obj = context.active_object
File diff suppressed because it is too large Load Diff
@@ -1,279 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Four wall-offset dimension gizmos (left / right / top / bottom) shared by door and
window edit gizmo groups both fillings sit in a LAYER2 wall and the offset math is
identical.
The compute side returns a *signed* value on the X axis (negative when the filling
is 180°-flipped onto the wall's opposite face) so the gizmo framework auto-flips
the rendered arrow; the apply side takes ``abs(value)`` because the user-facing
offset is always positive. Z-axis values are unsigned in both directions.
Fillings are assumed to align with the wall's local X axis to within ±90° — the
parametric door/window construction path enforces this, and the X-sign math
falls back to +1 if ``col[0].x`` lands on the ambiguous zero (filling rotated
exactly 90° in the wall plane).
Every public entry point falls back to a safe no-op when the host-wall chain
cannot be resolved: reads return 0.0, writes do nothing, and gizmo anchors
return a filling-relative position. This keeps the gizmos non-crashing when a
filling momentarily loses its host (e.g. mid-edit, partially-loaded files).
``_GEOM_CACHE`` is module-scoped and persists across tests tests must call
``clear_caches()`` between cases."""
from __future__ import annotations
from typing import TYPE_CHECKING, NamedTuple, Protocol
from mathutils import Vector
import bonsai.tool as tool
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
if TYPE_CHECKING:
import bpy
class FillingProps(Protocol):
"""Structural subset of door/window props this module touches."""
id_data: bpy.types.Object
overall_width: float
overall_height: float
# Wall-local frame axis indices. Y (depth) is unused — fillings sit on the wall's centreline.
_AXIS_X = 0
_AXIS_Z = 2
class _HostWallGeom(NamedTuple):
"""Cached host-wall geometry in SI metres, wall-local frame. ``height`` is
the vertical projection (already accounts for slanted extrusions)."""
wall_obj: bpy.types.Object
height: float
axis_min_x: float
axis_max_x: float
class _AxisExtent(NamedTuple):
"""``[low, high]`` interval on one wall-local axis; low = near end.
``x_sign`` is +1 / -1 for an X-axis filling extent only (carries the
180° auto-flip); always 1.0 elsewhere."""
low: float
high: float
x_sign: float = 1.0
class _Edge(NamedTuple):
"""Wall edge a gizmo measures to. ``is_max_end=True`` picks right/top, else left/bottom."""
axis_index: int
is_max_end: bool
_LEFT = _Edge(axis_index=_AXIS_X, is_max_end=False)
_RIGHT = _Edge(axis_index=_AXIS_X, is_max_end=True)
_BOTTOM = _Edge(axis_index=_AXIS_Z, is_max_end=False)
_TOP = _Edge(axis_index=_AXIS_Z, is_max_end=True)
# Avoids repeating the host-wall chain walk + LAYER2 geometry read per gizmo per frame.
_GEOM_CACHE = tool.Parametric.GenerationKeyedCache()
def clear_caches() -> None:
_GEOM_CACHE.clear()
def _host_wall_geom(filling_obj: bpy.types.Object) -> _HostWallGeom | None:
"""Cached host-wall geometry for a filling, or ``None`` if any link in
filling opening wall LAYER2 extrusion scene-object resolution breaks."""
return _GEOM_CACHE.get_or_compute(filling_obj.name, lambda: _compute_host_wall_geom(filling_obj))
def _compute_host_wall_geom(filling_obj: bpy.types.Object) -> _HostWallGeom | None:
element = tool.Ifc.get_entity(filling_obj)
if not element:
return None
host_wall = tool.Spatial.get_host_wall(element)
if not host_wall:
return None
wall_obj = tool.Ifc.get_object(host_wall)
length_height = tool.Wall.get_length_and_height(host_wall)
axis_extent = tool.Wall.get_axis_local_extent(host_wall)
# x_angle is None for non-LAYER2 walls — gates entry; the value itself is unused.
if not (wall_obj and length_height and axis_extent and tool.Wall.get_x_angle(host_wall) is not None):
return None
_, height = length_height
axis_min_x, axis_max_x = axis_extent
return _HostWallGeom(wall_obj=wall_obj, height=height, axis_min_x=axis_min_x, axis_max_x=axis_max_x)
def _filling_axis_extent(props: FillingProps, host_wall_obj: bpy.types.Object, axis_index: int) -> _AxisExtent:
"""Filling footprint on the wall's local axis.
X-axis extent carries the filling's orientation sign (180° flip onto
the opposite face) in ``x_sign``."""
filling_in_wall = host_wall_obj.matrix_world.inverted() @ props.id_data.matrix_world
origin = filling_in_wall.translation[axis_index]
if axis_index == _AXIS_X:
# col[0].x is the X-component of the filling's local X axis in the wall-local frame:
# +1 when filling's +X aligns with wall's +X, -1 after a 180° Z-flip.
x_sign = 1.0 if filling_in_wall.col[0].x >= 0.0 else -1.0
signed_width = x_sign * props.overall_width
return _AxisExtent(origin + min(0.0, signed_width), origin + max(0.0, signed_width), x_sign)
return _AxisExtent(origin, origin + props.overall_height)
def _wall_axis_extent(geom: _HostWallGeom, axis_index: int) -> _AxisExtent:
"""Wall span on one local axis: X = IFC axis-line endpoints (not mesh bound-box,
which drifts on trimmed walls); Z = 0 wall height."""
if axis_index == _AXIS_X:
return _AxisExtent(geom.axis_min_x, geom.axis_max_x)
return _AxisExtent(0.0, geom.height)
def _offset_from_extents(filling: _AxisExtent, wall: _AxisExtent, is_max_end: bool) -> float:
"""Distance from the wall edge to the filling's matching edge on the same axis."""
if is_max_end:
return wall.high - filling.high
return filling.low - wall.low
def _translate_along_wall_axis(
props: FillingProps, host_wall_obj: bpy.types.Object, delta: float, axis_index: int
) -> None:
"""Shift the filling by ``delta`` SI metres along the wall's local axis. Drag
operates in the filling's intent frame, not Blender's world frame, so a rotated
host wall still tracks correctly."""
if delta == 0.0:
return
direction_world = host_wall_obj.matrix_world.to_3x3().col[axis_index].normalized()
props.id_data.matrix_world.translation = props.id_data.matrix_world.translation + direction_world * delta
def _get_offset(props: FillingProps, edge: _Edge) -> float:
"""SI distance from the wall edge to the filling's matching edge on the same axis."""
geom = _host_wall_geom(props.id_data)
if not geom:
return 0.0
filling = _filling_axis_extent(props, geom.wall_obj, edge.axis_index)
wall = _wall_axis_extent(geom, edge.axis_index)
return _offset_from_extents(filling, wall, edge.is_max_end)
def _set_offset(props: FillingProps, edge: _Edge, value: float) -> None:
"""Translate the filling so its offset to ``edge`` becomes ``max(0, value)`` SI metres.
Max-end edges (right/top) translate in the opposite direction of near-end edges."""
geom = _host_wall_geom(props.id_data)
if not geom:
return
current = _get_offset(props, edge)
target = max(0.0, value)
delta = (current - target) if edge.is_max_end else (target - current)
_translate_along_wall_axis(props, geom.wall_obj, delta, edge.axis_index)
def has_host_wall(props: FillingProps) -> bool:
"""True when the filling resolves to a LAYER2 host wall present in the scene."""
return _host_wall_geom(props.id_data) is not None
def _edge_position(props: FillingProps, edge: _Edge) -> Vector:
"""Gizmo anchor in filling-local space, at the wall edge, pointing toward the filling."""
geom = _host_wall_geom(props.id_data)
if not geom:
if edge.axis_index == _AXIS_X:
return Vector((0.0, 0.0, props.overall_height / 2))
return Vector((props.overall_width / 2, 0.0, props.overall_height if edge.is_max_end else 0.0))
wall = _wall_axis_extent(geom, edge.axis_index)
edge_value = wall.high if edge.is_max_end else wall.low
if edge.axis_index == _AXIS_X:
wall_edge_world = geom.wall_obj.matrix_world @ Vector((edge_value, 0.0, 0.0))
pos = props.id_data.matrix_world.inverted() @ wall_edge_world
return Vector((pos.x, 0.0, props.overall_height / 2))
# LAYER2 wall matrix_world is upright, so wall-local Z and filling-local Z differ
# only by the filling's Z origin in the wall frame.
filling_z_in_wall = _filling_axis_extent(props, geom.wall_obj, axis_index=_AXIS_Z).low
return Vector((props.overall_width / 2, 0.0, edge_value - filling_z_in_wall))
def _compute_value(props: FillingProps, edge: _Edge) -> float:
"""Renderer-side value. X-axis edges return a signed value so the gizmo's
auto-flip kicks in for fillings on the wall's opposite face; Z-axis returns unsigned."""
geom = _host_wall_geom(props.id_data)
if not geom:
return 0.0
filling = _filling_axis_extent(props, geom.wall_obj, edge.axis_index)
wall = _wall_axis_extent(geom, edge.axis_index)
return filling.x_sign * _offset_from_extents(filling, wall, edge.is_max_end)
def _apply_value(props: FillingProps, edge: _Edge, value: float) -> None:
"""Drag-end commit; X-axis takes ``abs(value)`` since the negative sign in compute
is a rendering hint only (user-facing offset is always positive)."""
if edge.axis_index == _AXIS_X:
_set_offset(props, edge, abs(value))
else:
_set_offset(props, edge, value)
# attr_name identifies the gizmo within its group; values flow through
# compute/apply, not via a registered property.
WALL_OFFSET_GIZMO_CONFIGS: list[DimensionGizmoConfig] = [
DimensionGizmoConfig(
attr_name="host_wall_offset_left",
axis=(1, 0, 0),
visibility_condition=has_host_wall,
compute_value=lambda p: _compute_value(p, _LEFT),
apply_value=lambda p, v: _apply_value(p, _LEFT, v),
matrix_position=lambda p: _edge_position(p, _LEFT),
),
DimensionGizmoConfig(
attr_name="host_wall_offset_right",
axis=(-1, 0, 0),
visibility_condition=has_host_wall,
compute_value=lambda p: _compute_value(p, _RIGHT),
apply_value=lambda p, v: _apply_value(p, _RIGHT, v),
matrix_position=lambda p: _edge_position(p, _RIGHT),
),
DimensionGizmoConfig(
attr_name="host_wall_offset_bottom",
axis=(0, 0, 1),
visibility_condition=has_host_wall,
compute_value=lambda p: _compute_value(p, _BOTTOM),
apply_value=lambda p, v: _apply_value(p, _BOTTOM, v),
matrix_position=lambda p: _edge_position(p, _BOTTOM),
),
DimensionGizmoConfig(
attr_name="host_wall_offset_top",
axis=(0, 0, -1),
visibility_condition=has_host_wall,
compute_value=lambda p: _compute_value(p, _TOP),
apply_value=lambda p, v: _apply_value(p, _TOP, v),
matrix_position=lambda p: _edge_position(p, _TOP),
),
]
+12 -14
View File
@@ -39,8 +39,7 @@ import bonsai.core.root
import bonsai.tool as tool
from bonsai.bim.module.drawing import gizmos as gizmo
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
from bonsai.bim.module.model.wall_offset_gizmos import WALL_OFFSET_GIZMO_CONFIGS
from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin, PickTypeMixin
from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin
if TYPE_CHECKING:
from bonsai.bim.module.model.prop import BIMWindowProperties
@@ -492,7 +491,7 @@ class _WindowEditMixin(FeatureModifierEditMixin):
@classmethod
def _is_element_type(cls, element):
return tool.Parametric.is_window(element)
return tool.Blender.Modifier.is_window(element)
@classmethod
def _get_props(cls, obj: bpy.types.Object):
@@ -552,20 +551,20 @@ class RemoveWindow(bpy.types.Operator, tool.Ifc.Operator):
return {"FINISHED"}
class PickWindowType(bpy.types.Operator, tool.Ifc.Operator, PickTypeMixin):
"""Pick a window type from a popup menu."""
class CycleWindowType(bpy.types.Operator, tool.Ifc.Operator, gizmo.CycleTypeMixin):
"""Cycle through available window types. Shift+click to cycle in reverse."""
bl_idname = "bim.pick_window_type"
bl_label = "Pick Window Type"
bl_idname = "bim.cycle_window_type"
bl_label = "Cycle Window Type"
bl_options = {"REGISTER", "UNDO"}
element_checker = tool.Parametric.is_window
props_getter = tool.Model.get_window_props
element_checker = "is_window"
props_getter = "get_window_props"
type_literal = tool.Model.WindowType
type_attr = "window_type"
def _execute(self, context: bpy.types.Context) -> set[str]:
return self._pick_type(context)
return self._cycle_type(context)
# Frame accessor factory - creates callbacks that delegate to BIMWindowProperties methods
@@ -603,7 +602,7 @@ class GizmoWindowEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
enable_editing_operator = "bim.enable_editing_window"
finish_editing_operator = "bim.finish_editing_window"
cancel_editing_operator = "bim.cancel_editing_window"
pick_type_operator = "bim.pick_window_type"
cycle_type_operator = "bim.cycle_window_type"
# matrix_position lambdas replace the get_dimension_matrix_* methods
dimension_gizmo_props = [
@@ -744,15 +743,14 @@ class GizmoWindowEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
),
# lining_offset is handled specially in _update_dimension_gizmo_positions due to negative value support
DimensionGizmoConfig(attr_name="lining_offset", axis=(0, 1, 0), min_value=-10.0),
*WALL_OFFSET_GIZMO_CONFIGS,
]
props_getter = tool.Model.get_window_props
props_getter = "get_window_props"
gizmo_pref_name = "window"
@classmethod
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
return tool.Parametric.is_window(element)
return tool.Blender.Modifier.is_window(element)
def get_icon_y_extent(self, props: "BIMWindowProperties") -> tuple[float, float]:
"""Get Y extents for window icon positioning.
@@ -969,9 +969,7 @@ class EditObjectUI:
if PortData.data["total_ports"] > 0:
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
add_layout_hotkey_operator(
row, "Regen", "S_G", bpy.ops.bim.regenerate_distribution_element.__doc__, ui_context
)
add_layout_hotkey_operator(row, "Regen", "S_G", bpy.ops.bim.regenerate_distribution_element.__doc__, ui_context)
@classmethod
def draw_void(cls, context, row):
@@ -1296,15 +1294,9 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
bpy.ops.bim.generate_space()
return
if self.active_material_usage == "LAYER2":
if element and tool.Model.has_underside_connection(element):
bpy.ops.bim.regenerate_wall_to_underside()
else:
bpy.ops.bim.recalculate_wall()
bpy.ops.bim.recalculate_wall()
elif self.active_material_usage == "LAYER3":
bpy.ops.bim.recalculate_slab()
wall_objs = tool.Model.get_connected_wall_objs(element)
if wall_objs:
core.regenerate_wall_to_underside(tool.Ifc, tool.Geometry, tool.Model, wall_objs)
elif tool.System.get_ports(element):
bpy.ops.bim.regenerate_distribution_element()
elif self.active_material_usage == "PROFILE":
@@ -1444,7 +1436,10 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
bpy.ops.bim.enable_editing_extrusion_axis()
def hotkey_A_O(self):
bpy.ops.bim.toggle_host_openings()
if tool.Model.get_model_props().openings:
bpy.ops.bim.edit_openings(apply_all=True)
else:
bpy.ops.bim.show_openings()
def hotkey_C_E(self):
if not bpy.context.selected_objects:
@@ -63,7 +63,6 @@ import bonsai.core.project as core
import bonsai.tool as tool
from bonsai.bim import export_ifc, import_ifc
from bonsai.bim.ifc import IfcStore
from bonsai.bim.module.model import preview_base
from bonsai.bim.module.model.decorator import FaceAreaDecorator, PolylineDecorator
from bonsai.bim.module.model.polyline import PolylineOperator
from bonsai.bim.module.project.data import LinksData, ProjectLibraryData
@@ -964,11 +963,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=True,
default=False,
)
should_start_fresh_session: bpy.props.BoolProperty(
name="Should Start Fresh Session",
description="Clear current Blender session before loading IFC",
description="Clear current Blender session before loading IFC. Not supported with 'Use Relative Path' option",
default=True,
)
import_without_ifc_data: bpy.props.BoolProperty(
@@ -1076,6 +1075,9 @@ 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:
@@ -1140,6 +1142,8 @@ 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")
@@ -1870,7 +1874,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=True)
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False)
if TYPE_CHECKING:
filter_glob: str
@@ -1932,10 +1936,6 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
def _execute(self, context):
committed, failed_commits = tool.Parametric.commit_pending_edits()
# Previews are session-transient — discard rather than commit. Sibling
# gizmo polls gate on each preview's is_active flag, and a stuck flag
# persisted through the save would silently hide them on reload.
preview_base.discard_pending_previews(context.scene)
# Suffix is appended to the IFC save-success report below so the auto-commit
# info isn't immediately overwritten by the success message in Blender's
# status bar (only the latest self.report({"INFO"}, ...) sticks).
+1 -1
View File
@@ -444,7 +444,7 @@ class BIMProjectProperties(PropertyGroup):
items=get_parent_libaries,
)
use_relative_project_path: BoolProperty(name="Use Relative Project Path", default=True)
use_relative_project_path: BoolProperty(name="Use Relative Project Path", default=False)
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,15 +302,6 @@ 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"}
+1 -2
View File
@@ -151,8 +151,7 @@ class BIM_PT_type_attributes(Panel):
row = layout.row(align=True)
row.label(text=attribute["name"])
value = get_display_value(attribute["value"])
op = row.operator("bim.select_similar", text=value, icon="NONE", emboss=False)
op.key = "type." + attribute["name"]
row.label(text=value)
def add_object_button(self, context):
+2 -19
View File
@@ -36,17 +36,9 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
bl_description = (
"Apply opening objects to an Element.\n\n"
"The Element and the openings to be applied should be selected. The order of selection is not important.\n"
"Opening can be just a Blender mesh object.\n\n"
"Shift+click: keep the filling at its current matrix_world — skip the wall-axis snap "
"and the rl1/rl2 Z-elevation default that the regular click applies."
"Opening can be just a Blender mesh object."
)
# Toggled by ``invoke`` when the user holds SHIFT during a gizmo / hotkey
# click. The filling-opening generator gates its snap-to-wall-axis block
# on this flag. HIDDEN + SKIP_SAVE so the flag doesn't surface in the F6
# redo panel or persist into saved keymaps.
preserve_placement: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"})
@classmethod
def poll(cls, context):
if len(context.selected_objects) < 2:
@@ -54,10 +46,6 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
return False
return True
def invoke(self, context, event):
self.preserve_placement = bool(event.shift)
return self.execute(context)
def _execute(self, context):
selected_objects = context.selected_objects
target_object = selected_objects[0]
@@ -80,12 +68,7 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
elif not element1.is_a("IfcOpeningElement") and not element2.is_a("IfcOpeningElement"):
if element1.is_a("IfcWindow") or element1.is_a("IfcDoor"): # Add a fill to an element.
obj1, obj2 = obj2, obj1
FilledOpeningGenerator().generate(
obj2,
obj1,
target=obj2.matrix_world.translation,
preserve_placement=self.preserve_placement,
)
FilledOpeningGenerator().generate(obj2, obj1, target=obj2.matrix_world.translation)
continue
elif element1.is_a("IfcOpeningElement") or element2.is_a("IfcOpeningElement"):
if element1.is_a("IfcOpeningElement"): # Reassign an opening to another element.
+1 -1
View File
@@ -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=True)
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False)
filename_ext = ".ifc"
def execute(self, context):
+5 -199
View File
@@ -61,7 +61,7 @@ Pattern selection (which approach a new feature should adopt):
The authoritative list of registered parametric types and which use
`build_edit_lifecycle` vs. standalone operators lives in
`tool/parametric.py`'s `EDIT_TYPES` and is enforced by the registry
contract tests.
contract tests in `test/bim/test_parametric_registry.py`.
This module hosts operator-side mixins that import ``bonsai.tool`` freely.
The lightweight parametric registry consumed at addon-enable time must stay
@@ -71,16 +71,18 @@ from __future__ import annotations
import json
from collections.abc import Callable
from typing import ClassVar, get_args
from typing import TYPE_CHECKING, ClassVar
import bpy
import ifcopenshell.util.element
from bpy.app.handlers import persistent
from ifcopenshell import entity_instance
import bonsai.core.geometry
import bonsai.tool as tool
if TYPE_CHECKING:
from ifcopenshell import entity_instance
class ParametricEditMixinBase:
"""Common scaffolding for parametric edit-lifecycle mixins.
@@ -377,202 +379,6 @@ class PathPreservingEditMixin(ParametricEditMixinBase):
return {"FINISHED"}
# --- Type-selection mixins (Cycle / Pick) ------------------------------------
class TypeAccessorBase:
"""Shared contract for operators that resolve and write a Literal type
attribute on a Bonsai PropertyGroup.
Subclasses define ``element_checker``, ``props_getter``, ``type_literal``,
``type_attr``; ``skip_element_check`` bypasses element validation. Concrete
subclasses (``CycleTypeMixin``, ``PickTypeMixin``) add the interaction
shape on top.
Test doubles must be set on the operator instance the predicates are
bound at class-definition time, so patching the underlying tool module
has no effect."""
element_checker: Callable[[entity_instance], bool]
props_getter: Callable[[bpy.types.Object], bpy.types.PropertyGroup]
type_literal: type
type_attr: str
skip_element_check: bool = False
def _resolve_target(self, context: bpy.types.Context) -> bpy.types.Object | None:
"""Return the active object iff it passes ``element_checker`` (or the
check is skipped). ``None`` signals the operator should bail with
``{'CANCELLED'}``."""
obj = context.active_object
if not obj:
return None
if not self.skip_element_check:
element = tool.Ifc.get_entity(obj)
if not element or not self.element_checker(element):
return None
return obj
class CycleTypeMixin(TypeAccessorBase):
"""Operator mixin that cycles through ``type_literal``'s values.
Shift-click reverses direction."""
reverse: bpy.props.BoolProperty(name="Reverse", default=False, options={"HIDDEN", "SKIP_SAVE"})
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]:
self.reverse = event.shift
return self.execute(context)
def _cycle_type(self, context: bpy.types.Context) -> set[str]:
obj = self._resolve_target(context)
if obj is None:
return {"CANCELLED"}
props = self.props_getter(obj)
types = get_args(self.type_literal)
current = getattr(props, self.type_attr)
idx = types.index(current) if current in types else 0
direction = -1 if self.reverse else 1
setattr(props, self.type_attr, types[(idx + direction) % len(types)])
return {"FINISHED"}
class PickTypeMixin(TypeAccessorBase):
"""Operator mixin that opens a popup menu listing ``type_literal``'s values.
Empty ``value`` ``invoke`` opens the popup; non-empty the user picked
an item and ``_pick_type`` applies it.
When invoked mid-click (e.g. from a gizmo's ``target_set_operator``), the
menu opens only after the originating ``LEFTMOUSE`` releases. Otherwise
the still-pressed click flows straight into Blender's drag-through-pick
gesture and the menu commits whichever item the cursor drifts over on
release. Other invocation paths (command-palette / F3, EXEC_DEFAULT, F6
redo) bypass the wait and open the menu immediately.
The ``value`` StringProperty is declared on this mixin but registered via
the concrete Operator subclass's MRO scan — do not instantiate the mixin
standalone."""
# Carries the picked value through invoke→execute; empty default
# distinguishes "open popup" from "apply".
value: bpy.props.StringProperty(default="", options={"HIDDEN", "SKIP_SAVE"})
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]:
"""Open the picker menu, or apply a value that was preset by a
menu-item click.
Routing through ``execute()`` keeps subclass IFC-transaction wrapping
in the loop and means F6 redo / ``EXEC_DEFAULT`` reach the apply path."""
if self.value:
return self.execute(context)
if self._resolve_target(context) is None:
return {"CANCELLED"}
if event.value == "PRESS":
context.window_manager.modal_handler_add(self)
return {"RUNNING_MODAL"}
return self._open_picker(context)
def modal(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]:
if event.type == "LEFTMOUSE" and event.value == "RELEASE":
self._open_picker(context)
# INTERFACE does not remove a modal handler; only FINISHED /
# CANCELLED do.
return {"CANCELLED"}
if event.type in {"RIGHTMOUSE", "ESC"}:
return {"CANCELLED"}
return {"RUNNING_MODAL"}
def _open_picker(self, context: bpy.types.Context) -> set[str]:
bl_idname = self.bl_idname
values = list(get_args(self.type_literal))
def draw(menu_self, _menu_context):
layout = menu_self.layout
for v in values:
op = layout.operator(bl_idname, text=v)
op.value = v
context.window_manager.popup_menu(draw, title=self.bl_label, icon="MENU_PANEL")
# The type change is a two-step interaction: this invocation just OPENS
# the menu (no state change yet); a SECOND invocation fires when the
# user clicks a menu item — that one writes ``props.<type_attr>`` and
# returns FINISHED. By returning INTERFACE here (and not FINISHED), the
# menu-open step is excluded from Blender's undo stack so the user
# gets exactly ONE undo entry per type change. If we returned FINISHED
# here too, the stack would gain a no-op "opened the menu" entry that
# Ctrl+Z would dismiss before reverting the actual type change —
# confusing UX where the first Ctrl+Z appears to do nothing.
return {"INTERFACE"}
def _pick_type(self, context: bpy.types.Context) -> set[str]:
if not self.value:
# No-op rather than re-open the menu, so command-palette misuse
# doesn't infinite-loop.
return {"CANCELLED"}
obj = self._resolve_target(context)
if obj is None:
return {"CANCELLED"}
if self.value not in get_args(self.type_literal):
self.report({"WARNING"}, f"Unknown {self.type_attr}: {self.value!r}")
return {"CANCELLED"}
props = self.props_getter(obj)
setattr(props, self.type_attr, self.value)
return {"FINISHED"}
class IntegerInputDialogMixin:
"""Operator mixin that mirrors a per-feature ``IntProperty`` on the
operator into a draft attribute on the active object's parametric props,
via Blender's ``invoke_props_dialog`` popup.
Subclasses declare:
- ``attr_name`` name of the IntProperty on the subclass AND of the
attribute on the resolved props (same name on both sides).
- ``props_getter`` ``staticmethod(tool.Model.get_<feature>_props)``.
- ``requires_editing`` True iff the operator must no-op outside an
active edit lifecycle. Default False.
- ``value_min`` minimum value to clamp to. Default 1."""
attr_name: ClassVar[str] = ""
props_getter: ClassVar[Callable[[bpy.types.Object], bpy.types.PropertyGroup]]
requires_editing: ClassVar[bool] = False
value_min: ClassVar[int] = 1
def _resolve_props(self, context: bpy.types.Context) -> bpy.types.PropertyGroup | None:
"""Return the active object's parametric props if the operator is
allowed to fire, ``None`` otherwise (caller bails with ``CANCELLED``)."""
obj = context.active_object
if not obj:
return None
props = self.props_getter(obj)
if self.requires_editing and not props.is_editing:
return None
return props
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]: # noqa: ARG002
props = self._resolve_props(context)
if props is None:
return {"CANCELLED"}
setattr(self, self.attr_name, max(self.value_min, getattr(props, self.attr_name)))
return context.window_manager.invoke_props_dialog(self)
def execute(self, context: bpy.types.Context) -> set[str]:
props = self._resolve_props(context)
if props is None:
return {"CANCELLED"}
setattr(props, self.attr_name, max(self.value_min, getattr(self, self.attr_name)))
return {"FINISHED"}
# --- Undo-resync registry ----------------------------------------------------
#
# Per-type regenerators called from ``resync_parametric_drafts_after_undo``
+240 -26
View File
@@ -94,10 +94,7 @@ class IFCFileSelector:
filepath = self.get_filepath_abs()
if self.use_relative_path:
try:
filepath = filepath.relative_to(bpy.path.abspath("//"))
except ValueError:
pass # IFC file is not under the blend directory; keep absolute path
filepath = filepath.relative_to(bpy.path.abspath("//"))
return filepath.as_posix().replace("\\", "/")
def draw(self, context: bpy.types.Context) -> None:
@@ -278,33 +275,202 @@ class BIM_UL_panel_visibilities(bpy.types.UIList):
row.prop(item, "is_bookmarked", text="", icon="SOLO_ON" if item.is_bookmarked else "SOLO_OFF", emboss=False)
class GizmoPreferencesDoor(bpy.types.PropertyGroup):
"""Property group for door gizmo visibility settings."""
overall_height: BoolProperty(name="Overall Height", default=True)
overall_width: BoolProperty(name="Overall Width", default=True)
threshold_thickness: BoolProperty(name="Threshold Thickness", default=True)
threshold_depth: BoolProperty(name="Threshold Depth", default=True)
threshold_offset: BoolProperty(name="Threshold Offset", default=True)
lining_offset: BoolProperty(name="Lining Offset", default=True)
lining_depth: BoolProperty(name="Lining Depth", default=True)
lining_thickness: BoolProperty(name="Lining Thickness", default=True)
transom_offset: BoolProperty(name="Transom Offset", default=True)
transom_thickness: BoolProperty(name="Transom Thickness", default=True)
casing_thickness: BoolProperty(name="Casing Thickness", default=True)
casing_depth: BoolProperty(name="Casing Depth", default=True)
swing_arc: BoolProperty(name="Swing Arc", default=True, description="Show door swing direction arc")
flip_arc: BoolProperty(name="Flip Arc", default=True, description="Show flip door orientation arc")
if TYPE_CHECKING:
overall_height: bool
overall_width: bool
threshold_thickness: bool
threshold_depth: bool
threshold_offset: bool
lining_offset: bool
lining_depth: bool
lining_thickness: bool
transom_offset: bool
transom_thickness: bool
casing_thickness: bool
casing_depth: bool
swing_arc: bool
flip_arc: bool
class GizmoPreferencesWindow(bpy.types.PropertyGroup):
"""Property group for window gizmo visibility settings."""
overall_height: BoolProperty(name="Overall Height", default=True)
overall_width: BoolProperty(name="Overall Width", default=True)
lining_offset: BoolProperty(name="Lining Offset", default=True)
lining_depth: BoolProperty(name="Lining Depth", default=True)
lining_thickness: BoolProperty(name="Lining Thickness", default=True)
lining_to_panel_offset_x: BoolProperty(name="Lining to Panel Offset X", default=True)
lining_to_panel_offset_y: BoolProperty(name="Lining to Panel Offset Y", default=True)
frame_depth: BoolProperty(name="Frame Depth", default=True)
frame_thickness: BoolProperty(name="Frame Thickness", default=True)
mullion_thickness: BoolProperty(name="Mullion Thickness", default=True)
first_mullion_offset: BoolProperty(name="First Mullion Offset", default=True)
second_mullion_offset: BoolProperty(name="Second Mullion Offset", default=True)
transom_thickness: BoolProperty(name="Transom Thickness", default=True)
first_transom_offset: BoolProperty(name="First Transom Offset", default=True)
second_transom_offset: BoolProperty(name="Second Transom Offset", default=True)
if TYPE_CHECKING:
overall_height: bool
overall_width: bool
lining_offset: bool
lining_depth: bool
lining_thickness: bool
lining_to_panel_offset_x: bool
lining_to_panel_offset_y: bool
frame_depth: bool
frame_thickness: bool
mullion_thickness: bool
first_mullion_offset: bool
second_mullion_offset: bool
transom_thickness: bool
first_transom_offset: bool
second_transom_offset: bool
class GizmoPreferencesStair(bpy.types.PropertyGroup):
"""Property group for stair gizmo visibility settings."""
width: BoolProperty(name="Width", default=True)
height: BoolProperty(name="Height", default=True)
tread_run: BoolProperty(name="Tread Run", default=True)
tread_depth: BoolProperty(name="Tread Depth", default=True)
riser_height: BoolProperty(name="Riser Height", default=True)
nosing_length: BoolProperty(name="Nosing Length", default=True)
nosing_depth: BoolProperty(name="Nosing Depth", default=True)
total_length_target: BoolProperty(name="Total Length Target", default=True)
base_slab_depth: BoolProperty(name="Base Slab Depth", default=True)
top_slab_depth: BoolProperty(name="Top Slab Depth", default=True)
lock: BoolProperty(name="Total Length Lock", default=True)
plus: BoolProperty(name="Add Tread (+)", default=True)
minus: BoolProperty(name="Remove Tread (-)", default=True)
cycle: BoolProperty(name="Cycle Stair Type", default=True)
if TYPE_CHECKING:
width: bool
height: bool
tread_run: bool
tread_depth: bool
riser_height: bool
nosing_length: bool
nosing_depth: bool
total_length_target: bool
base_slab_depth: bool
top_slab_depth: bool
lock: bool
plus: bool
minus: bool
cycle: bool
class GizmoPreferencesWall(bpy.types.PropertyGroup):
"""Property group for wall gizmo visibility settings."""
length: BoolProperty(
name="Length",
default=True,
description="Show the length dimension gizmo along the wall axis.",
)
height: BoolProperty(
name="Height",
default=True,
description="Show the height dimension gizmo at the wall's start endpoint.",
)
height_end: BoolProperty(
name="Height (far end, walls > 5m)",
default=True,
description=(
"Show a second height gizmo at the wall's far end so long walls don't "
"require panning to reach the handle."
),
)
x_angle: BoolProperty(
name="Slope",
default=True,
description="Show the slope gizmo at the wall top measuring horizontal displacement of the top face.",
)
cycle: BoolProperty(
name="Cycle Offset Baseline",
default=True,
description="Show the baseline-state icon (Exterior / Centreline / Interior) in the editing icon row.",
)
scissors: BoolProperty(
name="Split at cursor",
default=True,
description="Show the split icon at the 3D cursor when it lies within the wall's length range.",
)
extend: BoolProperty(
name="Extend length to cursor X",
default=True,
description="Show the extend-length icon at the 3D cursor's projected wall-axis X.",
)
extend_height: BoolProperty(
name="Extend height to cursor Z",
default=True,
description="Show the extend-height icon at the 3D cursor's Z, on the wall axis.",
)
rotate: BoolProperty(
name="Rotate 90°",
default=True,
description="Show the rotate-90 icon in the editing icon row (rotates the wall around its Z axis).",
)
toggle_openings: BoolProperty(
name="Toggle Openings",
default=True,
description="Show the toggle-openings icon next to the pen (toggles opening fill visibility in the viewport).",
)
if TYPE_CHECKING:
length: bool
height: bool
height_end: bool
x_angle: bool
cycle: bool
scissors: bool
extend: bool
extend_height: bool
rotate: bool
toggle_openings: bool
class GizmoPreferences(bpy.types.PropertyGroup):
"""Aggregator for parametric gizmo visibility settings. One flat bool per
parametric feature; controls whether that feature's gizmo group polls
visible in the viewport."""
"""Property group for all gizmo visibility settings."""
draw_gizmos_in_3d_viewport: BoolProperty(
name="Draw Gizmos In 3D Viewport",
default=True,
description="Show interactive gizmos in the 3D viewport for parametric elements",
)
door: BoolProperty(name="Door", default=True)
window: BoolProperty(name="Window", default=True)
stair: BoolProperty(name="Stair", default=True)
railing: BoolProperty(name="Railing", default=True)
roof: BoolProperty(name="Roof", default=True)
array: BoolProperty(name="Array", default=True)
wall: BoolProperty(name="Wall", default=True)
door: bpy.props.PointerProperty(type=GizmoPreferencesDoor)
window: bpy.props.PointerProperty(type=GizmoPreferencesWindow)
stair: bpy.props.PointerProperty(type=GizmoPreferencesStair)
wall: bpy.props.PointerProperty(type=GizmoPreferencesWall)
if TYPE_CHECKING:
draw_gizmos_in_3d_viewport: bool
door: bool
window: bool
stair: bool
railing: bool
roof: bool
array: bool
wall: bool
door: GizmoPreferencesDoor
window: GizmoPreferencesWindow
stair: GizmoPreferencesStair
wall: GizmoPreferencesWall
class DocPreferences(bpy.types.PropertyGroup):
@@ -752,13 +918,61 @@ 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()
annotations = type(self.gizmos).__annotations__
for feature in tool.Parametric.EDIT_TYPES:
if feature.name in annotations:
box.prop(self.gizmos, feature.name)
bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Door", self.draw_door_gizmo_parameters)
bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Window", self.draw_window_gizmo_parameters)
bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Stair", self.draw_stair_gizmo_parameters)
bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Wall", self.draw_wall_gizmo_parameters)
def _draw_parametric_gizmo_parameters(
self,
layout: bpy.types.UILayout,
gizmo_pg: bpy.types.PropertyGroup,
dimension_gizmo_class: type,
special_gizmo_names: frozenset[str] = frozenset(),
) -> None:
"""Draw the per-element gizmo visibility toggles. Surfaces every annotation
on ``gizmo_pg`` that either maps to one of ``dimension_gizmo_class``'s
dimension gizmos or is named in ``special_gizmo_names`` (non-dimension icons
like baseline cycle, scissors, rotate, )."""
visible_names = {p.attr_name for p in dimension_gizmo_class.dimension_gizmo_props} | special_gizmo_names
try:
annotations = gizmo_pg.__annotations__
except AttributeError:
annotations = type(gizmo_pg).__annotations__
for prop in annotations:
if prop in visible_names:
layout.prop(gizmo_pg, prop)
def draw_door_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
from bonsai.bim.module.model.door import GizmoDoorEdition
self._draw_parametric_gizmo_parameters(
layout, self.gizmos.door, GizmoDoorEdition, frozenset({"swing_arc", "flip_arc"})
)
def draw_window_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
from bonsai.bim.module.model.window import GizmoWindowEdition
self._draw_parametric_gizmo_parameters(layout, self.gizmos.window, GizmoWindowEdition)
def draw_stair_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
from bonsai.bim.module.model.stair import GizmoStairEdition
self._draw_parametric_gizmo_parameters(
layout, self.gizmos.stair, GizmoStairEdition, frozenset({"lock", "plus", "minus", "cycle"})
)
def draw_wall_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
from bonsai.bim.module.model.wall import GizmoWallEdition
self._draw_parametric_gizmo_parameters(
layout,
self.gizmos.wall,
GizmoWallEdition,
frozenset({"cycle", "scissors", "extend", "extend_height", "rotate", "toggle_openings"}),
)
def draw_model_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
layout.prop(self, "occurrence_name_style")
-3
View File
@@ -93,8 +93,6 @@ 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()
@@ -109,7 +107,6 @@ 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):
+7 -57
View File
@@ -161,73 +161,23 @@ def align_objects(
model.align_objects(reference_obj, objs, align_type)
def regenerate_wall_to_underside(
ifc: type[tool.Ifc],
geometry: type[tool.Geometry],
model: type[tool.Model],
wall_objs: list[bpy.types.Object],
) -> None:
"""Re-clip walls to their connected underside objects after the slab has moved."""
clipped_objs = []
for obj in wall_objs:
wall = ifc.get_entity(obj)
slab_objs = model.get_connected_slab_objs(wall)
if not slab_objs:
continue
if ifc.is_moved(obj):
geometry.run_edit_object_placement(obj=obj)
# Sync each slab's Blender mesh to its current IFC representation before
# reading face geometry, so a changed profile is picked up correctly.
model.reload_body_representation(slab_objs)
model.remove_wall_to_underside_booleans(wall)
for slab_obj in slab_objs:
clip = model.get_slab_clipping_bmesh(slab_obj)
if clip:
model.clip_wall_to_slab(wall, clip)
clipped_objs.append(obj)
if clipped_objs:
model.reload_body_representation(clipped_objs)
def extend_wall_to_slab(
ifc: type[tool.Ifc],
geometry: type[tool.Geometry],
model: type[tool.Model],
slab_objs: list[bpy.types.Object],
slab_obj: bpy.types.Object,
wall_objs: list[bpy.types.Object],
) -> None:
# If any wall is currently in item mode, exit it before modifying the
# representation. Leaving stale item objects around causes delete_ifc_item
# to later remove the extrusion (or other pre-boolean items) from inside
# the boolean chain, corrupting the IFC model.
geom_props = geometry.get_geometry_props()
if geom_props.representation_obj in wall_objs:
geometry.disable_item_mode()
clipped_walls = []
if not (clip := model.get_slab_clipping_bmesh(slab_obj)):
return # Nothing to clip?
slab = ifc.get_entity(slab_obj)
for obj in wall_objs:
if ifc.is_moved(obj):
geometry.run_edit_object_placement(obj=obj)
wall = ifc.get_entity(obj)
# Merge previously connected slabs with newly requested ones so that
# re-running the operator never produces duplicate booleans and never
# silently discards clips that were applied in an earlier call.
existing = model.get_connected_slab_objs(wall)
seen = {id(s) for s in existing}
all_slab_objs = list(existing) + [s for s in slab_objs if id(s) not in seen]
# Remove stale booleans once, then re-clip against the full set.
model.remove_wall_to_underside_booleans(wall)
did_clip = False
for slab_obj in all_slab_objs:
clip = model.get_slab_clipping_bmesh(slab_obj)
if not clip:
continue
model.clip_wall_to_slab(wall, clip)
model.connect_wall_to_slab(wall, ifc.get_entity(slab_obj))
did_clip = True
if did_clip:
clipped_walls.append(obj)
if clipped_walls:
model.reload_body_representation(clipped_walls)
model.clip_wall_to_slab(wall, clip)
model.connect_wall_to_slab(wall, slab)
model.reload_body_representation(wall_objs)
class RequireTwoWallsError(Exception):
+22 -1
View File
@@ -20,6 +20,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Optional
import ifcopenshell.util.element
if TYPE_CHECKING:
import bpy
import ifcopenshell
@@ -56,12 +58,31 @@ def copy_class(
geometry.change_object_data(obj, data, is_global=True)
geometry.rename_object(data, geometry.get_representation_name(ifc.get_entity(data)))
# Only assign styles if element doesn't get them from material
if not root.has_material_styles(new):
if not _has_material_styles(ifc, new):
root.assign_body_styles(new, obj)
collector.assign(obj)
return new
def _has_material_styles(ifc: type[tool.Ifc], element: ifcopenshell.entity_instance) -> bool:
"""Check if element has styles defined through its material.
Returns True if any constituent material has a style representation,
which means styles should NOT be applied directly to the geometry.
"""
materials = ifcopenshell.util.element.get_materials(element)
if not materials:
return False
# Check if any of the constituent materials have styles
for material in materials:
if hasattr(material, "HasRepresentation") and material.HasRepresentation:
return True
return False
def assign_class(
ifc: type[tool.Ifc],
collector: type[tool.Collector],
+2 -3
View File
@@ -64,11 +64,10 @@ def assign_container(
spatial.disable_editing(obj)
all_elements.add(root_element)
all_elements.update(spatial.get_decomposition(root_element))
if products := [e for e in root_elements if spatial.can_contain(container, e)]:
if products := [e for e in root_elements if spatial.can_contain(container, root_element)]:
ifc.run("spatial.assign_container", products=products, relating_structure=container)
for element in all_elements:
if obj := ifc.get_object(element):
collector.assign(obj)
collector.assign(ifc.get_object(element))
def enable_editing_container(spatial: type[tool.Spatial], obj: bpy.types.Object) -> None:
+2 -6
View File
@@ -459,6 +459,7 @@ class Geometry:
def has_axis_representation(cls, element): pass
def has_data_users(cls, data): pass
def has_material_style_override(cls, obj): pass
def has_material_styles(cls, element): pass
def import_representation_parameters(cls, data): pass
def is_body_representation(cls, representation): pass
def is_box_representation(cls, representation): pass
@@ -680,9 +681,6 @@ class Model:
def export_profile(cls, obj, position=None): pass
def generate_occurrence_name(cls, element_type, ifc_class): pass
def get_extrusion(cls, representation): pass
def get_connected_slab_objs(cls, wall): pass
def get_connected_wall_objs(cls, slab): pass
def has_underside_connection(cls, element): pass
def get_manual_booleans(cls, element): pass
def get_material_layer_parameters(cls, element): pass
def get_slab_clipping_bmesh(cls, obj): pass
@@ -698,7 +696,6 @@ class Model:
def regenerate_profile(cls, obj): pass
def regenerate_slab(cls, obj): pass
def reload_body_representation(cls, obj_or_objects): pass
def remove_wall_to_underside_booleans(cls, wall): pass
def replace_object_ifc_representation(cls, ifc_file, ifc_context, obj, new_representation): pass
@@ -795,7 +792,7 @@ class Profile:
@interface
class Parametric:
def get_geom_generation(cls) -> int: pass
def refresh_post_commit(cls, operator) -> None: pass
def refresh_post_commit(cls) -> None: pass
@interface
@@ -887,7 +884,6 @@ class Root:
def get_object_name(cls, obj): pass
def get_object_representation(cls, obj): pass
def get_representation_context(cls, representation): pass
def has_material_styles(cls, element): pass
def is_containable(cls, element): pass
def is_drawing_annotation(cls, element): pass
def is_element_a(cls, element, ifc_class): pass
-21
View File
@@ -205,27 +205,6 @@ 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
+83 -72
View File
@@ -229,22 +229,15 @@ class Blender(bonsai.core.tool.Blender):
@classmethod
def get_active_object(cls, is_selected: bool = False) -> Union[bpy.types.Object, None]:
"""Return the active object, or ``None`` when the current context
exposes neither ``active_object`` nor a ``view_layer`` (stripped
operator contexts).
"""Gets the active object
:param is_selected: If true, the active object also needs to be selected.
"""
obj = getattr(bpy.context, "active_object", None)
if obj is None:
view_layer = getattr(bpy.context, "view_layer", None)
if view_layer is not None:
obj = view_layer.objects.active
if obj is None:
return None
if is_selected and not obj.select_get():
return None
return obj
if obj := (getattr(bpy.context, "active_object", None) or bpy.context.view_layer.objects.active):
if not is_selected:
return obj
if obj.select_get():
return obj
@classmethod
def get_selected_objects(cls, include_active: bool = True) -> set[bpy.types.Object]:
@@ -887,57 +880,19 @@ class Blender(bonsai.core.tool.Blender):
# ( 1.0, 1.0, -1.0), # 7
# ]
bound_box = obj.bound_box
min_pt = Vector(bound_box[0])
max_pt = Vector(bound_box[6])
bbox_dict = {
"min_x": min_pt.x,
"max_x": max_pt.x,
"min_y": min_pt.y,
"max_y": max_pt.y,
"min_z": min_pt.z,
"max_z": max_pt.z,
"min_point": min_pt,
"max_point": max_pt,
"center": (max_pt + min_pt) / 2,
# Intrinsic per-axis size in object-local space. Distinct from
# ``obj.dimensions``, which folds object-level scale into its
# output; this is the raw mesh bbox extent.
"dimensions": (max_pt.x - min_pt.x, max_pt.y - min_pt.y, max_pt.z - min_pt.z),
"min_x": bound_box[0][0],
"max_x": bound_box[6][0],
"min_y": bound_box[0][1],
"max_y": bound_box[6][1],
"min_z": bound_box[0][2],
"max_z": bound_box[6][2],
"min_point": Vector(bound_box[0]),
"max_point": Vector(bound_box[6]),
"center": (Vector(bound_box[6]) + Vector(bound_box[0])) / 2,
}
return bbox_dict
@classmethod
def get_object_world_bounding_box(cls, obj: bpy.types.Object) -> dict[str, Union[float, Vector]]:
"""Same shape as ``get_object_bounding_box`` but with ``matrix_world``
applied extents are computed across the 8 transformed corners, so
a rotated or scaled object reports its actual world-axis AABB rather
than the misleading transform of the local-space corners.
``bound_box[0]`` / ``bound_box[6]`` are the local min/max corners but
do NOT correspond to the world AABB extremes once the object is
rotated, so min/max must be taken per-axis across all 8 corners."""
corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
xs = [c.x for c in corners]
ys = [c.y for c in corners]
zs = [c.z for c in corners]
min_point = Vector((min(xs), min(ys), min(zs)))
max_point = Vector((max(xs), max(ys), max(zs)))
return {
"min_x": min_point.x,
"max_x": max_point.x,
"min_y": min_point.y,
"max_y": max_point.y,
"min_z": min_point.z,
"max_z": max_point.z,
"min_point": min_point,
"max_point": max_point,
"center": (min_point + max_point) / 2,
# World-axis-aligned per-axis size. For rotated objects this is
# the AABB extent, not the intrinsic mesh size (use the local
# variant for that).
"dimensions": (max_point.x - min_point.x, max_point.y - min_point.y, max_point.z - min_point.z),
}
@classmethod
def select_and_activate_single_object(cls, context: bpy.types.Context, active_object: bpy.types.Object) -> None:
for obj in context.selected_objects:
@@ -1377,6 +1332,74 @@ class Blender(bonsai.core.tool.Blender):
return True
class Modifier:
# ----------------------------------------------------------------------
# FIXME(PR5): backward-compat shims for callers still using the
# pre-refactor API. The is_<type> predicates now live on tool.Parametric;
# the Array helper bag now lives on tool.Array. PR4 migrates each caller;
# this whole shim block is removed in PR5's cleanup.
# ----------------------------------------------------------------------
@classmethod
def is_door(cls, element: entity_instance) -> bool:
return tool.Parametric.is_door(element)
@classmethod
def is_railing(cls, element: entity_instance) -> bool:
return tool.Parametric.is_railing(element)
@classmethod
def is_roof(cls, element: entity_instance) -> bool:
return tool.Parametric.is_roof(element)
@classmethod
def is_stair(cls, element: entity_instance) -> bool:
return tool.Parametric.is_stair(element)
@classmethod
def is_wall(cls, element: entity_instance) -> bool:
return tool.Parametric.is_wall(element)
@classmethod
def is_window(cls, element: entity_instance) -> bool:
return tool.Parametric.is_window(element)
class Array:
@classmethod
def bake_children_transform(cls, parent_element: ifcopenshell.entity_instance, item: int) -> None:
tool.Array.bake_children_transform(parent_element, item)
@classmethod
def constrain_children_to_parent(cls, parent_element: ifcopenshell.entity_instance) -> None:
tool.Array.constrain_children_to_parent(parent_element)
@classmethod
def get_all_children_objects(cls, parent_element: ifcopenshell.entity_instance) -> list:
return tool.Array.get_all_children_objects(parent_element)
@classmethod
def get_all_objects(cls, parent_element: ifcopenshell.entity_instance) -> list:
return tool.Array.get_all_objects(parent_element)
@classmethod
def get_children_objects(cls, modifier_data: dict) -> list:
return tool.Array.get_children_objects(modifier_data)
@classmethod
def get_modifiers_data(cls, parent_element: ifcopenshell.entity_instance):
return tool.Array.get_modifiers_data(parent_element)
@classmethod
def remove_constraints(cls, parent_element: ifcopenshell.entity_instance) -> None:
tool.Array.remove_constraints(parent_element)
@classmethod
def set_children_lock_state(
cls, parent_element: ifcopenshell.entity_instance, item: int, lock: bool
) -> None:
tool.Array.set_children_lock_state(parent_element, item, lock)
# ----------------------------------------------------------------------
@classmethod
def try_applying_edit_mode(cls, obj: bpy.types.Object, element: entity_instance) -> bool:
"""Tries to validate the current BIM modifier parameters for the active object
@@ -1995,18 +2018,6 @@ class Blender(bonsai.core.tool.Blender):
dct = {cls.bl_idname: cls.ifc_element_type for cls in (BimTool.__subclasses__())}
return types.MappingProxyType(dct)
@classmethod
@lru_cache
def get_property_header_tools(cls) -> frozenset[str]:
"""``BimTool`` plus its parametric subclasses — the workspace
tools whose 3D-view / N-panel header surfaces BIM Tool property
floats (extrusion_depth, length, x_angle). ``AnnotationTool``
and the non-``BimTool`` workspace tools (spatial / structural /
cad / covering) are excluded by construction."""
from bonsai.bim.module.model.workspace import BimTool
return frozenset(cls.bl_idname for cls in (BimTool.__subclasses__() + [BimTool]))
@classmethod
def get_object_constraint_props(cls, obj: bpy.types.Object) -> BIMObjectConstraintProperties:
return obj.BIMObjectConstraintProperties # pyright: ignore[reportAttributeAccessIssue]
-1
View File
@@ -135,7 +135,6 @@ 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:
+15 -42
View File
@@ -257,13 +257,7 @@ class Geometry(bonsai.core.tool.Geometry):
break
mesh = obj.data
assert isinstance(mesh, bpy.types.Mesh)
item_id = tool.Geometry.get_mesh_props(mesh).ifc_definition_id
try:
item = tool.Ifc.get().by_id(item_id)
except RuntimeError:
# Entity already deleted (e.g. removed as part of a sibling boolean collapse).
bpy.data.objects.remove(obj)
return
item = tool.Ifc.get().by_id(tool.Geometry.get_mesh_props(mesh).ifc_definition_id)
rep_obj = props.representation_obj
assert (rep_obj := props.representation_obj) and (rep_element := tool.Ifc.get_entity(rep_obj))
cls.remove_representation_item(item, rep_element)
@@ -427,29 +421,6 @@ 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'.
@@ -849,6 +820,15 @@ class Geometry(bonsai.core.tool.Geometry):
return True
return False
@classmethod
def has_material_styles(cls, element: ifcopenshell.entity_instance) -> bool:
"""True when any of ``element``'s materials exposes an
``IfcSurfaceStyle``. Gate body-style assignment to avoid double-styling."""
return any(
tool.Material.get_style(material) is not None
for material in ifcopenshell.util.element.get_materials(element)
)
@classmethod
def reimport_element_representations(
cls, obj: bpy.types.Object, representation: ifcopenshell.entity_instance, apply_openings: bool = True
@@ -1154,16 +1134,11 @@ class Geometry(bonsai.core.tool.Geometry):
@classmethod
def get_representation_item(cls, obj: bpy.types.Object) -> Union[ifcopenshell.entity_instance, None]:
data = obj.data
if not isinstance(data, Geometry.TYPES_WITH_MESH_PROPERTIES):
return None
ifc_id = tool.Geometry.get_mesh_props(data).ifc_definition_id
if not ifc_id:
return None
try:
item = tool.Ifc.get().by_id(ifc_id)
except RuntimeError:
return None
if item.is_a("IfcRepresentationItem"):
if (
isinstance(data, Geometry.TYPES_WITH_MESH_PROPERTIES)
and (ifc_id := tool.Geometry.get_mesh_props(data).ifc_definition_id)
and ((item := tool.Ifc.get().by_id(ifc_id)).is_a("IfcRepresentationItem"))
):
return item
return None
@@ -1337,8 +1312,6 @@ class Geometry(bonsai.core.tool.Geometry):
cls, representation: ifcopenshell.entity_instance
) -> ifcopenshell.entity_instance:
if representation.RepresentationType == "MappedRepresentation":
if not representation.Items:
return representation
return cls.resolve_mapped_representation(representation.Items[0].MappingSource.MappedRepresentation)
return representation
+2 -2
View File
@@ -1216,7 +1216,7 @@ class Loader(bonsai.core.tool.Loader):
) -> bool:
items = [i["item"] for i in ifcopenshell.util.representation.resolve_items(representation)]
if len(items) == 1 and items[0].is_a("IfcSweptDiskSolid"):
if tool.Parametric.is_railing(element):
if tool.Blender.Modifier.is_railing(element):
return False
return True
elif len(items) and ( # See #2508 why we accommodate for invalid IFCs here
@@ -1224,7 +1224,7 @@ class Loader(bonsai.core.tool.Loader):
and len({i.is_a() for i in items}) == 1
and len({i.Radius for i in items}) == 1
):
if tool.Parametric.is_railing(element):
if tool.Blender.Modifier.is_railing(element):
return False
return True
return False
+8 -3
View File
@@ -227,8 +227,10 @@ class Misc(bonsai.core.tool.Misc):
@classmethod
def set_object_origin_to_bottom(cls, obj: bpy.types.Object) -> None:
absolute_bound_box = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
min_z = min([c[2] for c in absolute_bound_box])
new_origin = obj.matrix_world.translation.copy()
new_origin[2] = tool.Blender.get_object_world_bounding_box(obj)["min_z"]
new_origin[2] = min_z
assert isinstance(obj.data, bpy.types.Mesh)
obj.data.transform(
Matrix.Translation(
@@ -247,8 +249,11 @@ class Misc(bonsai.core.tool.Misc):
@classmethod
def scale_object_to_height(cls, obj: bpy.types.Object, height: float) -> None:
bbox = tool.Blender.get_object_world_bounding_box(obj)
scale_factor = height / (bbox["max_z"] - bbox["min_z"])
absolute_bound_box = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
max_z = max([c[2] for c in absolute_bound_box])
min_z = min([c[2] for c in absolute_bound_box])
current_absolute_height = max_z - min_z
scale_factor = height / current_absolute_height
obj.matrix_world @= Matrix.Scale(
scale_factor, 4, obj.matrix_world.inverted().to_quaternion() @ Vector((0, 0, 1))
)
+21 -124
View File
@@ -351,8 +351,6 @@ class Model(bonsai.core.tool.Model):
@classmethod
def get_extrusion(cls, representation: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]:
"""Return first found IfcExtrudedAreaSolid"""
if not representation.Items:
return None
item = representation.Items[0]
while True:
if item.is_a("IfcExtrudedAreaSolid"):
@@ -845,57 +843,6 @@ class Model(bonsai.core.tool.Model):
items.append(item.FirstOperand)
return booleans
@classmethod
def get_connected_slab_objs(cls, wall: ifcopenshell.entity_instance) -> list[bpy.types.Object]:
"""Return Blender objects for slabs connected to wall via IfcRelConnectsElements(TOP)."""
result = []
for rel in wall.ConnectedFrom:
if rel.is_a("IfcRelConnectsElements") and rel.Description == "TOP":
slab_obj = tool.Ifc.get_object(rel.RelatingElement)
if slab_obj:
result.append(slab_obj)
return result
@classmethod
def get_connected_wall_objs(cls, slab: ifcopenshell.entity_instance) -> list[bpy.types.Object]:
"""Return Blender objects for LAYER2 walls connected to slab via IfcRelConnectsElements(TOP)."""
result = []
for rel in slab.ConnectedTo:
if rel.is_a("IfcRelConnectsElements") and rel.Description == "TOP":
wall_obj = tool.Ifc.get_object(rel.RelatedElement)
if wall_obj:
result.append(wall_obj)
return result
@classmethod
def has_underside_connection(cls, element: ifcopenshell.entity_instance) -> bool:
"""Return True if element has an IfcRelConnectsElements(TOP) relationship."""
return any(rel.is_a("IfcRelConnectsElements") and rel.Description == "TOP" for rel in element.ConnectedFrom)
@classmethod
def remove_wall_to_underside_booleans(cls, wall: ifcopenshell.entity_instance) -> None:
"""Remove all IfcBooleanResult items previously added by extend_walls_to_underside."""
manual_booleans = cls.get_manual_booleans(wall)
if not manual_booleans:
return
ifc_file = tool.Ifc.get()
for b in manual_booleans:
sec = b.SecondOperand
if sec is None:
# The IfcPolygonalFaceSet was already deleted externally. Splice the
# orphaned IfcBooleanResult out of the chain so the representation stays valid.
parents = list(ifc_file.get_inverse(b))
for parent in parents:
if parent.is_a("IfcBooleanResult") and parent.FirstOperand == b:
parent.FirstOperand = b.FirstOperand
elif parent.is_a("IfcShapeRepresentation"):
new_items = tuple((set(parent.Items) - {b}) | {b.FirstOperand})
parent.Items = new_items
cls.unmark_manual_booleans(wall, [b.id()])
ifc_file.remove(b)
elif sec.is_a("IfcTessellatedFaceSet"):
tool.Geometry.remove_representation_item(sec, wall)
@classmethod
def get_manual_booleans(
cls, element: ifcopenshell.entity_instance, representation: Optional[ifcopenshell.entity_instance] = None
@@ -908,8 +855,7 @@ class Model(bonsai.core.tool.Model):
representation = tool.Geometry.get_body_representation(element)
if not representation:
return []
all_chain_booleans = cls.get_booleans(element, representation)
booleans = [b for b in all_chain_booleans if b.id() in boolean_ids]
booleans = [b for b in cls.get_booleans(element, representation) if b.id() in boolean_ids]
return booleans
@classmethod
@@ -2611,15 +2557,12 @@ class Model(bonsai.core.tool.Model):
clipping_bm = bmesh.new()
vertex_map = {}
kept = 0
for face in bm.faces:
face.normal_update()
normal = face.normal.to_4d()
normal.w = 0
world_normal_z = (obj.matrix_world @ normal).z
if world_normal_z >= -0.5:
if (obj.matrix_world @ normal).z >= -0.5:
continue
kept += 1
new_verts = []
for vert in face.verts:
if not (new_vert := vertex_map.get(vert.index, None)):
@@ -2632,7 +2575,6 @@ class Model(bonsai.core.tool.Model):
return
bmesh.ops.recalc_face_normals(clipping_bm, faces=clipping_bm.faces)
clipping_bm.faces.ensure_lookup_table()
return clipping_bm # clipping_bm is in project units
@classmethod
@@ -2646,53 +2588,17 @@ class Model(bonsai.core.tool.Model):
min_z = min(zs)
max_z = max(zs)
ifc_file = tool.Ifc.get()
builder = ifcopenshell.util.shape_builder.ShapeBuilder(ifc_file)
operand = None
if (z := max_z - min_z) and not np.isclose(z, 0.0):
builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get())
# Build one IfcPolygonalFaceSet clip solid per clipping face.
# Each solid uses a rectangle on the slope plane rather than the exact face
# footprint. The original approach (exact footprint) caused a kissing-solid /
# boundary-coincidence bug when the operator is called twice for a ridge roof: the
# two slope solids share an exact ridge edge, and OCCT produces spurious extra
# vertices. Extending each solid slightly past the ridge (by margin) creates a
# volumetric overlap instead of a kissing boundary — OCCT handles overlapping
# DIFFERENCE operands correctly.
margin = 1.0 # project units past the face edge — enough to ensure overlap at ridge
operands = []
for face in bm.faces:
face.normal_update()
normal = Vector(face.normal).normalized()
result = bmesh.ops.extrude_face_region(bm, geom=bm.faces)
extruded_verts = [elem for elem in result["geom"] if isinstance(elem, bmesh.types.BMVert)]
bmesh.ops.translate(bm, verts=extruded_verts, vec=(0, 0, z))
# Orthonormal basis spanning the slope plane.
ref = Vector((0, 0, 1)) if abs(normal.z) < 0.9 else Vector((1, 0, 0))
tangent1 = normal.cross(ref).normalized()
tangent2 = normal.cross(tangent1).normalized()
centroid = sum((v.co for v in face.verts), Vector()) / len(face.verts)
# Tight bounding rectangle in slope-plane coords, plus a small margin.
t1_coords = [(v.co - centroid).dot(tangent1) for v in face.verts]
t2_coords = [(v.co - centroid).dot(tangent2) for v in face.verts]
half1 = max(abs(c) for c in t1_coords) + margin
half2 = max(abs(c) for c in t2_coords) + margin
# Rectangle on the slope plane, extruded upward in wall-local Z.
clip_bm = bmesh.new()
v0 = clip_bm.verts.new(centroid + half1 * tangent1 + half2 * tangent2)
v1 = clip_bm.verts.new(centroid - half1 * tangent1 + half2 * tangent2)
v2 = clip_bm.verts.new(centroid - half1 * tangent1 - half2 * tangent2)
v3 = clip_bm.verts.new(centroid + half1 * tangent1 - half2 * tangent2)
bottom_face = clip_bm.faces.new([v0, v1, v2, v3])
result = bmesh.ops.extrude_face_region(clip_bm, geom=[bottom_face])
top_verts = [e for e in result["geom"] if isinstance(e, bmesh.types.BMVert)]
bmesh.ops.translate(clip_bm, verts=top_verts, vec=Vector((0, 0, max_z - min_z)))
clip_bm.verts.ensure_lookup_table()
clip_verts = [v.co for v in clip_bm.verts]
clip_faces = [[v.index for v in f.verts] for f in clip_bm.faces]
operand = builder.mesh(clip_verts, clip_faces)
clip_bm.free()
operands.append(operand)
verts = [v.co for v in bm.verts]
faces = [[v.index for v in p.verts] for p in bm.faces]
operand = builder.mesh(verts, faces)
for extrusion in ifcopenshell.util.shape.get_base_extrusions(wall) or []:
if extrusion.Position:
@@ -2709,9 +2615,10 @@ class Model(bonsai.core.tool.Model):
extrusion.Depth = max_z / direction[2]
if operands:
body_repr = ifcopenshell.util.representation.get_representation(wall, "Model", "Body", "MODEL_VIEW")
booleans = ifcopenshell.api.geometry.add_boolean(ifc_file, first_item=extrusion, second_items=operands)
if operand:
booleans = ifcopenshell.api.geometry.add_boolean(
tool.Ifc.get(), first_item=extrusion, second_items=[operand]
)
tool.Model.mark_manual_booleans(wall, booleans)
@classmethod
@@ -2943,7 +2850,7 @@ class Model(bonsai.core.tool.Model):
def offset_wall(cls, wall: bpy.types.Object, baseline: Literal["EXTERIOR", "INTERIOR", "CENTER"]) -> None:
element = tool.Ifc.get_entity(wall)
usage = ifcopenshell.util.element.get_material(element)
if usage is None or not usage.is_a("IfcMaterialLayerSetUsage"):
if not usage.is_a("IfcMaterialLayerSetUsage"):
return
layer_set = usage.ForLayerSet
if baseline == "CENTER":
@@ -2964,20 +2871,10 @@ class Model(bonsai.core.tool.Model):
@classmethod
def recreate_wall(cls, element: ifcopenshell.entity_instance, obj: bpy.types.Object) -> None:
# Curved fillet-corner walls own a hand-built banana body that
# ``regenerate_wall_representation`` would flatten — it reads the axis
# as a 2-point reference line and builds a straight extrusion. Rebuild
# the curve in place instead: ``regenerate_fillet_corner_wall`` keeps
# radius + placement from the pset / current ``ObjectPlacement`` while
# picking up new thickness / height from the wall type, which is what
# we want when a type-property edit triggered this call.
if tool.Parametric.is_fillet_corner_wall(element):
# Lazy import: ``tool.Model`` loads before ``bim/module/model`` at
# addon enable; a module-level import would cycle.
from bonsai.bim.module.model.wall import regenerate_fillet_corner_wall
regenerate_fillet_corner_wall(element, obj)
return
# FIXME(PR4): the fillet-corner branch lands with PR4's
# `regenerate_fillet_corner_wall` (bim/module/model/wall.py). On v0.8.0
# the function doesn't exist; falling through to the straight-extrusion
# path preserves v0.8.0 behaviour for fillet walls until PR4 ships.
rep = ifcopenshell.api.geometry.regenerate_wall_representation(tool.Ifc.get(), element)
bonsai.core.geometry.switch_representation(
tool.Ifc,
@@ -3012,7 +2909,7 @@ class Model(bonsai.core.tool.Model):
if not wall:
continue
is_layer2_usage = tool.Model.get_usage_type(element) == "LAYER2"
is_fillet_corner = tool.Parametric.is_fillet_corner_wall(element)
is_fillet_corner = bool(ifcopenshell.util.element.get_pset(element, "BBIM_Wall", "IsFilletCorner"))
if not (is_layer2_usage or is_fillet_corner):
continue
if is_layer2_usage:
+35 -40
View File
@@ -147,17 +147,17 @@ class Parametric(bonsai.core.tool.Parametric):
self._data.clear()
self._gen = None
# 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.
# FIXME(PR4): array / pipe_segment / duct_segment land with their
# finish/cancel operators in PR4. Adding them to EDIT_TYPES without those
# operators makes auto-commit-on-save dispatch bim.finish_editing_<name>
# for objects flagged as in-edit, which then raises because the operator
# doesn't exist. PR4 re-adds the three entries together with the operators.
EDIT_TYPES: list[ParametricObject] = [
ParametricObject("door", has_non_editable_path=True, supports_build_edit_lifecycle=True),
ParametricObject("window", has_non_editable_path=True, supports_build_edit_lifecycle=True),
ParametricObject("stair", has_non_editable_path=True, supports_build_edit_lifecycle=True),
ParametricObject("railing", supports_build_edit_lifecycle=True),
ParametricObject("roof", supports_build_edit_lifecycle=True),
ParametricObject("array", supports_build_edit_lifecycle=True),
ParametricObject("wall"),
]
@@ -169,7 +169,6 @@ 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
@@ -179,24 +178,16 @@ class Parametric(bonsai.core.tool.Parametric):
return cls._geom_generation
@classmethod
def refresh_post_commit(cls, operator: bpy.types.Operator) -> None:
"""Post-commit hook for ``tool.Ifc.Operator``: bumps the geometry
generation counter so caches keyed off it drop stale entries on
the next draw, and tags viewports for redraw.
def refresh_post_commit(cls) -> None:
"""Post-commit hook for ``tool.Ifc.Operator``: re-syncs scene-level
workspace-tool header fields from current IFC state and bumps the
geometry generation counter so caches keyed off it drop stale
entries on the next draw."""
import bonsai.bim.handler # late import: bim.handler imports tool.*
Additionally refreshes the BIM Tool header floats for the
validate-gizmo path operators whose ``bl_idname`` is the
``finish_op`` of an entry in ``EDIT_TYPES``. That is the only
commit class where selection didn't change but the header
values displayed did. Other operators skip the refresh: they
don't target an active-object header edit, and their commit
context may lack the view-layer attributes the refresh reads."""
cls._geom_generation += 1
bonsai.bim.handler.update_bim_tool_props()
tool.Blender.update_all_viewports()
if operator.bl_idname in {feature.finish_op for feature in cls.EDIT_TYPES}:
import bonsai.bim.handler # late import: bim.handler imports tool.*
bonsai.bim.handler.refresh_bim_tool_headers()
@classmethod
def find_by_name(cls, name: str) -> Optional[ParametricObject]:
@@ -271,18 +262,6 @@ class Parametric(bonsai.core.tool.Parametric):
for obj in bpy.data.objects:
cls._validated_editing_feature(obj)
@classmethod
def on_load_post(cls, scene: bpy.types.Scene) -> None:
"""Drain load-transient parametric state on a freshly opened scene
so no draft edit flag, preview flag, or cache entry persists from
the saved file."""
from bonsai.bim.module.model import wall_offset_gizmos
from bonsai.bim.module.model.preview_base import discard_pending_previews
cls.heal_stale_edit_flags()
discard_pending_previews(scene)
wall_offset_gizmos.clear_caches()
@classmethod
def get_pending_edits(cls) -> list[tuple[bpy.types.Object, str]]:
"""``(object, finish_operator_bl_idname)`` pairs for every object
@@ -403,6 +382,29 @@ class Parametric(bonsai.core.tool.Parametric):
if hasattr(bpy.types.Object, feature.props_attr):
delattr(bpy.types.Object, feature.props_attr)
@classmethod
def iter_gizmo_preference_classes(cls, ui_module) -> list[type]:
"""``GizmoPreferences<Name>`` classes that exist on ``ui_module`` for
every registry entry, plus the shared ``GizmoPreferencesFeature`` if
present. Order matches ``EDIT_TYPES``. Used by ``bim/__init__.py`` to
inject the per-type ``GizmoPreferences<X>`` classes at the correct
point before ``ui.GizmoPreferences``, which references them via
``PointerProperty``."""
# FIXME(PR5): drop the per-feature loop once PR4 consolidates
# bim/ui.py to use a single shared GizmoPreferencesFeature class
# and rewrites GizmoPreferences accordingly. The shared-class
# branch is the forward-compat path; the per-feature loop keeps
# v0.8.0's bim/ui.py working until then.
out: list[type] = []
for feature in cls.EDIT_TYPES:
gpref = getattr(ui_module, f"GizmoPreferences{feature.name.capitalize()}", None)
if gpref is not None:
out.append(gpref)
shared = getattr(ui_module, "GizmoPreferencesFeature", None)
if shared is not None:
out.append(shared)
return out
# --- Feature-kind predicates ------------------------------------------------
# One predicate per registered parametric type. Each is total: accepts any
# IFC entity (or None), returns a bool, never raises. Predicates live with
@@ -485,13 +487,6 @@ class Parametric(bonsai.core.tool.Parametric):
return False
if tool.Model.get_usage_type(element) == "LAYER2":
return True
return cls.is_fillet_corner_wall(element)
@classmethod
def is_fillet_corner_wall(cls, element: entity_instance) -> bool:
"""``True`` if the wall carries the ``BBIM_Wall.IsFilletCorner`` flag,
marking it as a curved corner whose banana body is hand-built rather
than regenerated from the wall's axis + layer set."""
import ifcopenshell.util.element
return bool(ifcopenshell.util.element.get_pset(element, "BBIM_Wall", "IsFilletCorner"))
+59 -138
View File
@@ -373,38 +373,26 @@ class Raycast(bonsai.core.tool.Raycast):
except:
loc = Vector((0, 0, 0))
snap_obj._ensure_bvh()
verts_2d = [
view3d_utils.location_3d_to_region_2d(region, rv3d, v) for v in snap_obj.verts_3d
] # Numpy version is worst in performance
intersected = snap_obj.raycast_boxes(
context, event, snap_obj.root, intersected=[], rays=(ray_origin, ray_direction)
)
# Collect edges from intersected BVH boxes
edges = []
for it in intersected:
edges.extend(it.edges)
edges = set(edges)
# Build only the vertices indices that belong to these edges
verts_idx: set[int] = set()
for e in edges:
ev = snap_obj.obj.data.edges[e].vertices
verts_idx.add(ev[0])
verts_idx.add(ev[1])
# Lazily project only the needed vertices to 2D screen space
verts_2d: dict[int, Vector] = {}
for idx in verts_idx:
v2d = view3d_utils.location_3d_to_region_2d(region, rv3d, snap_obj.verts_3d[idx])
if v2d is not None:
verts_2d[idx] = v2d
edge_verts = {}
for e in edges:
verts_idx = snap_obj.obj.data.edges[e].vertices
v1 = snap_obj.verts_3d[verts_idx[0]]
v2 = snap_obj.verts_3d[verts_idx[1]]
v1_2d = verts_2d.get(verts_idx[0])
v2_2d = verts_2d.get(verts_idx[1])
verts_idx = tuple(snap_obj.obj.data.edges[e].vertices)
verts = snap_obj.obj.data.vertices
v1 = snap_obj.obj.matrix_world @ verts[verts_idx[0]].co
v1_2d = verts_2d[verts_idx[0]]
v2 = snap_obj.obj.matrix_world @ verts[verts_idx[1]].co
v2_2d = verts_2d[verts_idx[1]]
if (v1_2d is None) ^ (v2_2d is None):
point, _ = cls.intersect_edge_region_border(region, context.space_data, rv3d, v1, v2)
if v1_2d is None:
@@ -416,16 +404,10 @@ class Raycast(bonsai.core.tool.Raycast):
snap_threshold = 10.0
# Check all vertices for proximity to mouse position.
# Re-use the 2D projections already computed for edge endpoints.
for i, v3d in enumerate(snap_obj.verts_3d):
if i in verts_2d:
v2d = verts_2d[i]
else:
v2d = view3d_utils.location_3d_to_region_2d(region, rv3d, v3d)
if v2d is None:
continue
distance = (Vector(mouse_pos) - v2d).length
for i, point in enumerate(verts_2d):
if not point:
continue
distance = (Vector(mouse_pos) - point).length
if distance <= snap_threshold:
snap_point = {
"object": snap_obj.obj,
@@ -817,30 +799,6 @@ class Raycast(bonsai.core.tool.Raycast):
else:
return None, None, None
@classmethod
def process_wireframe_snap_obj(
cls,
context: bpy.types.Context,
event: bpy.types.Event,
snap_obj,
ray_origin: Vector,
closest_snaps: list,
):
snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
hit_obj = None
hit = None
if snap_points:
closest_length_squared = float("inf")
for point in snap_points:
point["group"] = "Wireframe"
closest_snaps.append(point)
length = (point["point"] - ray_origin).length_squared
if length < closest_length_squared:
closest_length_squared = length
hit = point["point"]
hit_obj = point["object"]
return hit_obj, hit
@classmethod
def ray_cast_and_get_closest_to_camera_snaps(
cls,
@@ -855,43 +813,35 @@ class Raycast(bonsai.core.tool.Raycast):
ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event)
space = context.space_data
xray_mode = (space.shading.type == "SOLID" and space.shading.show_xray) or (
space.shading.type == "WIREFRAME" and space.shading.show_xray_wireframe
)
closest_snaps = []
hit = None
if not xray_mode and objs_to_raycast:
# Non-xray - only the closest solid object's Face snap is kept by
# the caller (detect_snapping_points). Process solids in distance
# order and stop at the first hit to minimise raycasts.
wireframe_objs = []
solid_objs = []
for snap_obj in objs_to_raycast:
if snap_obj.obj.type in {"EMPTY", "CURVE"} or (
hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0
):
wireframe_objs.append(snap_obj)
else:
solid_objs.append(snap_obj)
for snap_obj in objs_to_raycast:
if snap_obj.obj.type in {"EMPTY", "CURVE"} or (
hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0
):
# For wireframe objects we have to test all the snaps to see which is closer
snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
closest_wf_hit = None
closest_wf_length_squared = 1.0
closest_wf_point = None
if snap_points:
for point in snap_points:
point["group"] = "Wireframe"
closest_snaps.append(point)
length = (point["point"] - ray_origin).length_squared
if closest_wf_hit is None or length < closest_wf_length_squared:
closest_wf_length_squared = length
closest_wf_hit = point["point"]
closest_wf_point = point
# Rough distance - object origin to ray origin
solid_objs.sort(key=lambda so: (so.obj.matrix_world.translation - ray_origin).length_squared)
if closest_wf_point:
hit_obj = closest_wf_point["object"]
hit = closest_wf_point["point"]
face_index = None
# Process wireframe objects first (all of them, always collected)
for snap_obj in wireframe_objs:
hit_obj, hit = cls.process_wireframe_snap_obj(context, event, snap_obj, ray_origin, closest_snaps)
if hit is not None:
length_squared = (hit - ray_origin).length_squared
if closest_obj is None or length_squared < closest_length_squared:
closest_length_squared = length_squared
closest_obj = hit_obj
closest_hit = hit
closest_face_index = None
# Process solid objects in distance order, stop at first hit
for snap_obj in solid_objs:
else:
# Solid objects
hit_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, snap_obj.obj)
if hit:
@@ -905,45 +855,14 @@ class Raycast(bonsai.core.tool.Raycast):
}
closest_snaps.append(snap_point)
length_squared = (hit - ray_origin).length_squared
if closest_obj is None or length_squared < closest_length_squared:
closest_length_squared = length_squared
closest_obj = hit_obj
closest_hit = hit
closest_face_index = face_index
break
else:
# Xray mode - process all objects (all snaps are kept by the caller)
for snap_obj in objs_to_raycast:
if snap_obj.obj.type in {"EMPTY", "CURVE"} or (
hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0
):
hit_obj, hit = cls.process_wireframe_snap_obj(context, event, snap_obj, ray_origin, closest_snaps)
face_index = None
else:
# Solid objects
hit_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, snap_obj.obj)
if hit:
snap_point = {
"point": hit,
"type": "Face",
"group": "Object",
"object": hit_obj,
"face_index": face_index,
"distance": 9, # High value so it has low priority
}
closest_snaps.append(snap_point)
if hit is not None:
length_squared = (hit - ray_origin).length_squared
if closest_obj is None or length_squared < closest_length_squared:
closest_length_squared = length_squared
closest_obj = hit_obj
closest_hit = hit
closest_face_index = face_index
# Here we test which is closer, including wireframe and solid objects
if hit is not None:
length_squared = (hit - ray_origin).length_squared
if closest_obj is None or length_squared < closest_length_squared:
closest_length_squared = length_squared
closest_obj = hit_obj
closest_hit = hit
closest_face_index = face_index
# Label snaps from the closest object
if closest_obj is not None:
@@ -969,6 +888,15 @@ class Raycast(bonsai.core.tool.Raycast):
def create_snap_obj(cls, obj):
if obj.data is None or not isinstance(obj.data, bpy.types.Mesh):
return None
# Evict cached entries whose Blender object has since been freed.
valid = []
for s in cls.snap_objs:
try:
_ = s.obj.name
valid.append(s)
except ReferenceError:
pass
cls.snap_objs[:] = valid
for i, snap_obj in enumerate(cls.snap_objs):
if obj.name == snap_obj.obj.name:
# Handle objects modified while a modal operator is active.
@@ -1017,19 +945,12 @@ class SnapObj:
def __init__(self, obj: bpy.types.Object):
self.__class__.all.append(self)
self.obj = obj
self.root = None
self._bvh_built = False
self.root = self._create_root_node()
self.root.edges = [e.index for e in obj.data.edges]
self.split_box(self.root, 0)
self.verts_3d = [obj.matrix_world @ v.co for v in obj.data.vertices]
self.snap_points = []
def _ensure_bvh(self):
if self._bvh_built:
return
self.root = self._create_root_node()
self.root.edges = [e.index for e in self.obj.data.edges]
self.split_box(self.root, 0)
self._bvh_built = True
def __clear_all__():
for instance in SnapObj.all:
del instance
+1 -13
View File
@@ -71,18 +71,6 @@ class Root(bonsai.core.tool.Root):
should_use_presentation_style_assignment=props.should_use_presentation_style_assignment,
)
@classmethod
def has_material_styles(cls, element: ifcopenshell.entity_instance) -> bool:
"""``True`` if any constituent material on ``element`` carries a style
representation. Body styles should NOT be applied directly when this
is True the material-inherited style is the authoritative source.
Paired with ``assign_body_styles``: callers check this first and only
call ``assign_body_styles`` when it returns False."""
materials = ifcopenshell.util.element.get_materials(element)
if not materials:
return False
return any(getattr(m, "HasRepresentation", None) for m in materials)
@classmethod
def copy_representation(
cls, source: ifcopenshell.entity_instance, dest: ifcopenshell.entity_instance
@@ -393,7 +381,7 @@ class Root(bonsai.core.tool.Root):
# Make sure that the array children also get reassigned to the correct aggregate
pset = ifcopenshell.util.element.get_pset(new[0], "BBIM_Array")
if pset:
array_children = tool.Array.get_all_children_objects(new[0])
array_children = tool.Blender.Modifier.Array.get_all_children_objects(new[0])
for obj in array_children:
bonsai.core.aggregate.assign_object(
tool.Ifc,
+23 -14
View File
@@ -687,10 +687,8 @@ Scenario: Saving with a door mid-edit auto-commits the draft value to the IFC ps
Then "active_object.BIMDoorProperties.is_editing" is "True"
When I press "bim.save_project(filepath='{temp_project_path}', should_save_as=True)"
Then "active_object.BIMDoorProperties.is_editing" is "False"
# BBIM_<Type> psets store project units, not raw Blender SI. The empty project
# used in an_empty_blender_session is METRIC_MM, so 2.5 m → 2500 mm in the pset.
And the variable "saved_height" is "__import__('json').loads(ifcopenshell.util.element.get_pset({ifc}.by_type('IfcDoor')[0], 'BBIM_Door', 'Data'))['overall_height']"
And the variable "saved_height" equals "2500.0"
And the variable "saved_height" equals "2.5"
Scenario: Saving with no parametric edits in progress leaves the door pset unchanged
Given an empty IFC project
@@ -707,10 +705,14 @@ Scenario: Saving with no parametric edits in progress leaves the door pset uncha
Scenario: Saving with a wall mid-edit auto-commits the draft to IFC
Given an empty IFC project
And I load the demo construction library
And I add a cube
And the object "Cube" is selected
And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType"
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
And I press "bim.assign_class"
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
And the variable "cube" is "{ifc}.by_type('IfcWallType')[0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{cube}"
And I press "bim.add_occurrence"
And the object "IfcWall/Wall" is selected
And I press "bim.enable_editing_wall()"
@@ -720,18 +722,21 @@ Scenario: Saving with a wall mid-edit auto-commits the draft to IFC
Scenario: Enabling and finishing a wall edit with no drag is a no-op
Given an empty IFC project
And I load the demo construction library
And I add a cube
And the object "Cube" is selected
And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType"
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
And I press "bim.assign_class"
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
And the variable "cube" is "{ifc}.by_type('IfcWallType')[0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{cube}"
And I press "bim.add_occurrence"
And the object "IfcWall/Wall" is selected
And the variable "entity_count_before" is "len(list({ifc}))"
When I press "bim.enable_editing_wall()"
And I press "bim.finish_editing_wall()"
Then "active_object.BIMWallProperties.is_editing" is "False"
And the variable "entity_count_after" is "len(list({ifc}))"
And the variable "entity_count_after" equals "{entity_count_before}"
And "len(list({ifc}))" is "{entity_count_before}"
Scenario: Cancelling a wall edit clears is_editing
Given an empty IFC project
@@ -751,10 +756,14 @@ Scenario: Cancelling a wall edit clears is_editing
Scenario: Wall parametric edit works on IFC2X3 projects
Given an empty IFC2X3 project
And I load the demo construction library
And I add a cube
And the object "Cube" is selected
And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType"
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
And I press "bim.assign_class"
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
And the variable "cube" is "{ifc}.by_type('IfcWallType')[0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{cube}"
And I press "bim.add_occurrence"
And the object "IfcWall/Wall" is selected
When I press "bim.enable_editing_wall()"
@@ -1,212 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""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
@@ -1,219 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Contract tests for the door swing-arc gizmo positioning.
Each test calls ``GizmoDoorEdition.update_swing_gizmos`` as an unbound method
against a SimpleNamespace stand-in that records ``matrix_basis`` assignments and
``hide`` flags. The expected matrices are recomputed from first principles so
the tests describe the geometric contract directly rather than echoing the
implementation."""
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)
@@ -1,96 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Behaviour contracts for the wall-fillet operator chain.
Each fillet operator's geometry path requires real Blender + IFC fixtures
(walls with IfcMaterialLayerSetUsage, neighbour rels, etc.). End-to-end
fillet round-trips belong in the bim feature suite (model.feature) where
that scaffolding already exists. This file pins the surface-level invariants
that don't depend on the geometry path:
* the lifecycle operators are registered under their conventional bl_idnames,
* the enable poll rejects ineligible selections.
State-clearing tests via ``bpy.ops.bim.cancel_wall_fillet_preview()`` were
removed because the dispatch is flaky in full-suite ordering the operator
early-returns when ``context.screen`` is unattached and prior tests can leave
the screen in that state. The behaviour is covered by the user-visible live
test loop instead."""
import 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
@@ -1,463 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Poll + positioning tests for ``GizmoHostAddOpening``.
The gizmo dispatches on element type: walls keep the existing axis-projection
math, while LAYER3 hosts (slabs, roofs) use a world-Z face bias derived from
the void object's elevation. Each branch is exercised independently with
mocks so the per-type contract is pinned without launching a full Blender
modelling session."""
import contextlib
from types import SimpleNamespace
from unittest.mock import patch
import bpy
import pytest
from mathutils import Matrix, Vector
import bonsai.tool as tool
from test.bim.bootstrap import NewFile
from test.bim.module.model.conftest import make_context
pytestmark = pytest.mark.model
# ---------------------------------------------------------------------------
# poll() — entry gate per host type and per co-selection shape
# ---------------------------------------------------------------------------
_IFC_CLASS_BY_KIND = {
"wall": "IfcWall",
"slab": "IfcSlab",
"roof": "IfcRoof",
"plain": "IfcDiscreteAccessory",
}
class _FakeIfcEntity:
"""Minimal stand-in for an ``ifcopenshell.entity_instance`` in poll tests.
Provides the two surfaces the gizmo's poll consults: ``is_a(type_name)``
(used directly by ``is_supported_host`` for slab/roof) and an optional
``HasOpenings`` attribute (probed by the poll's ``hasattr`` guard)."""
def __init__(self, ifc_class: str, has_openings: bool = True):
self._ifc_class = ifc_class
if has_openings:
self.HasOpenings = ()
def is_a(self, type_name: str) -> bool:
return self._ifc_class == type_name
def _build_poll_callbacks(selected, active_kind, other_kind):
"""Build the ``(get_entity, is_path_connectable_wall)`` side-effect
callables that simulate one poll() invocation. ``active_kind`` /
``other_kind`` accept ``"wall"``, ``"slab"``, ``"roof"``, ``"plain"``
(non-host IFC element), ``"mesh"`` (no IFC entity), or ``None``
(object outside the selection set).
Wall recognition goes through ``tool.Parametric.is_path_connectable_wall``
so fillet-corner walls (which have no LAYER2 usage) also surface the
add-opening icon; slab/roof use ``is_a`` on the fake entity so the
broadened class-based predicate is exercised."""
sentinels = {kind: _FakeIfcEntity(_IFC_CLASS_BY_KIND[kind]) for kind in _IFC_CLASS_BY_KIND}
# The "plain" sentinel lacks HasOpenings so the hasattr guard branch
# is reachable from the corresponding poll test.
sentinels["plain"] = _FakeIfcEntity(_IFC_CLASS_BY_KIND["plain"], has_openings=False)
def entity_for(kind):
if kind in (None, "mesh"):
return None
return sentinels[kind]
entity_map = {}
if len(selected) >= 1:
entity_map[id(selected[0])] = entity_for(active_kind)
if len(selected) >= 2:
entity_map[id(selected[1])] = entity_for(other_kind)
def get_entity(obj):
return entity_map.get(id(obj))
def is_path_connectable_wall(element):
return element is sentinels["wall"]
return get_entity, is_path_connectable_wall
def _run_poll(
patched_tool, prefs_on=True, n_selected=2, active_in_selected=True, active_kind="wall", other_kind="mesh"
):
from bonsai.bim.module.model.host_add_opening_gizmo import GizmoHostAddOpening
selected = [object() for _ in range(n_selected)]
active = selected[0] if (active_in_selected and selected) else object()
get_entity, is_path_connectable_wall = _build_poll_callbacks(selected, active_kind, other_kind)
with patched_tool(
viewport_gizmos=prefs_on,
selected=selected,
entity=get_entity,
modifier_predicates={"is_path_connectable_wall": is_path_connectable_wall},
):
return GizmoHostAddOpening.poll(make_context(active=active, selected=selected))
@pytest.mark.parametrize("host_kind", ["wall", "slab", "roof"])
def test_poll_accepts_each_host_with_a_plain_mesh_void(host_kind, patched_tool):
assert _run_poll(patched_tool, active_kind=host_kind, other_kind="mesh") is True
def test_poll_rejects_when_gizmo_toggle_off(patched_tool):
assert _run_poll(patched_tool, prefs_on=False) is False
def test_poll_rejects_when_selection_count_is_not_two(patched_tool):
assert _run_poll(patched_tool, n_selected=1) is False
assert _run_poll(patched_tool, n_selected=3) is False
def test_poll_rejects_when_active_is_not_in_selection(patched_tool):
assert _run_poll(patched_tool, active_in_selected=False) is False
def test_poll_rejects_when_active_has_no_ifc_entity(patched_tool):
assert _run_poll(patched_tool, active_kind="mesh") is False
def test_poll_rejects_when_active_is_not_a_host(patched_tool):
# "plain" sentinel is recognised as an IFC entity but is none of wall/slab/roof.
assert _run_poll(patched_tool, active_kind="plain") is False
@pytest.mark.parametrize(
"active_kind,other_kind",
[
("wall", "wall"), # wall-join gizmo owns this
("slab", "slab"), # future slab-edit gizmo
("roof", "roof"),
("wall", "slab"), # extend-vertically gizmo overlaps with this
("slab", "wall"),
("roof", "wall"),
],
)
def test_poll_rejects_host_host_pairs(active_kind, other_kind, patched_tool):
"""Host + host pairings must be suppressed so the icon never stacks with
the wall-join / extend-vertical / future slab-edit gizmos."""
assert _run_poll(patched_tool, active_kind=active_kind, other_kind=other_kind) is False
def test_poll_rejects_active_host_without_has_openings(patched_tool):
# Real-world equivalent: an IFC class that the active schema strips
# ``HasOpenings`` from (e.g., a non-element subtype). The active sentinel
# is set up as a connectable wall but with no HasOpenings attribute.
from bonsai.bim.module.model.host_add_opening_gizmo import GizmoHostAddOpening
selected = [object(), object()]
active = selected[0]
host_sentinel = object() # No HasOpenings attribute
other_sentinel = None
with patched_tool(
viewport_gizmos=True,
selected=selected,
entity=lambda o: host_sentinel if o is selected[0] else other_sentinel,
modifier_predicates={"is_path_connectable_wall": lambda e: e is host_sentinel},
):
assert GizmoHostAddOpening.poll(make_context(active=active, selected=selected)) is False
# ---------------------------------------------------------------------------
# position_gizmos() — branch dispatch and per-branch anchor math
# ---------------------------------------------------------------------------
def _run_position_wall_branch(patched_tool, *, other_translation=(0.5, 0.0, 0.0), top_down=True):
"""Drive the wall branch with stub IFC reads, returning the icon's
matrix_basis translation."""
from bonsai.bim.module.drawing import gizmos as gizmo_module
from bonsai.bim.module.model import host_add_opening_gizmo as host_mod
from bonsai.bim.module.model.host_add_opening_gizmo import GizmoHostAddOpening
geom = {"anchor_x": 0.0, "length": 2.0, "height": 3.0, "offset": 0.0, "thickness": 0.2}
wall_element = object()
active = SimpleNamespace(matrix_world=Matrix.Identity(4))
other = SimpleNamespace(matrix_world=Matrix.Translation(Vector(other_translation)))
selected = [active, other]
context = SimpleNamespace(active_object=active)
icon = SimpleNamespace(matrix_basis=None, hide=True)
self_stub = SimpleNamespace(add_opening_icon=icon)
with contextlib.ExitStack() as stack:
stack.enter_context(
patched_tool(
selected_list=selected,
entity=wall_element,
modifier_predicates={"is_path_connectable_wall": True},
view_top_down=top_down,
screen_up=Vector((0.0, 1.0, 0.0)),
)
)
stack.enter_context(patch.object(host_mod, "_get_wall_geom_cached", return_value=geom))
stack.enter_context(patch.object(host_mod, "_wall_camera_facing_icon_y", return_value=0.0))
stack.enter_context(patch.object(gizmo_module, "get_billboard_rotation", return_value=Matrix.Identity(4)))
stack.enter_context(
patch.object(
gizmo_module, "billboarded_at", side_effect=lambda pos, rot, scale=0.5: Matrix.Translation(pos)
)
)
GizmoHostAddOpening.position_gizmos(self_stub, context)
return icon.matrix_basis.translation
def test_wall_branch_drops_height_lift_in_top_down_view(patched_tool):
"""In plan view the wall-top Z lift must collapse to zero and the icon
must instead offset along screen-up otherwise the icon stacks on top
of the wall outline and the user can't see it."""
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
pos = _run_position_wall_branch(patched_tool, top_down=True)
assert pos.z == pytest.approx(0.0)
assert pos.y == pytest.approx(BaseParametricGizmoGroup.SCREEN_STACK_OFFSET)
def _run_position_layer3_branch(
patched_tool, *, host_world_z_range=(0.0, 0.2), other_z=1.0, other_xy=(0.7, 0.4), is_path_connectable_wall=False
):
"""Drive the LAYER3 (slab/roof) branch and return the icon translation.
``host_world_z_range`` sets the world-Z extents of the host's bounding box
(the gizmo picks top vs bottom by comparing the void's Z to the box
midpoint). ``is_path_connectable_wall`` keeps a single helper for both
branches by flipping the dispatch predicate."""
from bonsai.bim.module.drawing import gizmos as gizmo_module
from bonsai.bim.module.model.host_add_opening_gizmo import GizmoHostAddOpening
z_min, z_max = host_world_z_range
# bound_box returns 8 corners in local space; we only need their world-Z
# range to drive the branch, so fix XY at zero and vary Z.
local_corners = [(0.0, 0.0, z_min), (0.0, 0.0, z_max)] * 4
host_obj = SimpleNamespace(matrix_world=Matrix.Identity(4), bound_box=local_corners)
other = SimpleNamespace(matrix_world=Matrix.Translation(Vector((other_xy[0], other_xy[1], other_z))))
selected = [host_obj, other]
context = SimpleNamespace(active_object=host_obj)
icon = SimpleNamespace(matrix_basis=None, hide=True)
self_stub = SimpleNamespace(add_opening_icon=icon)
host_element = object()
with contextlib.ExitStack() as stack:
stack.enter_context(
patched_tool(
selected_list=selected,
entity=host_element,
modifier_predicates={"is_path_connectable_wall": is_path_connectable_wall},
)
)
stack.enter_context(patch.object(gizmo_module, "get_billboard_rotation", return_value=Matrix.Identity(4)))
stack.enter_context(
patch.object(
gizmo_module, "billboarded_at", side_effect=lambda pos, rot, scale=0.5: Matrix.Translation(pos)
)
)
GizmoHostAddOpening.position_gizmos(self_stub, context)
return icon.matrix_basis.translation
@pytest.mark.parametrize("other_z", [1.0, 0.1, -1.0])
def test_layer3_branch_always_parks_above_top_face(patched_tool, other_z):
"""Icon parks above the host's top face regardless of the void's Z —
predictable height every time. Void's XY is preserved so clicking the
icon dispatches the operator at the intended XY position."""
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
pos = _run_position_layer3_branch(patched_tool, host_world_z_range=(0.0, 0.2), other_z=other_z, other_xy=(0.7, 0.4))
assert pos.x == pytest.approx(0.7)
assert pos.y == pytest.approx(0.4)
assert pos.z == pytest.approx(0.2 + BaseParametricGizmoGroup.ICON_Z_OFFSET)
# ---------------------------------------------------------------------------
# is_supported_host() — predicate totality
# ---------------------------------------------------------------------------
def test_is_supported_host_returns_false_for_none():
"""Total predicate: ``None`` short-circuits to False without raising."""
from bonsai.bim.module.model.host_add_opening_gizmo import is_supported_host
assert is_supported_host(None) is False
def test_is_supported_host_accepts_bare_ifc_slab():
"""The slab branch is class-based — any ``IfcSlab`` qualifies, even
without LAYER3 parametric usage. The positioner reads ``obj.bound_box``,
which works for both parametric and imported geometry."""
from bonsai.bim.module.model.host_add_opening_gizmo import is_supported_host
assert is_supported_host(_FakeIfcEntity("IfcSlab")) is True
def test_is_supported_host_accepts_bare_ifc_roof():
"""The roof branch is class-based, not pset-based — a bare ``IfcRoof``
imported from another IFC tool qualifies even without the Bonsai
BBIM_Roof parametric marker that ``tool.Parametric.is_roof``
would require."""
from bonsai.bim.module.model.host_add_opening_gizmo import is_supported_host
assert is_supported_host(_FakeIfcEntity("IfcRoof")) is True
def test_is_supported_host_rejects_non_host_ifc_class():
"""Non-host IFC classes are filtered — covers ``IfcCovering`` (which has
HasOpenings but is not a wall/slab/roof) and prevents the gizmo from
surfacing on arbitrary building elements."""
from bonsai.bim.module.model.host_add_opening_gizmo import is_supported_host
assert is_supported_host(_FakeIfcEntity("IfcCovering")) is False
assert is_supported_host(_FakeIfcEntity("IfcDiscreteAccessory")) is False
# ---------------------------------------------------------------------------
# End-to-end smoke: gizmo's target operator handles host + mesh-void selection
# ---------------------------------------------------------------------------
#
# The gizmo binds ``bim.add_opening`` via ``setup_icon_gizmo`` — clicking the
# icon dispatches that operator with the current selection set. The operator
# has its own target/opening detection that swaps based on which selected
# object carries an IFC entity. This smoke test pins that handoff: with a
# host as the active object and a non-IFC mesh as the "void", the operator
# creates an ``IfcOpeningElement`` linked to the host via the standard
# ``HasOpenings`` inverse.
class TestAddOpeningIntegrationOnSlab(NewFile):
def test_creates_opening_when_slab_is_active_with_mesh_void(self):
tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc"
bpy.ops.bim.create_project()
ifc_file = tool.Ifc.get()
slab_type = ifc_file.by_type("IfcSlabType")[0]
bpy.ops.bim.add_occurrence(relating_type_id=slab_type.id())
slab = ifc_file.by_type("IfcSlab")[0]
slab_obj = tool.Ifc.get_object(slab)
assert isinstance(slab_obj, bpy.types.Object)
assert len(slab.HasOpenings) == 0
void_obj = bpy.data.objects.new("VoidMesh", bpy.data.meshes.new("VoidMesh"))
bpy.context.scene.collection.objects.link(void_obj)
void_obj.matrix_world = void_obj.matrix_world.copy()
void_obj.matrix_world.translation = (
slab_obj.matrix_world.translation.x,
slab_obj.matrix_world.translation.y,
slab_obj.matrix_world.translation.z + 1.0,
)
tool.Blender.set_objects_selection(bpy.context, slab_obj, (slab_obj, void_obj))
bpy.ops.bim.add_opening()
assert len(slab.HasOpenings) == 1
opening = slab.HasOpenings[0].RelatedOpeningElement
assert opening.is_a("IfcOpeningElement")
class TestAddOpeningPollOnForeignAuthoredSlab(NewFile):
def test_poll_resolves_true_for_slab_without_layer3_usage(self):
"""An ``IfcSlab`` loaded from a non-Bonsai IFC carries no
``IfcMaterialLayerSetUsage``, so ``tool.Blender.Modifier.is_slab``
rejects it yet the gizmo's widened predicate accepts any
``IfcSlab`` because the positioner only reads the bound box.
This pins the bare-class branch through the full ``poll`` path
with real bpy + ifcopenshell state."""
import ifcopenshell.api.material
from bonsai.bim.module.model.host_add_opening_gizmo import (
GizmoHostAddOpening,
is_supported_host,
)
tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc"
bpy.ops.bim.create_project()
ifc_file = tool.Ifc.get()
slab_type = ifc_file.by_type("IfcSlabType")[0]
bpy.ops.bim.add_occurrence(relating_type_id=slab_type.id())
slab = ifc_file.by_type("IfcSlab")[0]
slab_obj = tool.Ifc.get_object(slab)
assert isinstance(slab_obj, bpy.types.Object)
# Strip every material association so the slab has no direct
# LayerSetUsage and nothing to inherit from the type. The slab is
# now a foreign-authored IFC class in everything but provenance.
ifcopenshell.api.material.unassign_material(ifc_file, products=[slab, slab_type])
assert tool.Blender.Modifier.is_slab(slab) is False
assert is_supported_host(slab) is True
void_obj = bpy.data.objects.new("VoidMesh", bpy.data.meshes.new("VoidMesh"))
bpy.context.scene.collection.objects.link(void_obj)
void_obj.matrix_world = void_obj.matrix_world.copy()
void_obj.matrix_world.translation = (
slab_obj.matrix_world.translation.x,
slab_obj.matrix_world.translation.y,
slab_obj.matrix_world.translation.z + 1.0,
)
tool.Blender.set_objects_selection(bpy.context, slab_obj, (slab_obj, void_obj))
assert GizmoHostAddOpening.poll(bpy.context) is True
class TestAddOpeningPollOnForeignAuthoredRoof(NewFile):
def test_poll_resolves_true_for_roof_without_bbim_pset(self):
"""A mesh-bodied ``IfcRoof`` promoted from a raw Blender mesh
carries no ``BBIM_Roof`` pset, so ``tool.Parametric.is_roof``
rejects it yet the gizmo's widened predicate accepts any
``IfcRoof`` because the positioner only reads the bound box. This
fixture mirrors how a foreign IFC roof loads (geometry + IFC
identity, no parametric markers)."""
from bonsai.bim.module.model.host_add_opening_gizmo import (
GizmoHostAddOpening,
is_supported_host,
)
tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc"
bpy.ops.bim.create_project()
bpy.ops.mesh.primitive_cube_add(size=2, location=(0, 0, 0))
roof_obj = bpy.context.active_object
assert roof_obj is not None
tool.Root.get_root_props().ifc_product = "IfcElement"
bpy.ops.bim.assign_class(ifc_class="IfcRoof")
roof = tool.Ifc.get_entity(roof_obj)
assert roof is not None and roof.is_a("IfcRoof")
assert tool.Parametric.is_roof(roof) is False
assert is_supported_host(roof) is True
void_obj = bpy.data.objects.new("VoidMesh", bpy.data.meshes.new("VoidMesh"))
bpy.context.scene.collection.objects.link(void_obj)
void_obj.matrix_world = void_obj.matrix_world.copy()
void_obj.matrix_world.translation = (
roof_obj.matrix_world.translation.x,
roof_obj.matrix_world.translation.y,
roof_obj.matrix_world.translation.z + 1.0,
)
tool.Blender.set_objects_selection(bpy.context, roof_obj, (roof_obj, void_obj))
assert GizmoHostAddOpening.poll(bpy.context) is True
@@ -1,521 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Tests for 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 == {}
@@ -1,178 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Tests for the parametric-edit preview registry contract.
Every test reads the live ``PREVIEW_CANCEL_OPS`` registry rather than hard-
coding preview keys or cancel-operator names, so adding a new preview to the
registry automatically exercises the same invariants without test changes."""
import types
import bpy
import pytest
pytestmark = pytest.mark.model
@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."
)
@@ -1,306 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Unit tests for the roof parametric gizmo group.
Covers the parts of ``GizmoRoofEdition`` that don't need a live Blender
viewport: the mode-conditional ``visibility_condition`` lambdas, the
slope ``compute_value`` / ``apply_value`` roundtrip, the
``CycleRoofGenerationMethod`` operator metadata + cycle behaviour, and
the ``_update_dimension_gizmo_positions`` override that anchors all three
dimension gizmos at the object's local origin."""
import math
from types import SimpleNamespace
from unittest.mock import patch
import bpy
import pytest
pytestmark = pytest.mark.model
def _get_config(attr_name):
"""Return the ``DimensionGizmoConfig`` for ``attr_name`` from the roof gizmo."""
from bonsai.bim.module.model.roof import GizmoRoofEdition
for cfg in GizmoRoofEdition.dimension_gizmo_props:
if cfg.attr_name == attr_name:
return cfg
raise AssertionError(f"no DimensionGizmoConfig with attr_name={attr_name!r}")
# ----------------------------------------------------------------------------
# Mode-conditional visibility
# ----------------------------------------------------------------------------
#
# ``height`` and ``angle`` are mutually exclusive — exactly one is shown
# depending on ``generation_method``. ``roof_thickness`` applies regardless
# of the generation mode.
def test_height_gizmo_visible_only_in_height_mode():
cfg = _get_config("height")
assert cfg.visibility_condition(SimpleNamespace(generation_method="HEIGHT")) is True
assert cfg.visibility_condition(SimpleNamespace(generation_method="ANGLE")) is False
def test_angle_gizmo_visible_only_in_angle_mode():
cfg = _get_config("angle")
assert cfg.visibility_condition(SimpleNamespace(generation_method="ANGLE")) is True
assert cfg.visibility_condition(SimpleNamespace(generation_method="HEIGHT")) is False
def test_thickness_has_no_mode_gate():
"""Slab thickness applies to both generation modes — pinning
``visibility_condition is None`` guards against an accidental mode-gate
being added later that would silently hide it when toggling modes."""
assert _get_config("roof_thickness").visibility_condition is None
# ----------------------------------------------------------------------------
# Slope (angle) ↔ rise roundtrip
# ----------------------------------------------------------------------------
#
# The slope handle displays vertical rise at a fixed 1m run; dragging it
# updates ``props.angle`` via ``atan2(rise, run)``. Roundtrip preservation
# is the contract — feeding ``compute_value`` into ``apply_value`` must
# leave the angle unchanged (within float tolerance).
def test_slope_compute_value_returns_rise_at_reference_run():
from bonsai.bim.module.model.roof import _ROOF_SLOPE_REFERENCE_RUN
cfg = _get_config("angle")
# 30° slope → rise = tan(30°) * 1m ≈ 0.5774 m
props = SimpleNamespace(angle=math.radians(30))
assert cfg.compute_value(props) == pytest.approx(math.tan(math.radians(30)) * _ROOF_SLOPE_REFERENCE_RUN)
def test_slope_apply_value_sets_angle_from_rise():
from bonsai.bim.module.model.roof import _ROOF_SLOPE_REFERENCE_RUN
cfg = _get_config("angle")
props = SimpleNamespace(angle=0.0)
cfg.apply_value(props, 0.5)
assert props.angle == pytest.approx(math.atan2(0.5, _ROOF_SLOPE_REFERENCE_RUN))
def test_slope_roundtrip_preserves_angle():
cfg = _get_config("angle")
for deg in (5, 15, 30, 45, 60, 80):
props = SimpleNamespace(angle=math.radians(deg))
rise = cfg.compute_value(props)
cfg.apply_value(props, rise)
assert math.degrees(props.angle) == pytest.approx(deg, abs=1e-6)
def test_slope_apply_value_clamps_negative_to_zero():
"""A negative drag (rise < 0) must not produce a negative angle —
``atan2(-x, run)`` would yield a negative result, but ``apply_value``
clamps to ``[0, pi/2 - 1e-3]`` so the roof never inverts."""
cfg = _get_config("angle")
props = SimpleNamespace(angle=math.radians(30))
cfg.apply_value(props, -1.0)
assert props.angle == 0.0
def test_slope_apply_value_clamps_at_near_vertical():
"""Slopes approaching 90° are clamped just below to avoid a vertical
extrusion that would degenerate the bisect step in
``generate_hipped_roof_bmesh``."""
from bonsai.bim.module.model.roof import _ROOF_MAX_SLOPE_ANGLE
cfg = _get_config("angle")
props = SimpleNamespace(angle=0.0)
cfg.apply_value(props, 1e9) # absurdly steep
assert props.angle == pytest.approx(_ROOF_MAX_SLOPE_ANGLE)
# ----------------------------------------------------------------------------
# Cycle operator metadata
# ----------------------------------------------------------------------------
#
# ``CycleRoofGenerationMethod`` plugs into ``CycleTypeMixin`` so the
# HEIGHT ↔ ANGLE icon cycles through the two values. The mixin reads four
# class attributes to do its work; if any drift, the cycle no-ops or
# CANCELLED-loops in subtle ways. Pin them here.
def test_cycle_operator_class_metadata():
from typing import get_args
from bonsai import tool
from bonsai.bim.module.model.roof import CycleRoofGenerationMethod
assert CycleRoofGenerationMethod.bl_idname == "bim.cycle_roof_generation_method"
assert CycleRoofGenerationMethod.element_checker == tool.Parametric.is_roof
assert CycleRoofGenerationMethod.props_getter == tool.Model.get_roof_props
assert CycleRoofGenerationMethod.type_attr == "generation_method"
# The Literal resolves to ("HEIGHT", "ANGLE") — the mixin calls
# ``get_args(type_literal)`` to enumerate the cycle.
assert get_args(CycleRoofGenerationMethod.type_literal) == ("HEIGHT", "ANGLE")
assert CycleRoofGenerationMethod.type_literal is tool.Model.RoofGenerationMethod
def test_cycle_operator_wired_on_gizmo_group():
"""The gizmo group's ``cycle_type_operator`` must match the bl_idname or
the base class skips the cycle icon entirely (see gizmos.py:4987)."""
from bonsai.bim.module.model.roof import CycleRoofGenerationMethod, GizmoRoofEdition
assert GizmoRoofEdition.cycle_type_operator == CycleRoofGenerationMethod.bl_idname
def _cycle_stub_self(*, reverse: bool, props, element_is_target: bool = True):
"""Build a stub ``self`` for ``CycleTypeMixin._cycle_type``.
``bpy.types.Operator`` subclasses can't be ``__init__``-ed outside of
Blender's registration path (``bpy_struct.__new__`` rejects a bare
call). Calling the unbound mixin method with a stub ``self`` that
mirrors the class attributes the method reads is the cleanest way to
exercise the cycle logic without launching a registered operator
instance.
``element_checker`` and ``props_getter`` are captured by the cycle
operator at class-definition time, so global ``tool.*`` patches at
test time can't intercept them — the stub injects callables directly
instead. ``_resolve_target`` is bound from ``TypeAccessorBase`` so
the cycle method's call into it dispatches against the stub
attributes."""
from types import MethodType
from bonsai.bim.module.model.roof import CycleRoofGenerationMethod
from bonsai.bim.parametric_lifecycle import TypeAccessorBase
stub = SimpleNamespace(
reverse=reverse,
skip_element_check=False,
element_checker=lambda _elem: element_is_target,
props_getter=lambda _obj: props,
type_literal=CycleRoofGenerationMethod.type_literal,
type_attr=CycleRoofGenerationMethod.type_attr,
)
stub._resolve_target = MethodType(TypeAccessorBase._resolve_target, stub)
return stub
def test_cycle_type_advances_forward():
"""``_cycle_type`` advances the prop value to the next item in the
Literal. The stub injects ``element_checker`` / ``props_getter``
directly so the method runs without a live IFC fixture."""
from bonsai import tool
from bonsai.bim import parametric_lifecycle as gizmo_module
props = SimpleNamespace(generation_method="HEIGHT")
context = SimpleNamespace(active_object=object())
with patch.object(tool.Ifc, "get_entity", return_value=object()):
result = gizmo_module.CycleTypeMixin._cycle_type(_cycle_stub_self(reverse=False, props=props), context)
assert result == {"FINISHED"}
assert props.generation_method == "ANGLE"
def test_cycle_type_reverse_walks_backward():
"""Shift+click sets ``reverse=True`` and walks the cycle in the other
direction from HEIGHT that means wrapping to ANGLE (the last item)."""
from bonsai import tool
from bonsai.bim import parametric_lifecycle as gizmo_module
props = SimpleNamespace(generation_method="HEIGHT")
context = SimpleNamespace(active_object=object())
with patch.object(tool.Ifc, "get_entity", return_value=object()):
gizmo_module.CycleTypeMixin._cycle_type(_cycle_stub_self(reverse=True, props=props), context)
assert props.generation_method == "ANGLE" # wrapped from HEIGHT backward
def test_cycle_type_cancels_when_active_is_not_a_roof():
"""Non-roof active object → CANCELLED, props untouched. Guards against
a stray cycle click on a wall mutating ``wall.generation_method`` (a
non-existent attr) and silently no-oping or AttributeError-ing later."""
from bonsai import tool
from bonsai.bim import parametric_lifecycle as gizmo_module
props = SimpleNamespace(generation_method="HEIGHT")
context = SimpleNamespace(active_object=object())
with patch.object(tool.Ifc, "get_entity", return_value=object()):
result = gizmo_module.CycleTypeMixin._cycle_type(
_cycle_stub_self(reverse=False, props=props, element_is_target=False),
context,
)
assert result == {"CANCELLED"}
assert props.generation_method == "HEIGHT"
# ----------------------------------------------------------------------------
# _update_dimension_gizmo_positions — origin anchoring
# ----------------------------------------------------------------------------
#
# All three dimension gizmos anchor at the object's local origin. Their
# declared axes (height/slope +Z, thickness -Z) separate them in 3D so
# they don't visually collide despite sharing a position; height + slope
# are themselves mutually exclusive via visibility_condition on
# generation_method.
def test_override_positions_all_dimensions_at_object_origin():
"""The override calls ``set_dimension_gizmo_position`` with the
object-local origin (0, 0, 0) for every dimension gizmo. Anchoring at
the object origin keeps the gizmos tied to the object's matrix_world
rather than to footprint geometry that may not be cached yet fixes
the first-click default-identity-matrix bug structurally."""
from bonsai.bim.module.model.roof import GizmoRoofEdition
calls: dict[str, tuple] = {}
def record(attr_name, _mw, position, axis, _value=None):
calls[attr_name] = (position, axis)
stub = SimpleNamespace(set_dimension_gizmo_position=record)
GizmoRoofEdition._update_dimension_gizmo_positions(stub, context=None, mw=None, props=None)
assert set(calls) == {"height", "angle", "roof_thickness"}
for name in ("height", "angle", "roof_thickness"):
position, _axis = calls[name]
assert position.xyz[:] == pytest.approx(
(0.0, 0.0, 0.0)
), f"{name} anchored at {position.xyz[:]} instead of object origin"
# Axes split the three handles along Z+ (height/slope) vs Z- (thickness)
# so they don't visually collide despite sharing the anchor point.
assert calls["height"][1] == (0, 0, 1)
assert calls["angle"][1] == (0, 0, 1)
assert calls["roof_thickness"][1] == (0, 0, -1)
# ----------------------------------------------------------------------------
# Registration smoke test
# ----------------------------------------------------------------------------
#
# Pattern 4 from _shared/bonsai-test-patterns.md: assert the operator is
# actually registered as ``bim.cycle_roof_generation_method``. Catches
# ``bl_idname`` typos and missing-from-``classes``-tuple regressions at
# test time rather than at user-click time (the failure mode otherwise is
# a silent no-op on the cycle icon, because the gizmo base class skips the
# icon entirely if its ``cycle_type_operator`` resolves to nothing).
def test_cycle_operator_is_registered_under_bim_namespace():
assert hasattr(bpy.ops.bim, "cycle_roof_generation_method")
@@ -133,75 +133,3 @@ def test_set_icon_gizmo_position_does_not_apply_object_rotation():
for row_a, row_b in zip(stub.matrix_basis, expected):
for va, vb in zip(row_a, row_b):
assert abs(va - vb) < 1e-6
def test_icon_slot_placeholder_skips_validation_and_returns_no_attrs():
"""Placeholder slots reserve an X position without an auto-created gizmo:
construction must not require ``gizmo_idname`` / ``operator``, and
``gizmo_attrs()`` must return an empty tuple so the base class's
setup/positioning loops naturally skip the slot."""
from bonsai.bim.module.drawing.gizmos import IconSlot
slot = IconSlot(name="my_label", placeholder=True)
assert slot.placeholder is True
assert slot.gizmo_attrs() == ()
with pytest.raises(TypeError, match="gizmo_idname"):
IconSlot(name="broken")
def test_count_label_gizmo_is_registered():
"""The shared text-only ``xN`` gizmo must register so the stair group's
``gizmos.new("BIM_GT_count_label")`` resolves."""
from bonsai.bim.module.drawing.gizmos import GizmoCountLabel
assert GizmoCountLabel.bl_idname == "BIM_GT_count_label"
assert bpy.types.Gizmo.bl_rna_get_subclass_py("BIM_GT_count_label") is GizmoCountLabel
def test_stair_edit_row_reserves_label_slot_between_tread_lock_and_plus():
"""The ``tread_count_label`` placeholder slot must sit one
``ICON_ARRAY_GAP`` past the tread-lock and one gap before the plus
icon, so the layout naturally allocates the count label's X without
any subclass-side gap math."""
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
from bonsai.bim.module.model.stair import GizmoStairEdition
slot_x = GizmoStairEdition._slot_x_positions()
gap = BaseParametricGizmoGroup.ICON_ARRAY_GAP
assert "tread_count_label" in slot_x
assert slot_x["tread_count_label"] - slot_x["tread_lock"] == pytest.approx(gap)
assert slot_x["plus"] - slot_x["tread_count_label"] == pytest.approx(gap)
assert slot_x["minus"] - slot_x["plus"] == pytest.approx(gap)
def test_update_tread_count_gizmos_toggles_label_with_editing():
"""``update_tread_count_gizmos`` must propagate ``props.is_editing``
to the label's hide state so the badge appears only inside edit mode."""
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
from bonsai.bim.module.model.stair import GizmoStairEdition
class _GizmoStub:
def __init__(self):
self.hide = False
plus_gz = _GizmoStub()
minus_gz = _GizmoStub()
label_gz = _GizmoStub()
fake_self = types.SimpleNamespace(
plus_gizmo=plus_gz,
minus_gizmo=minus_gz,
tread_count_label_gizmo=label_gz,
update_gizmo_visibility=lambda g, v: BaseParametricGizmoGroup.update_gizmo_visibility(fake_self, g, v),
is_gizmo_hidden_by_modal=lambda g: False,
)
props_editing = types.SimpleNamespace(is_editing=True, number_of_treads=5)
GizmoStairEdition.update_tread_count_gizmos(fake_self, props_editing)
assert label_gz.hide is False
props_idle = types.SimpleNamespace(is_editing=False, number_of_treads=5)
GizmoStairEdition.update_tread_count_gizmos(fake_self, props_idle)
assert label_gz.hide is True
@@ -1,154 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Behaviour contract: every wall gizmo group hides while a parametric-edit
preview is active.
Enumerates wall gizmo groups by walking the wall module for ``bpy.types.GizmoGroup``
subclasses rather than naming them adding a new wall gizmo group automatically
joins the test. The test then asserts the BEHAVIOUR (poll returns False when
``preview_base.any_preview_active`` is True) without pinning the name of the
helper function the gizmo uses internally to enforce it."""
import inspect
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,126 +179,3 @@ def test_poll_rejects_when_other_is_not_layer2_wall():
_run_poll(prefs_on=True, active_is_in_selected=True, len_override=None, active_usage="LAYER3", other_usage=None)
is False
)
# ----------------------------------------------------------------------------
# _iter_path_connections — IfcRelConnectsPathElements inverse-graph walk
# ----------------------------------------------------------------------------
#
# Normalises both ConnectedTo and ConnectedFrom orientations to (other, self_ct,
# other_ct) so callers always read "self first" regardless of which side of the
# rel this wall was authored on. Non-wall partners and malformed (None) refs are
# filtered out so per-frame gizmo positioning survives partial IFC state.
def _make_path_rel(relating, related, relating_ct, related_ct, kind="IfcRelConnectsPathElements"):
"""Build a stub IfcRelConnectsPathElements for inverse-walk tests."""
return SimpleNamespace(
is_a=lambda name, _k=kind: name == _k,
RelatingElement=relating,
RelatedElement=related,
RelatingConnectionType=relating_ct,
RelatedConnectionType=related_ct,
)
def _run_iter_path_connections(elem, *, partner_predicate=lambda _e: True):
from bonsai import tool
from bonsai.bim.module.model.wall import _iter_path_connections
with patch.object(tool.Parametric, "is_path_connectable_wall", side_effect=partner_predicate):
return _iter_path_connections(elem)
def test_iter_path_connections_empty_inverses_yields_nothing():
elem = SimpleNamespace(ConnectedTo=[], ConnectedFrom=[])
assert _run_iter_path_connections(elem) == []
def test_iter_path_connections_connected_to_orientation_is_self_first():
# Self is the rel's RelatingElement → its connection type is RelatingConnectionType.
self_elem = object()
other = object()
rel = _make_path_rel(relating=self_elem, related=other, relating_ct="ATEND", related_ct="ATSTART")
elem = SimpleNamespace(ConnectedTo=[rel], ConnectedFrom=[])
assert _run_iter_path_connections(elem) == [(other, "ATEND", "ATSTART")]
def test_iter_path_connections_connected_from_orientation_is_self_first():
# Self is the rel's RelatedElement → its connection type is RelatedConnectionType.
# The helper must FLIP the tuple so callers still see (other, self_ct, other_ct).
self_elem = object()
other = object()
rel = _make_path_rel(relating=other, related=self_elem, relating_ct="ATSTART", related_ct="ATEND")
elem = SimpleNamespace(ConnectedTo=[], ConnectedFrom=[rel])
assert _run_iter_path_connections(elem) == [(other, "ATEND", "ATSTART")]
def test_iter_path_connections_skips_non_path_rels():
# IfcRelAggregates, IfcRelContainedInSpatialStructure, etc. share the
# ConnectedTo/ConnectedFrom inverse arrays — only IfcRelConnectsPathElements
# carries the per-end connection-type semantics we care about.
self_elem = object()
other = object()
non_path = _make_path_rel(
relating=self_elem, related=other, relating_ct="ATSTART", related_ct="ATEND", kind="IfcRelAggregates"
)
path = _make_path_rel(relating=self_elem, related=other, relating_ct="ATEND", related_ct="ATSTART")
elem = SimpleNamespace(ConnectedTo=[non_path, path], ConnectedFrom=[])
assert _run_iter_path_connections(elem) == [(other, "ATEND", "ATSTART")]
def test_iter_path_connections_skips_non_wall_partners():
# Walls may path-connect to non-wall elements (columns, beams). The single-
# wall unjoin gizmo only surfaces wall-to-wall joins to match the existing
# two-wall gizmo's scope.
self_elem = object()
wall_partner = object()
non_wall_partner = object()
rel_wall = _make_path_rel(relating=self_elem, related=wall_partner, relating_ct="ATEND", related_ct="ATSTART")
rel_non_wall = _make_path_rel(
relating=self_elem, related=non_wall_partner, relating_ct="ATEND", related_ct="ATSTART"
)
elem = SimpleNamespace(ConnectedTo=[rel_wall, rel_non_wall], ConnectedFrom=[])
result = _run_iter_path_connections(elem, partner_predicate=lambda e: e is wall_partner)
assert result == [(wall_partner, "ATEND", "ATSTART")]
def test_iter_path_connections_includes_fillet_corner_partner():
# Fillet-corner walls carry no LAYER2 usage but are still valid path
# partners. The enumeration must use the same predicate the gizmo group's
# poll uses for the host wall — otherwise the corner is silently dropped
# from the neighbour's connection list and looks unconnected from the
# LAYER2 wall's perspective.
self_elem = object()
fillet_partner = object()
rel = _make_path_rel(relating=self_elem, related=fillet_partner, relating_ct="ATEND", related_ct="ATSTART")
elem = SimpleNamespace(ConnectedTo=[rel], ConnectedFrom=[])
result = _run_iter_path_connections(elem, partner_predicate=lambda e: e is fillet_partner)
assert result == [(fillet_partner, "ATEND", "ATSTART")]
def test_iter_path_connections_tolerates_none_partner_refs():
# Malformed / partial IFC files can leave a rel's element ref unset.
# Without a None guard, the partner predicate would receive None and
# raise on `.is_a(...)` mid-frame, silently breaking the gizmo group.
self_elem = object()
other = object()
rel_none = _make_path_rel(relating=self_elem, related=None, relating_ct="ATEND", related_ct="ATSTART")
rel_ok = _make_path_rel(relating=self_elem, related=other, relating_ct="ATSTART", related_ct="ATEND")
elem = SimpleNamespace(ConnectedTo=[rel_none, rel_ok], ConnectedFrom=[])
assert _run_iter_path_connections(elem) == [(other, "ATSTART", "ATEND")]
def test_iter_path_connections_walks_both_inverses_in_order():
# A wall can sit on both sides of different path rels (e.g. authored once
# as the RelatingElement, once as the RelatedElement). The helper walks
# ConnectedTo first, then ConnectedFrom — pinning the order so callers can
# depend on it for icon-slot allocation.
self_elem = object()
p1 = object()
p2 = object()
rel_to = _make_path_rel(relating=self_elem, related=p1, relating_ct="ATSTART", related_ct="ATSTART")
rel_from = _make_path_rel(relating=p2, related=self_elem, relating_ct="ATEND", related_ct="ATEND")
elem = SimpleNamespace(ConnectedTo=[rel_to], ConnectedFrom=[rel_from])
assert _run_iter_path_connections(elem) == [(p1, "ATSTART", "ATSTART"), (p2, "ATEND", "ATEND")]
@@ -1,386 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Forward-compat AST contracts for wall gizmo internals.
Pins structural invariants that no per-call-site behavioural test can catch
on its own: the kind of "someone tidied the imports" regression that leaves
tests green but silently changes runtime semantics. Each contract names the
invariant it pins so a future revert tells the contributor exactly what the
rule is."""
import ast
import inspect
import textwrap
import pytest
pytestmark = pytest.mark.wall
def test_iter_path_connections_uses_path_connectable_predicate():
"""The partner filter must consult the looser ``is_path_connectable_wall``
predicate, matching the host-side predicate used by the gizmo group's
poll. Strict ``is_wall`` rejects fillet-corner walls (which have no
LAYER2 usage by IFC spec), so a regression to ``is_wall`` would silently
drop fillet partners from the connection list visible to the user as
"the corner looks unconnected from the adjacent wall's selection.\" """
from bonsai.bim.module.model.wall import _iter_path_connections
source = inspect.getsource(_iter_path_connections)
tree = ast.parse(source)
attr_names = {node.attr for node in ast.walk(tree) if isinstance(node, ast.Attribute)}
assert "is_path_connectable_wall" in attr_names, (
"_iter_path_connections must filter partners with is_path_connectable_wall — "
"the same predicate the gizmo group's poll uses on the host wall. "
"Symmetry between host and partner predicates is required for fillet "
"corners (no LAYER2 usage) to surface as connected from their LAYER2 "
"neighbours' perspective."
)
assert "is_wall" not in attr_names, (
"_iter_path_connections must NOT call .is_wall on partner elements — "
"that strict predicate drops fillet-corner walls. Use "
"is_path_connectable_wall instead."
)
def test_gizmo_wall_link_toggle_invokes_partner_bbox_helper():
"""The wall subclass must call draw_wall_partner_bbox when its hover
state is active. Without this contract the partner-wall highlight
silently regresses if someone "tidies" the draw() override away."""
from bonsai.bim.module.model import wall as wall_module
source = textwrap.dedent(inspect.getsource(wall_module.GizmoWallLinkToggle.draw))
tree = ast.parse(source)
attr_names = {node.attr for node in ast.walk(tree) if isinstance(node, ast.Attribute)}
call_names: set[str] = set()
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
if isinstance(node.func, ast.Attribute):
call_names.add(node.func.attr)
elif isinstance(node.func, ast.Name):
call_names.add(node.func.id)
assert "is_highlight" in attr_names, (
"GizmoWallLinkToggle.draw must gate its highlight call on self.is_highlight — "
"without it the partner outline would draw every frame, not just on hover."
)
assert "draw_wall_partner_bbox" in call_names, (
"GizmoWallLinkToggle.draw must call draw_wall_partner_bbox to render the "
"partner outline. The shared composite in decorator.py is the canonical "
"trigger for this feature; replacing it with an ad-hoc draw call would "
"drift from the array-children bbox styling."
)
def test_every_wall_gizmo_group_resolves_get_decoration_colors():
"""Any wall ``GizmoGroup`` whose ``setup()`` reads decoration colours via
``self.get_decoration_colors()`` must inherit from a mixin that supplies
it (``gizmo.BaseParametricGizmoGroup`` or ``gizmo.BillboardingGizmoGroupMixin``).
Without the mixin the call AttributeErrors inside ``setup()``, Blender
logs the failure and skips the rest of ``setup()``, and every later
``draw_prepare()`` blows up on whichever attribute the truncated setup
failed to assign a silent, runtime-only regression that no other test
catches."""
import bpy
from bonsai.bim.module.model import wall as wall_module
offenders: list[str] = []
for name in dir(wall_module):
cls = getattr(wall_module, name)
if not inspect.isclass(cls):
continue
if inspect.getmodule(cls) is not wall_module:
continue
if not issubclass(cls, bpy.types.GizmoGroup):
continue
setup = cls.__dict__.get("setup")
if setup is None:
continue
try:
src = inspect.getsource(setup)
except (OSError, TypeError):
continue
if "self.get_decoration_colors()" not in src:
continue
if not hasattr(cls, "get_decoration_colors"):
offenders.append(cls.__name__)
assert not offenders, (
f"GizmoGroup subclasses {offenders} call self.get_decoration_colors() in "
"setup() but inherit from no class that provides it. Add "
"gizmo.BillboardingGizmoGroupMixin (or gizmo.BaseParametricGizmoGroup) to "
"the class bases — both define get_decoration_colors and are the canonical "
"wall-gizmo mixins."
)
def test_host_add_opening_accepts_fillet_corner_active():
"""``is_supported_host`` (the gate ``GizmoHostAddOpening.poll`` dispatches
through) must classify walls via ``is_path_connectable_wall``, not the
strict ``is_wall`` predicate. Fillet-corner walls carry no LAYER2 usage
by IFC spec, so the strict predicate rejects them and the add-opening
icon never surfaces over a curved corner symmetry with the join /
unjoin / extend wall gizmos (all of which already poll on the looser
predicate) is required for the user to drop openings into fillet
corners at all."""
from bonsai.bim.module.model.host_add_opening_gizmo import is_supported_host
source = textwrap.dedent(inspect.getsource(is_supported_host))
tree = ast.parse(source)
attr_names = {node.attr for node in ast.walk(tree) if isinstance(node, ast.Attribute)}
assert "is_path_connectable_wall" in attr_names, (
"is_supported_host must gate walls on tool.Parametric.is_path_connectable_wall. "
"The strict is_wall predicate hides the add-opening gizmo over every "
"fillet-corner wall."
)
assert "is_wall" not in attr_names, (
"is_supported_host must NOT call .is_wall — that strict predicate drops "
"fillet-corner walls. Use is_path_connectable_wall instead, matching the "
"host gate every other wall-state gizmo group uses."
)
def test_join_intersection_uses_l_and_t_glyphs():
"""``GizmoWallJoinIntersection.setup`` must bind the join icon to the L
glyph (``VIEW3D_GT_wall_corner``) and the extend-to icon to the T glyph
(``VIEW3D_GT_wall_tee``). The L / T pair makes the corner-join vs
extend-into-side distinction read at a glance a regression to the
arrow-merge glyph for both icons makes them visually indistinguishable
once they're stacked at the same XY."""
from bonsai.bim.module.model.wall import GizmoWallJoinIntersection
source = textwrap.dedent(inspect.getsource(GizmoWallJoinIntersection.setup))
assert '"VIEW3D_GT_wall_corner"' in source, (
"GizmoWallJoinIntersection.setup must bind join_icon to VIEW3D_GT_wall_corner "
"(the L glyph). The arrow-merge glyph (VIEW3D_GT_merge) is the collinear-merge "
"case and was visually ambiguous with the extend-to icon when both were stacked."
)
assert '"VIEW3D_GT_wall_tee"' in source, (
"GizmoWallJoinIntersection.setup must bind extend_to_wall_icon to "
"VIEW3D_GT_wall_tee (the T glyph). The arrow-extend glyph was visually "
"ambiguous with the join icon when both were stacked."
)
def test_join_intersection_stacks_along_screen_up_in_both_states():
"""``GizmoWallJoinIntersection.position_gizmos`` must route both the
joined (unjoin + fillet) and the intersecting (join + extend + fillet)
states through ``_stack_at`` so the icons stay individually clickable
in any view, including top / plan view where world-Z separation
collapses to zero on screen. A regression that re-introduces a
per-state ``billboarded_at(corner, ...)`` write outside ``_stack_at``
silently flattens the stack back onto one screen pixel."""
from bonsai.bim.module.model.wall import GizmoWallJoinIntersection
source = textwrap.dedent(inspect.getsource(GizmoWallJoinIntersection.position_gizmos))
tree = ast.parse(source)
call_names: set[str] = set()
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
if isinstance(node.func, ast.Attribute):
call_names.add(node.func.attr)
elif isinstance(node.func, ast.Name):
call_names.add(node.func.id)
assert "_stack_at" in call_names, (
"GizmoWallJoinIntersection.position_gizmos must call self._stack_at to "
"lay icons along screen-up at the wall-top anchor. Direct "
"billboarded_at writes for the join/unjoin/extend/fillet icons bypass "
"the stacking contract and re-introduce the top-view collapse bug."
)
def _get_wall_axis_callers_in(method) -> set[str]:
"""Return the set of attribute chains in ``method``'s source that resolve
to ``tool.Model.get_wall_axis``. Empty set means the method does not read
from the mesh-bound-box axis source."""
source = textwrap.dedent(inspect.getsource(method))
tree = ast.parse(source)
offenders: set[str] = set()
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
func = node.func
if not isinstance(func, ast.Attribute) or func.attr != "get_wall_axis":
continue
# Reconstruct the receiver chain to surface it in the assertion message.
chain: list[str] = [func.attr]
receiver = func.value
while isinstance(receiver, ast.Attribute):
chain.append(receiver.attr)
receiver = receiver.value
if isinstance(receiver, ast.Name):
chain.append(receiver.id)
offenders.add(".".join(reversed(chain)))
return offenders
def _method_writes_ifc_axis(method) -> bool:
"""True iff ``method``'s body calls ``self.set_axis(...)`` — the only
path that writes a wall's IFC reference line via
``ifcopenshell.api.geometry.assign_representation``. Methods that only
read ``axis["base"]`` / ``axis["side"]`` for layer-polygon work (slab
clipping, opening snap) never call ``set_axis`` and are not under this
rule."""
source = textwrap.dedent(inspect.getsource(method))
tree = ast.parse(source)
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
func = node.func
if isinstance(func, ast.Attribute) and func.attr == "set_axis":
return True
return False
def test_dumb_wall_joiner_axis_writers_read_ifc_reference_line():
"""Any ``DumbWallJoiner`` method that writes the IFC reference line
(via ``self.set_axis`` ``ifcopenshell.api.geometry.assign_representation``)
must read its input axis from the IFC reference line too not from
``tool.Model.get_wall_axis``, whose X-extent comes from ``obj.bound_box``
(the Body mesh AABB). The bound-box axis drifts past or short of the
IFC reference line at mitred / butt-jointed walls and at walls with end
openings; mixing it on input with the IFC axis on output produces
non-colinear sub-axes that compound through chained extend/split/join
edits.
The IFC-anchored helper is ``tool.Wall.get_world_reference_line`` for
world-space endpoints, or ``ifcopenshell.util.representation.get_reference_line``
for local-SI endpoints.
Joiner methods that only read layer-polygon base/side (e.g. ``clip``
for slab intersection) are exempt they need the body footprint, not
the axis, and never call ``set_axis``."""
from bonsai.bim.module.model.wall import DumbWallJoiner
offenders: dict[str, set[str]] = {}
for name, method in inspect.getmembers(DumbWallJoiner, predicate=inspect.isfunction):
if not _method_writes_ifc_axis(method):
continue
bad_calls = _get_wall_axis_callers_in(method)
if bad_calls:
offenders[name] = bad_calls
assert not offenders, (
f"DumbWallJoiner methods that call self.set_axis must not read the "
f"bound-box-derived axis: {offenders}. Use "
"tool.Wall.get_world_reference_line for world-space endpoints, or "
"ifcopenshell.util.representation.get_reference_line for local-SI "
"endpoints. Mixing bound_box on input with IFC axis on output "
"produces non-colinear sub-axes that compound through chained "
"extend/split/join edits."
)
def test_extend_walls_to_polyline_set_origin_uses_ifc_reference_line():
"""``ExtendWallsToPolylinePoint.set_origin`` seeds the polyline preview
anchor at one of the wall's axis endpoints. The downstream operator
(``DumbWallJoiner.extend``) projects the user's chosen target onto the
IFC reference line; if the preview anchor comes from
``tool.Model.get_wall_axis`` (bound_box) the user sees the preview at
one endpoint and the wall lands at a different one the visible
"extend falls short by a few cm/m" symptom."""
from bonsai.bim.module.model.wall import ExtendWallsToPolylinePoint
offenders = _get_wall_axis_callers_in(ExtendWallsToPolylinePoint.set_origin)
assert not offenders, (
f"ExtendWallsToPolylinePoint.set_origin must not read the bound-box-derived "
f"axis: {offenders}. Use tool.Wall.get_world_reference_line so the preview "
"anchor lands on the same IFC reference line the downstream extend operator "
"projects onto."
)
def test_wall_toggle_openings_uses_idle_slots():
"""The wall's toggle_openings icon must be declared in
``GizmoWallEdition.idle_slots`` so the base class lays it out at the
standard pen-row position. Routing it through ad-hoc setup helpers
instead would re-introduce the X-collision with the array's first
per-layer icon the bug this contract was added to prevent."""
from bonsai.bim.module.model.wall import GizmoWallEdition
slot_names = {s.name for s in GizmoWallEdition.idle_slots}
assert "toggle_openings" in slot_names, (
"GizmoWallEdition.idle_slots must contain a slot named 'toggle_openings'. "
"The base class derives its X position from the slot's tuple index so peer "
"groups (GizmoArrayEdition's per-layer icons) can query a real layout edge "
"via _idle_row_right_edge() instead of a hardcoded per-feature table."
)
def test_no_pen_row_toggle_openings_helpers_remain():
"""The legacy ``setup_pen_row_toggle_openings_icon`` and
``update_pen_row_toggle_openings_icon`` helpers were removed once
toggle_openings migrated into the ``idle_slots`` system. A re-introduced
helper would shadow the slot-driven layout features calling it would
set up a second gizmo at a different X and the collision-prevention
contract would silently regress.
Walks the wall and roof modules (the historical callers) plus
drawing/gizmos.py (the historical home) for any reference to either
name."""
import bonsai.bim.module.drawing.gizmos as gizmos_mod
import bonsai.bim.module.model.roof as roof_mod
import bonsai.bim.module.model.wall as wall_mod
forbidden = ("setup_pen_row_toggle_openings_icon", "update_pen_row_toggle_openings_icon")
for mod in (gizmos_mod, roof_mod, wall_mod):
source = inspect.getsource(mod)
for name in forbidden:
assert name not in source, (
f"{mod.__name__} still references {name!r}. The toggle_openings icon "
f"is now declared via idle_slots; the ad-hoc helpers were removed to "
f"prevent layout drift between feature groups."
)
def test_array_idle_max_x_walks_registry_not_hardcoded_dict():
"""``GizmoArrayEdition._resolve_feature_idle_max_x`` must query peer
parametric gizmo groups' ``_idle_row_right_edge`` rather than indexing
a hardcoded per-feature ``_FEATURE_IDLE_MAX_X`` dict. The dict approach
was the source of the toggle_openings array-layer-icon collision bug
on arrayed walls (find_for_element returns 'array' first, shadowing the
wall reservation)."""
from bonsai.bim.module.model.array import GizmoArrayEdition
assert not hasattr(GizmoArrayEdition, "_FEATURE_IDLE_MAX_X"), (
"GizmoArrayEdition._FEATURE_IDLE_MAX_X was a hardcoded per-feature dict "
"that shadowed peer groups' real idle rows for compound elements (arrayed "
"walls). It was replaced by a registry walk via REGISTRY + "
"_idle_row_right_edge() — re-introducing the dict would re-create the bug."
)
source = inspect.getsource(GizmoArrayEdition._resolve_feature_idle_max_x)
assert "_idle_row_right_edge" in source, (
"_resolve_feature_idle_max_x must call peer_cls._idle_row_right_edge() so "
"the X position derives from each peer's actual declared idle_slots."
)
assert "REGISTRY" in source, (
"_resolve_feature_idle_max_x must iterate BaseParametricGizmoGroup.REGISTRY "
"to discover peer groups; find_for_element returns ONE entry and shadows "
"compound-element memberships."
)
@@ -18,20 +18,21 @@
#
# This file was generated with the assistance of an AI coding tool.
"""Regression tests for the post-IFC-commit refresh path.
"""Regression tests for the post-IFC-commit refresh path that re-syncs the
workspace tool header (``BIMModelProperties``) and invalidates the per-wall
gizmo geometry cache.
Two invariants:
* Every commit bumps ``_geom_generation`` so caches keyed off it drop
stale entries on the next read.
* The BIM Tool header float refresh (``refresh_bim_tool_headers``) fires
only for commits whose operator is a parametric ``finish_op`` from
``tool.Parametric.EDIT_TYPES`` the validate-gizmo path. Other
operators skip it; their commit context may lack the view-layer
attributes the refresh reads."""
Bug repro before the fix: hotkey operators that edited the active wall in
place (``bpy.ops.bim.hotkey(hotkey="S_E")`` / ``"C_E"``) mutated IFC but never
fired ``active_object_callback`` (no selection change), so the header H/L/A
fields and the gizmo cache both kept showing stale values. ``refresh_ui_data``
ran, but it never resynced ``BIMModelProperties`` and never invalidated the
per-gizmo-group geometry cache. The fix wires both refreshes through
``tool.Parametric.refresh_post_commit`` and calls it from every
``tool.Ifc.Operator`` epilogue."""
import types
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import bpy
import pytest
@@ -45,42 +46,17 @@ def _require_real_bpy():
pytest.skip("requires real Blender (bpy is mocked or absent)")
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()``."""
def test_refresh_post_commit_bumps_generation_and_resyncs_header():
"""``refresh_post_commit`` must bump the generation counter and call
``update_bim_tool_props`` so the workspace tool header re-syncs from IFC."""
import bonsai.bim.handler as handler
from bonsai import tool
before = tool.Parametric.get_geom_generation()
tool.Parametric.refresh_post_commit(MagicMock(bl_idname="bim.append_library_element"))
with patch.object(handler, "update_bim_tool_props") as mock_resync:
tool.Parametric.refresh_post_commit()
assert tool.Parametric.get_geom_generation() == before + 1
def test_refresh_post_commit_refreshes_headers_for_validate_gizmo_operators():
"""Operators whose ``bl_idname`` matches a ``ParametricObject.finish_op``
in ``EDIT_TYPES`` are the validate-gizmo path: selection didn't
change, but the IFC values backing the BIM Tool header did. The
commit hook must push the new IFC state into the header floats."""
import bonsai.bim.handler as handler
from bonsai import tool
finish_op_idname = tool.Parametric.EDIT_TYPES[0].finish_op
with patch.object(handler, "refresh_bim_tool_headers") as mock_refresh:
tool.Parametric.refresh_post_commit(MagicMock(bl_idname=finish_op_idname))
mock_refresh.assert_called_once()
def test_refresh_post_commit_skips_header_refresh_for_non_finish_operators():
"""Other operators must not trigger the header refresh. The refresh
reads ``bpy.context``; for commits invoked from a stripped operator
context (e.g. nested ``bpy.ops`` calls during project setup) this
would raise ``AttributeError`` and break the outer operator chain."""
import bonsai.bim.handler as handler
from bonsai import tool
with patch.object(handler, "refresh_bim_tool_headers") as mock_refresh:
tool.Parametric.refresh_post_commit(MagicMock(bl_idname="bim.append_library_element"))
mock_refresh.assert_not_called()
mock_resync.assert_called_once()
def test_geom_generation_invalidates_wall_geom_cache():
@@ -99,7 +75,7 @@ def test_geom_generation_invalidates_wall_geom_cache():
sentinel_a = {"length": 1.0, "height": 2.0, "x_angle": 0.0}
sentinel_b = {"length": 1.5, "height": 2.5, "x_angle": 0.0}
with patch.object(tool.Wall, "read_geometry", side_effect=[sentinel_a, sentinel_b]):
with patch.object(wall_mod, "_read_wall_geometry", side_effect=[sentinel_a, sentinel_b]):
first = wall_mod._get_wall_geom_cached(group, fake_obj)
assert first is sentinel_a
# Same call without a generation bump must hit the cache (no extra read).
@@ -1,453 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Offset arithmetic for the filling (Door / Window) wall-offset dimension gizmos.
The apply path (``_set_offset``) must translate ``obj.matrix_world`` so the
corresponding read path (``_get_offset``) reads back the new value i.e.
drag a left-offset to 2.0 m, then reading the left offset must return ~2.0 m.
The tests below pin that round-trip, the rotated/flipped filling case (the
add-opening flow may 180° a filling onto the wall's opposite face), and the
visibility predicate."""
from math import pi
from unittest import mock
import pytest
from mathutils import Matrix
pytestmark = pytest.mark.model
# Module under test, plus its cache dict so each test starts from a clean slate.
import bonsai.bim.module.model.wall_offset_gizmos as subject
@pytest.fixture(autouse=True)
def _clear_geom_cache():
"""Per-test isolation — the module-level cache would otherwise carry mocks
across tests and surface as flaky-looking failures."""
subject._GEOM_CACHE.clear()
yield
subject._GEOM_CACHE.clear()
def _make_props(filling_matrix, overall_width=1.0, overall_height=2.0, name="FillingObj"):
"""Filling PropertyGroup stand-in (``BIMDoorProperties`` /
``BIMWindowProperties`` share the relevant shape). The wall-offset helpers
only touch ``id_data`` (the filling obj), ``overall_width``, and
``overall_height``; nothing else from the real PropertyGroup matters here."""
filling_obj = mock.Mock(name=name)
filling_obj.name = name
filling_obj.matrix_world = filling_matrix
props = mock.Mock(spec=["id_data", "overall_width", "overall_height"])
props.id_data = filling_obj
props.overall_width = overall_width
props.overall_height = overall_height
return props, filling_obj
def _patch_host_wall(wall_matrix, length, height, geom_gen=1, host_present=True, x_angle=0.0):
"""Mock the chain ``Ifc.get_entity → Spatial.get_host_wall → Ifc.get_object``
and the ``tool.Wall.*`` IFC reads so the helpers see a host wall
positioned at ``wall_matrix`` with the supplied length, height, and
extrusion angle. The IFC axis is taken to start at wall-local X=0 and
extend to X=``length``. ``host_present=False`` makes the chain return
None partway through."""
wall_obj = mock.Mock(name="WallObj")
wall_obj.matrix_world = wall_matrix
host_wall = mock.Mock(name="IfcWall") if host_present else None
return mock.patch.multiple(
subject.tool,
Ifc=mock.MagicMock(
spec=subject.tool.Ifc,
get_entity=mock.Mock(return_value=mock.Mock(name="IfcDoor")),
get_object=mock.Mock(return_value=wall_obj if host_present else None),
),
Spatial=mock.MagicMock(
spec=subject.tool.Spatial,
get_host_wall=mock.Mock(return_value=host_wall),
),
Wall=mock.MagicMock(
spec=subject.tool.Wall,
get_length_and_height=mock.Mock(return_value=(length, height) if host_present else None),
get_axis_local_extent=mock.Mock(return_value=(0.0, length) if host_present else None),
get_x_angle=mock.Mock(return_value=x_angle if host_present else None),
),
Parametric=mock.MagicMock(
spec=subject.tool.Parametric,
get_geom_generation=mock.Mock(return_value=geom_gen),
),
)
# ----------------------------------------------------------------------
# Visibility predicate
# ----------------------------------------------------------------------
def test_has_host_wall_returns_true_when_chain_resolves():
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
assert subject.has_host_wall(props) is True
def test_has_host_wall_returns_false_when_no_host():
props, _ = _make_props(Matrix.Identity(4))
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0, host_present=False):
assert subject.has_host_wall(props) is False
def test_has_host_wall_returns_true_for_slanted_wall():
"""Slanted LAYER2 walls keep ``matrix_world`` upright — the slope lives in
the IFC extrusion direction and in the wall mesh vertices, not in the
object transform. So wall-local Z still equals world Z, the offset math
round-trips, and the gizmos must remain visible."""
import math
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0, x_angle=math.radians(15)):
assert subject.has_host_wall(props) is True
# ----------------------------------------------------------------------
# Compute helpers (un-flipped filling, wall at world origin)
# ----------------------------------------------------------------------
def test_get_wall_offset_left_for_filling_at_known_x():
"""Wall span 0→5 on X; filling origin at wall-local X=1.5 with +X aligned.
Filling's left edge is at wall-X 1.5 → offset_left = 1.5."""
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
assert subject._get_offset(props, subject._LEFT) == pytest.approx(1.5)
def test_get_wall_offset_right_complements_left_plus_width():
"""offset_left + overall_width + offset_right == wall length."""
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
left = subject._get_offset(props, subject._LEFT)
right = subject._get_offset(props, subject._RIGHT)
assert left + props.overall_width + right == pytest.approx(5.0)
def test_get_wall_offset_bottom_for_filling_at_sill_height():
"""Wall base at world Z=0; filling origin at wall-local Z=0.5 → sill at 0.5 m."""
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
assert subject._get_offset(props, subject._BOTTOM) == pytest.approx(0.5)
def test_get_wall_offset_top_complements_bottom_plus_height():
"""offset_bottom + overall_height + offset_top == wall height."""
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
bottom = subject._get_offset(props, subject._BOTTOM)
top = subject._get_offset(props, subject._TOP)
assert bottom + props.overall_height + top == pytest.approx(3.0)
# ----------------------------------------------------------------------
# Slanted wall (LAYER2): wall ``matrix_world`` stays upright, so the
# offset math is the same as for a vertical wall. Pinning this guards
# against the visibility gate being re-added or the math diverging.
# ----------------------------------------------------------------------
def test_get_wall_offset_bottom_for_slanted_wall():
"""For a LAYER2 slanted wall the wall matrix is identity rotation — sill
height read in the wall's local Z is still the world Z above the wall
base."""
import math
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.9)))
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0, x_angle=math.radians(15)):
assert subject._get_offset(props, subject._BOTTOM) == pytest.approx(0.9)
def test_set_wall_offset_bottom_round_trips_for_slanted_wall():
"""Round-trip on a slanted wall: setting then reading the bottom offset
yields the input. The apply path translates along the wall's local Z
direction in world space (``matrix_world.to_3x3().col[2]``), which equals
world Z for an upright wall matrix regardless of IFC slope."""
import math
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0, x_angle=math.radians(15)):
subject._set_offset(props, subject._BOTTOM, 1.2)
assert subject._get_offset(props, subject._BOTTOM) == pytest.approx(1.2)
# ----------------------------------------------------------------------
# Flipped filling (180° around Z) — add-opening flow flips a filling that
# lands on the wall's opposite face. Offset arithmetic must still report
# the leftmost/rightmost edges in wall coordinates, not in filling coordinates.
# ----------------------------------------------------------------------
def test_get_wall_offset_left_handles_flipped_filling():
"""Flipped filling at wall-local X=1.5: filling extends from X=1.5 in filling's +X
direction, which is wall's -X. So filling's leftmost edge in wall coords is
at wall-X 0.5, not wall-X 1.5."""
filling_matrix = Matrix.Translation((1.5, 0.0, 0.5)) @ Matrix.Rotation(pi, 4, "Z")
props, _ = _make_props(filling_matrix, overall_width=1.0)
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
assert subject._get_offset(props, subject._LEFT) == pytest.approx(0.5)
def test_get_wall_offset_right_handles_flipped_filling():
"""Same flipped filling — filling's rightmost edge in wall coords is at the
filling origin (wall-X 1.5), so offset_right = wall_length - 1.5 = 3.5."""
filling_matrix = Matrix.Translation((1.5, 0.0, 0.5)) @ Matrix.Rotation(pi, 4, "Z")
props, _ = _make_props(filling_matrix, overall_width=1.0)
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
assert subject._get_offset(props, subject._RIGHT) == pytest.approx(3.5)
# ----------------------------------------------------------------------
# Apply helpers — must round-trip with the compute helpers.
# ----------------------------------------------------------------------
def test_set_wall_offset_left_translates_filling_along_wall_x():
"""Setting left-offset to 2.0 (from 1.5) shifts the filling origin by +0.5
along the wall's local X axis."""
props, filling_obj = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
subject._set_offset(props, subject._LEFT, 2.0)
assert filling_obj.matrix_world.translation.x == pytest.approx(2.0)
assert filling_obj.matrix_world.translation.z == pytest.approx(0.5)
def test_set_wall_offset_bottom_translates_filling_along_wall_z():
"""Setting bottom-offset to 1.0 (from 0.5) shifts the filling origin by +0.5
along the wall's local Z axis."""
props, filling_obj = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
subject._set_offset(props, subject._BOTTOM, 1.0)
assert filling_obj.matrix_world.translation.z == pytest.approx(1.0)
assert filling_obj.matrix_world.translation.x == pytest.approx(1.5)
def test_set_wall_offset_right_round_trips_with_get():
"""The right-edge setter is the symmetric pair of the left-edge setter —
they must produce mutually consistent geometry, otherwise pulling the
right edge would silently desync the left."""
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
subject._set_offset(props, subject._RIGHT, 1.0)
result = subject._get_offset(props, subject._RIGHT)
assert result == pytest.approx(1.0)
def test_set_wall_offset_top_round_trips_with_get():
"""Same round-trip invariant for the top edge."""
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
subject._set_offset(props, subject._TOP, 0.25)
result = subject._get_offset(props, subject._TOP)
assert result == pytest.approx(0.25)
# ----------------------------------------------------------------------
# Cache invalidation
# ----------------------------------------------------------------------
def test_geom_cache_invalidates_when_generation_bumps():
"""The cache must reset when ``tool.Parametric.get_geom_generation()``
advances so IFC mutations don't leave stale host-wall reads in memory."""
props, _ = _make_props(Matrix.Translation((1.0, 0.0, 0.0)))
# Patch get_geom_generation on the real class — the cache binds to the class
# at definition time, so a mock.patch.multiple on subject.tool.Parametric
# would be invisible to it.
from bonsai.tool.parametric import Parametric
with mock.patch.object(Parametric, "get_geom_generation", return_value=1):
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
first = subject._get_offset(props, subject._LEFT)
with mock.patch.object(Parametric, "get_geom_generation", return_value=2):
with _patch_host_wall(Matrix.Identity(4), length=8.0, height=3.0):
right_after_bump = subject._get_offset(props, subject._RIGHT)
assert first == pytest.approx(1.0)
# 8 m wall, filling at x=1, width 1 → right offset = 6. Reads 6 only if the
# cache dropped on the generation bump.
assert right_after_bump == pytest.approx(6.0)
# ----------------------------------------------------------------------
# Signed value the gizmo reports for left/right (sign-flip on filling's 180° Z rotation)
#
# Without the sign flip the dim arrow renders in the wrong world direction
# for a filling whose local +X is opposite the wall's local +X. The gizmo
# system flips its rendered dim arrow 180° around Z whenever the reported
# value is negative, so the unflipped/flipped cases produce mirrored signs
# and the arrow ends up pointing the right way visually in both orientations.
# ----------------------------------------------------------------------
def test_left_signed_value_positive_for_unflipped_filling():
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
assert subject._compute_value(props, subject._LEFT) == pytest.approx(1.5)
def test_left_signed_value_negative_for_flipped_filling():
"""Filling rotated 180° around Z: its leftmost edge (in wall coords) sits
at wall-X 0.5, so the user-facing left offset is 0.5 but the signed
value must be -0.5 so the gizmo flips its rendered arrow 180° around Z."""
from math import pi
filling = Matrix.Translation((1.5, 0.0, 0.5)) @ Matrix.Rotation(pi, 4, "Z")
props, _ = _make_props(filling, overall_width=1.0)
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
assert subject._compute_value(props, subject._LEFT) == pytest.approx(-0.5)
def test_right_signed_value_positive_for_unflipped_filling():
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
assert subject._compute_value(props, subject._RIGHT) == pytest.approx(2.5)
def test_right_signed_value_negative_for_flipped_filling():
from math import pi
filling = Matrix.Translation((1.5, 0.0, 0.5)) @ Matrix.Rotation(pi, 4, "Z")
props, _ = _make_props(filling, overall_width=1.0)
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
assert subject._compute_value(props, subject._RIGHT) == pytest.approx(-3.5)
# ----------------------------------------------------------------------
# Matrix-position anchors at the wall edge (visual: arrow tail at wall,
# head at filling). The position is in filling-local frame so that mw @ pos
# lands at the wall edge in world.
# ----------------------------------------------------------------------
def test_left_offset_position_lands_at_wall_start_in_world():
"""For an unflipped filling at wall-local (1.5, 0, 0.5) with the wall at the
world origin (bound_box.min_x=0), the matrix_position transformed by the
filling's world matrix must land at world (0, 0, mid_height)."""
props, filling_obj = _make_props(Matrix.Translation((1.5, 0.0, 0.5)), overall_height=2.0)
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
pos_filling_local = subject._edge_position(props, subject._LEFT)
pos_world = filling_obj.matrix_world @ pos_filling_local
# Wall's start at world X=0 (bound_box.min_x=0 in the patched wall).
assert pos_world.x == pytest.approx(0.0)
def test_top_offset_position_lands_at_wall_top_in_world():
"""The top arrow anchors at wall-top — filling-local Z must equal
``overall_height + top_offset`` so the mw-multiplied point lands on the
wall's top edge at world height."""
props, filling_obj = _make_props(Matrix.Translation((1.5, 0.0, 0.5)), overall_height=2.0)
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
pos_filling_local = subject._edge_position(props, subject._TOP)
pos_world = filling_obj.matrix_world @ pos_filling_local
# Wall height 3.0, wall base at world Z=0 → wall top at world Z=3.
assert pos_world.z == pytest.approx(3.0)
def test_bottom_offset_position_lands_at_wall_base_in_world():
"""Same idea for the bottom anchor — filling-local Z = ``-bottom_offset``
so the point lands at world Z=0 (the wall's base)."""
props, filling_obj = _make_props(Matrix.Translation((1.5, 0.0, 0.5)), overall_height=2.0)
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
pos_filling_local = subject._edge_position(props, subject._BOTTOM)
pos_world = filling_obj.matrix_world @ pos_filling_local
assert pos_world.z == pytest.approx(0.0)
def test_apply_value_takes_absolute_value():
"""Apply lambdas use ``abs(v)`` so the apply path stays correct even when
the compute side returned a negative signed value (flipped filling)."""
props, filling_obj = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
# Simulate the gizmo handing back a negative value (flipped-filling scenario).
# The user-facing offset is +2.0, the filling must end up at wall-X=2.0.
left_cfg = next(c for c in subject.WALL_OFFSET_GIZMO_CONFIGS if c.attr_name == "host_wall_offset_left")
left_cfg.apply_value(props, -2.0)
assert filling_obj.matrix_world.translation.x == pytest.approx(2.0)
def test_clear_caches_drops_all_entries():
"""The ``load_post`` handler calls ``clear_caches`` so a fresh file
doesn't inherit stale entries from the previous one. Pin the contract."""
props, _ = _make_props(Matrix.Translation((1.0, 0.0, 0.0)))
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
subject._get_offset(props, subject._LEFT)
assert subject._GEOM_CACHE._data
subject.clear_caches()
assert not subject._GEOM_CACHE._data
# ----------------------------------------------------------------------
# Stale-cache edge cases — cache keys are Blender object names, invalidated
# only by parametric generation bumps and ``load_post``. Anything that
# changes scene state without bumping the generation (Blender rename,
# external Python script deleting a wall) leaves the cache holding stale
# entries until the next IFC mutation. These tests pin that behavior.
# ----------------------------------------------------------------------
def test_filling_rename_within_session_reads_correctly_under_new_name():
"""Reading offsets after a Blender rename hits a cache miss under the
new name and recomputes the old-name entry is leaked but harmless,
and the new-name read returns correct geometry."""
props, filling_obj = _make_props(Matrix.Translation((1.5, 0.0, 0.5)), name="Door1")
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
first = subject._get_offset(props, subject._LEFT)
assert "Door1" in subject._GEOM_CACHE._data
filling_obj.name = "Door1_renamed"
second = subject._get_offset(props, subject._LEFT)
assert first == pytest.approx(1.5)
assert second == pytest.approx(1.5)
assert "Door1_renamed" in subject._GEOM_CACHE._data
def test_host_wall_deletion_serves_stale_cache_until_invalidation():
"""If the host wall is removed without bumping the generation counter
(e.g. external script), the cache keeps returning the pre-deletion
geometry only an IFC mutation or ``clear_caches()`` drops the stale
entry."""
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
assert subject.has_host_wall(props) is True
# Even after the patch exits and the chain would now return None, the
# cached entry under the filling's name is still served.
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0, host_present=False):
assert subject.has_host_wall(props) is True # stale
subject.clear_caches()
assert subject.has_host_wall(props) is False # recomputed
def test_filling_rotated_90_degrees_in_wall_plane_falls_back_to_positive_sign():
"""A filling rotated exactly 90° around Z has ``col[0].x == 0.0``, which
is the ambiguous boundary for the X-sign. The implementation falls back
to +1 (the ``>= 0.0`` branch), so the renderer-side value reads as
positive same sign as an unflipped filling."""
filling_matrix = Matrix.Translation((1.5, 0.0, 0.5)) @ Matrix.Rotation(pi / 2, 4, "Z")
props, _ = _make_props(filling_matrix, overall_width=1.0)
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
signed = subject._compute_value(props, subject._LEFT)
# +1 fallback × unflipped-equivalent left offset = +1.5 (filling origin in wall coords).
assert signed == pytest.approx(1.5)
@@ -1,79 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Pins the outward-normals invariant of the parametric-wall draft preview mesh.
``regenerate_wall_mesh_from_props`` rebuilds ``obj.data`` as a fresh bmesh
box from ``BIMWallProperties`` every time a gizmo handle moves. The hand
authored face windings carry no guarantee of outward orientation, so the
function must normalise face windings before writing the mesh back
otherwise the viewport renders the draft with inverted shading and
back-face culling hides faces the user expects to see."""
import types
from unittest.mock import patch
import bpy
import pytest
from mathutils import Vector
pytestmark = pytest.mark.wall
@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)
+1 -11
View File
@@ -1764,17 +1764,7 @@ def i_load_the_ifc_test_file(filepath):
@given("I load the demo construction library")
@when("I load the demo construction library")
def i_add_a_construction_library():
# Pick the library file whose schema matches the current project so the
# appended types are valid (IFC2X3-vs-IFC4 entity attributes differ).
schema_to_library = {
"IFC2X3": "IFC2X3 Demo Library.ifc",
"IFC4": "IFC4 Demo Library.ifc",
"IFC4X3": "IFC4X3 Demo Library.ifc",
"IFC4X3_ADD2": "IFC4X3 Demo Library.ifc",
}
schema = tool.Ifc.get().schema
lib_name = schema_to_library.get(schema, "IFC4 Demo Library.ifc")
lib_path = f"./bonsai/bim/data/libraries/{lib_name}"
lib_path = "./bonsai/bim/data/libraries/IFC4 Demo Library.ifc"
bpy.ops.bim.select_library_file(filepath=lib_path, append_all=True)
@@ -1,177 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Forward-compat AST contracts for the BIM Tool refresh path.
Pins structural invariants that no behavioural test can catch on its own:
the commit-driven header refresh fires only for the parametric validate-
gizmo operators (``bim.finish_editing_<name>``), never universally and
the header writer never drifts into user-intent enum writes."""
import ast
from pathlib import Path
import pytest
pytestmark = pytest.mark.model
HANDLER_PATH = Path(__file__).parent.parent.parent / "bonsai" / "bim" / "handler.py"
PARAMETRIC_PATH = HANDLER_PATH.parent.parent / "tool" / "parametric.py"
MODEL_MODULE_DIR = HANDLER_PATH.parent / "module" / "model"
# User-intent enums encode the user's "what to build next" choice on the
# BIM Tool panel. The header-only writer must never drift into enum writes;
# user-intent enums are owned by the selection-change path.
USER_INTENT_ENUM_ATTRS = frozenset({"ifc_class", "relating_type_id"})
def _function_node(tree: ast.Module, name: str) -> ast.FunctionDef:
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef) and node.name == name:
return node
raise AssertionError(f"{name!r} not found in {HANDLER_PATH.name}")
@pytest.fixture(scope="module")
def handler_tree() -> ast.Module:
return ast.parse(HANDLER_PATH.read_text(encoding="utf-8"))
def test_read_headers_into_props_writes_only_header_floats(handler_tree: ast.Module) -> None:
"""``_read_headers_into_props`` is the header-only writer called from
the selection-driven refresh. It must not assign to user-intent enum
slots (``ifc_class``, ``relating_type_id``); those are the
'what to build next' choice and have their own targeted writes
earlier in ``update_bim_tool_props``."""
fn = _function_node(handler_tree, "_read_headers_into_props")
offenders = []
for node in ast.walk(fn):
if not isinstance(node, ast.Assign):
continue
for target in node.targets:
if isinstance(target, ast.Attribute) and target.attr in USER_INTENT_ENUM_ATTRS:
offenders.append((target.attr, node.lineno))
if offenders:
msgs = ", ".join(f"{attr} at line {line}" for attr, line in offenders)
pytest.fail(
f"_read_headers_into_props assigns to user-intent enum slot(s): {msgs}. "
f"Header refresh must not re-target the user's BIM Tool panel selection."
)
def test_refresh_post_commit_gates_header_refresh_on_edit_types_registry() -> None:
"""``tool.Parametric.refresh_post_commit`` fires for every IFC
operator commit. Only operators whose ``bl_idname`` matches a
``ParametricObject.finish_op`` in ``EDIT_TYPES`` (the validate-
gizmo path) must trigger a BIM Tool header refresh selection
didn't change but the header values did. Other operators must
skip the refresh: they don't target an active-object header edit,
and their commit context may lack the view-layer attributes the
refresh reads.
The gate must consult the registry, not match a string prefix
``EDIT_TYPES`` is the canonical list of parametric features, and
querying it stays correct even if ``ParametricObject.finish_op``
changes its derivation rule."""
parametric_tree = ast.parse(PARAMETRIC_PATH.read_text(encoding="utf-8"))
fn = _function_node(parametric_tree, "refresh_post_commit")
found_gated_call = False
for node in ast.walk(fn):
if not isinstance(node, ast.If):
continue
references_registry = any(
isinstance(sub, ast.Attribute) and sub.attr == "EDIT_TYPES" for sub in ast.walk(node.test)
)
if not references_registry:
continue
for body_node in ast.walk(node):
if (
isinstance(body_node, ast.Call)
and isinstance(body_node.func, ast.Attribute)
and body_node.func.attr == "refresh_bim_tool_headers"
):
found_gated_call = True
break
if found_gated_call:
break
assert found_gated_call, (
"tool.Parametric.refresh_post_commit must gate refresh_bim_tool_headers on an "
"If whose test references EDIT_TYPES (the parametric registry). An ungated call "
"fires the refresh for commits in contexts that strip view-layer attributes; "
"a missing call silently drops the validate-gizmo header refresh."
)
def _modules_with_module_scope_cache_and_clear():
"""Yield ``module_name`` for every ``bim/module/model/*.py`` source that
declares a module-scope ``GenerationKeyedCache()`` assignment AND a
top-level ``def clear_caches``. These are the modules whose cache state
survives file loads and must be drained from ``_apply_save_file_invariants``."""
for path in MODEL_MODULE_DIR.glob("*.py"):
tree = ast.parse(path.read_text(encoding="utf-8"))
has_cache = False
has_clear = False
for node in tree.body:
if isinstance(node, ast.FunctionDef) and node.name == "clear_caches":
has_clear = True
continue
if isinstance(node, ast.Assign):
for sub in ast.walk(node.value):
if (
isinstance(sub, ast.Call)
and isinstance(sub.func, ast.Attribute)
and sub.func.attr == "GenerationKeyedCache"
):
has_cache = True
break
if has_cache and has_clear:
yield path.stem
def test_on_load_post_drains_every_module_scope_geom_cache() -> None:
"""Module-scope ``GenerationKeyedCache`` instances persist across file
loads the counter they invalidate against is class-level and survives
a ``.blend`` reload. Without a ``load_post`` drain the cache may serve
entries whose ``bpy_struct`` references point into the previous file's
freed ``bpy.data``, raising ``ReferenceError`` on the next attribute read.
Pin: every model module that exposes both a module-scope cache and a
top-level ``clear_caches`` is called from ``tool.Parametric.on_load_post``,
the central post-load drain."""
parametric_tree = ast.parse(PARAMETRIC_PATH.read_text(encoding="utf-8"))
fn = _function_node(parametric_tree, "on_load_post")
drained: set[str] = set()
for node in ast.walk(fn):
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "clear_caches"
and isinstance(node.func.value, ast.Name)
):
drained.add(node.func.value.id)
missing = [name for name in _modules_with_module_scope_cache_and_clear() if name not in drained]
if missing:
pytest.fail(
"Module(s) expose a module-scope GenerationKeyedCache + clear_caches() but "
f"tool.Parametric.on_load_post does not drain them on load_post: {sorted(missing)}. "
"Add a `<module>.clear_caches()` call so freshly-loaded files cannot serve "
"entries holding freed bpy.data references from the previous file."
)
@@ -1,74 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Restricted-context regression test for ``tool.Blender.get_active_object``.
Some Blender contexts (e.g. the C-side operator context handed to
programmatically-invoked nested ``bpy.ops`` calls) lack the view-layer
attributes a normal UI context exposes. The canonical accessor must
return ``None`` in that case rather than ``AttributeError`` otherwise
every caller routed through it inherits the same crash class that
originally broke ``bpy.ops.bim.new_project(preset='demo')``."""
from unittest.mock import patch
import pytest
pytestmark = pytest.mark.model
class _RestrictedContext:
"""Stand-in for a ``bpy.context`` stripped of view-layer attributes."""
def __getattr__(self, name):
raise AttributeError(name)
def test_get_active_object_returns_none_in_restricted_context():
"""``tool.Blender.get_active_object`` is the canonical defensive
accessor. Both the primary read (``bpy.context.active_object``) and
the fallback (``bpy.context.view_layer.objects.active``) must
tolerate a stripped context otherwise the 150+ callers in the
codebase that route through this helper inherit the crash."""
import bonsai.tool.blender as blender_tool
with patch.object(blender_tool, "bpy") as bpy_patch:
bpy_patch.context = _RestrictedContext()
assert blender_tool.Blender.get_active_object() is None
def test_property_header_tools_whitelists_bim_tool_family_only():
"""``tool.Blender.get_property_header_tools`` gates the validate-
gizmo header refresh. Parametric ``BimTool`` subclasses and the
base ``BimTool`` itself must be included; ``AnnotationTool`` and
workspace tools outside the ``BimTool`` family (spatial, structural,
etc.) must not they don't surface these header floats."""
import bonsai.tool as tool_
# Ensure the lru_cache picks up subclasses registered by the
# current Blender session (idempotent if already populated).
tool_.Blender.get_property_header_tools.cache_clear()
headers = tool_.Blender.get_property_header_tools()
assert "bim.bim_tool" in headers, "base BimTool must surface property headers"
assert "bim.wall_tool" in headers, "parametric BimTool subclass must surface property headers"
assert "bim.annotation_tool" not in headers, "AnnotationTool is not a BimTool subclass — no header surface"
assert "bim.spatial_tool" not in headers, "SpatialTool is not BimTool-derived"
assert "bim.structural_tool" not in headers, "StructuralTool is not BimTool-derived"
+30 -19
View File
@@ -22,15 +22,14 @@
The registry is the single source of truth for which parametric element types
exist. Every consumer (auto-commit on save, finish/cancel chains, the
``PointerProperty`` attachment, the ``GizmoPreferences`` per-feature toggle)
derives identifiers from each entry's short ``name`` token. Forget any
downstream registration and the silent-desync the framework exists to prevent
will ship.
``PointerProperty`` attachment, the ``GizmoPreferences<X>`` registration) derives
identifiers from each entry's short ``name`` token. Forget any downstream
registration and the silent-desync the framework exists to prevent will ship.
These tests pin the registry-to-runtime contract: for every entry the operator
``bl_idname``s resolve to registered ``bpy.ops.bim.*`` callables, the
``PropertyGroup`` class is attached to ``bpy.types.Object``, and the per-type
predicate exists on `tool.Parametric`."""
predicate exists on `tool.Blender.Modifier`."""
import types
@@ -82,11 +81,11 @@ def test_every_entry_has_property_group_attached(registry):
)
def test_every_entry_has_parametric_predicate(registry):
def test_every_entry_has_modifier_predicate(registry):
from bonsai import tool
missing = [e.name for e in registry if getattr(tool.Parametric, f"is_{e.name}", None) is None]
assert not missing, f"tool.Parametric missing is_<name> predicates: {missing}"
missing = [e.name for e in registry if getattr(tool.Blender.Modifier, f"is_{e.name}", None) is None]
assert not missing, f"tool.Blender.Modifier missing is_<name> predicates: {missing}"
def test_every_predicate_does_not_raise_on_non_matching_element(registry):
@@ -109,7 +108,7 @@ def test_every_predicate_does_not_raise_on_non_matching_element(registry):
raised = []
for feature in registry:
predicate = getattr(tool.Parametric, f"is_{feature.name}", None)
predicate = getattr(tool.Blender.Modifier, f"is_{feature.name}", None)
if predicate is None:
continue
try:
@@ -123,14 +122,19 @@ def test_every_predicate_does_not_raise_on_non_matching_element(registry):
)
def test_gizmo_preferences_field_per_registry_entry(registry):
"""Every registry entry must have a matching ``<name>: BoolProperty`` field
on ``ui.GizmoPreferences`` so the addon-preferences UI auto-renders a
toggle for it and ``BaseParametricGizmoGroup.poll`` can gate the whole
gizmo group on ``prefs.gizmos.<name>``.
def test_gizmo_preferences_attached_when_class_exists(registry):
"""For every registry entry whose ``GizmoPreferences<Name>`` class exists in
``bonsai.bim.ui``, the matching sub-PointerProperty must be declared on
``ui.GizmoPreferences`` under the registry entry's ``name`` token.
Catches the silent-skip behaviour of the registry-driven gizmo-prefs
discovery: a typo in the class name or a dropped registration would
otherwise produce a missing sub-panel at runtime with no error.
Entries without a ``GizmoPreferences<Name>`` class are allowed not
every parametric type ships gizmo prefs.
Checks ``__annotations__`` rather than ``hasattr`` because Blender's
PropertyGroup syntax (``field: bpy.props.BoolProperty(...)``) is an
PropertyGroup syntax (``field: bpy.props.PointerProperty(...)``) is an
annotation-only assignment the attribute only materialises on the
class after Blender's metaclass installs the bpy_struct descriptor,
which depends on registration timing. Reading ``__annotations__``
@@ -138,9 +142,16 @@ def test_gizmo_preferences_field_per_registry_entry(registry):
from bonsai.bim import ui
annotations = getattr(ui.GizmoPreferences, "__annotations__", {})
missing = [feature.name for feature in registry if feature.name not in annotations]
missing = []
for feature in registry:
prefs_class_name = f"GizmoPreferences{feature.name.capitalize()}"
if not hasattr(ui, prefs_class_name):
continue
if feature.name not in annotations:
missing.append((feature.name, prefs_class_name))
assert not missing, (
f"ui.GizmoPreferences missing BoolProperty field(s) for: {missing}"
f"each registry entry must have a matching ``<name>: BoolProperty(...)`` "
f"field on ``ui.GizmoPreferences`` so the preferences UI surfaces a toggle"
f"ui.GizmoPreferences missing sub-PointerProperty field(s) for: {missing}"
f"each registered ``GizmoPreferences<Name>`` class must have a matching "
f"``<name>: PointerProperty(type=GizmoPreferences<Name>)`` field on "
f"``ui.GizmoPreferences``"
)
-1
View File
@@ -56,7 +56,6 @@ class TestCopyClass:
ifc.get_entity("data").should_be_called().will_return("new_representation")
geometry.get_representation_name("new_representation").should_be_called().will_return("name")
geometry.rename_object("data", "name").should_be_called()
root.has_material_styles("element").should_be_called().will_return(False)
root.assign_body_styles("element", "obj").should_be_called()
collector.assign("obj").should_be_called()
subject.copy_class(ifc, collector, geometry, root, obj="obj")
-91
View File
@@ -1,91 +0,0 @@
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
-451
View File
@@ -1,451 +0,0 @@
# 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()
+5 -9
View File
@@ -588,10 +588,6 @@ 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()
@@ -609,7 +605,7 @@ class TestUsingArrays(NewFile):
props.count = 4
props.x = 4
props.sync_children = sync_children
bpy.ops.bim.finish_editing_array()
bpy.ops.bim.edit_array(item=0)
if add_second_layer:
bpy.ops.bim.add_array()
@@ -618,14 +614,14 @@ class TestUsingArrays(NewFile):
props.count = 3
props.y = 4
props.sync_children = sync_children
bpy.ops.bim.finish_editing_array()
bpy.ops.bim.edit_array(item=1)
def test_remove_array_last_to_first(self):
self.setup_array(add_second_layer=True)
bpy.ops.bim.remove_array(item=1)
assert len(self._array_objects()) == 4
assert len(bpy.context.selected_objects) == 4
bpy.ops.bim.remove_array(item=0)
assert len(self._array_objects()) == 1
assert len(bpy.context.selected_objects) == 1
def test_remove_array_first_to_last(self):
self.setup_array(add_second_layer=True)
@@ -651,7 +647,7 @@ class TestUsingArrays(NewFile):
bpy.ops.bim.apply_array() # apply second layer
bpy.ops.bim.apply_array() # apply first layer
objs = self._array_objects()
objs = bpy.context.selected_objects
assert len(objs) == 12
# check BBIM_Array psets are removed
@@ -26,7 +26,7 @@
#let bill_of_quantities_table(currency: "") = table(
#let bill_of_quantities_table = 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], [],[l],[w],[h/w], [Quantity], [Rate (#currency)], [Total (#currency)]
[Hierarchy], [Description], [],[l],[w],[h/w], [Quantity], [Rate], [Total]
)
#let schedule_of_rates_table(currency: "") = table(
#let schedule_of_rates_table = table(
columns: (30mm,130mm, 25mm),
rows: (6mm, 248mm),
align: (center, left, center),
@@ -51,12 +51,12 @@
top: 1pt,
bottom: 1pt
),
[Identification], [Description], [Rate (#currency)]
[Identification], [Description], [Rate]
)
#let summary_table(currency: "") = table(
#let summary_table = 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 (#currency)],
text(size: 8pt)[Total (#currency)]
text(size: 8pt)[Description],
text(size: 8pt)[Sub Total],
text(size: 8pt)[Total]
)
@@ -127,6 +127,7 @@
#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
@@ -215,8 +216,7 @@
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") == "" or row.at("RateSubtotal") == "" {
format-decimal(0.0, places: 2)} else {
let total = if row.at("Quantity") == "" {0.0} 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 + bottom)[#unit_map.at(row.at("Unit"), default: "")]
let unit = table.cell(align: right)[#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"),
name + linebreak() + description,
if row.at("Identification") == "" {name + linebreak() + description} else {name + linebreak() + description},
[]
)
(
[],
unit,
table.cell(align: right+bottom)[#unit],
table.cell(align: right+bottom)[#rate],
)
(
@@ -342,12 +342,8 @@
) = {
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 => {
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
})
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)))
.sum(default: 0.00)
set text(size: 10pt)
@@ -481,9 +477,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))(currency: project_currency)
format_table.at(schedule_type, default: bill_of_quantities_table)
)
)
@@ -526,9 +522,9 @@
set page(
background:
place( top + left, dx: 15mm, dy: 25mm,
(format_table.at("SUMMARY"))(currency: project_currency)
format_table.at("SUMMARY")
)
)
create-summary(schedule_path, options)
}
}
}
+4 -12
View File
@@ -149,12 +149,8 @@ namespace {
template <>
struct dispatch_conversion<ifcopenshell::geometry::taxonomy::type_by_kind::max> {
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());
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()));
return false;
}
};
@@ -173,12 +169,8 @@ namespace {
template <>
struct dispatch_with_upgrade<ifcopenshell::geometry::taxonomy::upgrades::max> {
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());
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()));
return false;
}
};
+5 -17
View File
@@ -35,25 +35,13 @@ namespace IfcGeom {
class Transformation {
private:
ifcopenshell::geometry::Settings settings_;
ifcopenshell::geometry::taxonomy::matrix4::ptr matrix_, matrix_orig_units_;
ifcopenshell::geometry::taxonomy::matrix4::ptr matrix_;
public:
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;
}
}
Transformation(const ifcopenshell::geometry::Settings& settings, const ifcopenshell::geometry::taxonomy::matrix4::ptr& matrix)
: settings_(settings)
, matrix_(matrix)
{}
const ifcopenshell::geometry::taxonomy::matrix4::ptr& data() const {
if (matrix_orig_units_) {
return matrix_orig_units_;
}
if (matrix_) {
return matrix_;
}
@@ -116,16 +116,6 @@ 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>
@@ -141,8 +131,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, 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::map<Point_d, std::set<Point_d>> neighbours;
std::map<Point_d, std::list<CGAL::Plane_3<Kernel>>> originals;
std::vector<Point_d> planes_as_point;
for (auto& p : planes) {
@@ -215,7 +205,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>, Point_d_4d_Less> normalized_to_original;
std::map<Point_d, CGAL::Plane_3<Kernel>> normalized_to_original;
for (auto& p : planes_fixed) {
// @todo can we skip normalization (simply divide by largest component perhaps)
@@ -48,7 +48,6 @@
#include <stack>
#include <unordered_map>
#include <unordered_set>
#include <cstdint>
#include <BRepExtrema_TriangleSet.hxx>
#include <BRepLProp_SLProps.hxx>
#include <BVH_BinaryTree.hxx>
@@ -136,39 +136,17 @@ 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();
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;
}
entity_part = mv.Shape();
Logger::Warning("Sucessfully detected exterior volume to non-manifold first operand");
} catch (const Standard_Failure& e) {
failure.emplace(e.GetMessageString());
}
if (failure) {
Logger::Warning("MakeVolume failed: " + *failure, entity);
Logger::Warning("MakeVolume failed: " + std::string(e.GetMessageString()), entity);
}
} else {
Logger::Warning("Non-manifold first operand, use --make-volume to try and make manifold");
@@ -1,7 +1,5 @@
#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,13 +154,7 @@ IfcGeom::OpenCascadeKernel::faceset_helper::faceset_helper(
typedef std::array<int, 2> edge_t;
typedef std::set<edge_t> edge_set_t;
// 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;
std::set<edge_set_t> edge_sets;
for (auto& loop : loops) {
std::vector<std::pair<int, int> > segments;
@@ -171,12 +165,12 @@ IfcGeom::OpenCascadeKernel::faceset_helper::faceset_helper(
segments.push_back(std::make_pair(C, D));
});
if (edge_sets.find({loop->external.get_value_or(false), segment_set}) != edge_sets.end()) {
if (edge_sets.find(segment_set) != edge_sets.end()) {
duplicate_faces++;
duplicates_.insert(loop->identity());
continue;
}
edge_sets.insert({loop->external.get_value_or(false), segment_set});
edge_sets.insert(segment_set);
if (segments.size() >= 3) {
for (auto& p : segments) {
@@ -300,11 +300,7 @@ bool OpenCascadeKernel::convert(const taxonomy::sweep_along_curve::ptr scs, Topo
if (applied_temporary_offset) {
gp_Trsf trsf;
// 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()));
trsf.SetTranslation(gp_Vec(-mean.x(), -mean.y(), -mean.z()));
result.Move(trsf);
}
-1
View File
@@ -7,7 +7,6 @@
#include "../../ifcparse/IfcLogger.h"
#include <mutex>
#include <cstdint>
#define INCLUDE_SCHEMA(x) STRINGIFY(../../ifcparse/x.h)
#include INCLUDE_SCHEMA(IfcSchema)
+7 -14
View File
@@ -17,13 +17,6 @@
#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
@@ -1632,19 +1625,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)) {
ifcopenshell::geometry::visit<taxonomy::collection>(s, fn);
visit<taxonomy::collection>(s, fn);
} else if (auto s = std::dynamic_pointer_cast<taxonomy::loop>(i)) {
ifcopenshell::geometry::visit<taxonomy::loop>(s, fn);
visit<taxonomy::loop>(s, fn);
} else if (auto s = std::dynamic_pointer_cast<taxonomy::face>(i)) {
ifcopenshell::geometry::visit<taxonomy::face>(s, fn);
visit<taxonomy::face>(s, fn);
} else if (auto s = std::dynamic_pointer_cast<taxonomy::shell>(i)) {
ifcopenshell::geometry::visit<taxonomy::shell>(s, fn);
visit<taxonomy::shell>(s, fn);
} else if (auto s = std::dynamic_pointer_cast<taxonomy::solid>(i)) {
ifcopenshell::geometry::visit<taxonomy::solid>(s, fn);
visit<taxonomy::solid>(s, fn);
} else if (auto s = std::dynamic_pointer_cast<taxonomy::loft>(i)) {
ifcopenshell::geometry::visit<taxonomy::loft>(s, fn);
visit<taxonomy::loft>(s, fn);
} else if (auto s = std::dynamic_pointer_cast<taxonomy::boolean_result>(i)) {
ifcopenshell::geometry::visit<taxonomy::boolean_result>(s, fn);
visit<taxonomy::boolean_result>(s, fn);
}
else {
fn(i);
@@ -81,13 +81,6 @@ 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
-1
View File
@@ -27,7 +27,6 @@
#include "utils.h"
#include <atomic>
#include <cstdint>
#include <boost/shared_ptr.hpp>
class aggregate_of_instance;
+1 -1
View File
@@ -201,7 +201,7 @@ namespace {
if (character >= 0x20 && character <= 0x7e) {
stream.put((char)character);
} else {
stream << "\\u" << static_cast<uint32_t>(character);
stream << "\\u" << character;
}
});
return stream.str();
-3
View File
@@ -36,9 +36,6 @@
#endif
#include <cstdint>
#include <cstring>
#include <boost/optional.hpp>
#include <boost/shared_ptr.hpp>
#include <boost/logic/tribool.hpp>
+9 -24
View File
@@ -4,12 +4,9 @@
#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>
@@ -413,8 +410,10 @@ IfcUtil::IfcBaseClass* IfcParse::impl::rocks_db_file_storage::assert_existance(s
}
namespace {
std::unique_ptr<rocksdb::DB> init_db(const std::string& filepath, bool readonly) {
rocksdb::DB* init_db(const std::string& filepath, bool readonly) {
rocksdb::DB* db = nullptr;
#ifdef IFOPSH_WITH_ROCKSDB
rocksdb::Options options;
// options.disable_auto_compactions = true;
options.create_if_missing = true;
@@ -446,31 +445,16 @@ 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
}
}
@@ -479,12 +463,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.get(), "g|")
, byguid_internal_(db, "g|")
, byguid_(&byguid_internal_, [this](size_t v) { return assert_existance(v, entityinstance_ref); }, [](IfcUtil::IfcBaseClass* v) { return v->identity(); })
, instance_ids_(db.get(), "i|")
, instance_ids_(db, "i|")
, instance_by_name_(&instance_ids_, [this](size_t v) { return assert_existance(v, entityinstance_ref); })
, bytype_(db.get(), "t|")
, byref_excl_(db.get(), "v|")
, bytype_(db, "t|")
, byref_excl_(db, "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(); })
{
@@ -507,6 +491,7 @@ IfcParse::impl::rocks_db_file_storage::~rocks_db_file_storage()
assert(s.ok());
db->Close();
delete db;
#endif
}
-1
View File
@@ -34,7 +34,6 @@
#include <boost/circular_buffer.hpp>
#include <iterator>
#include <map>
#include <cstdint>
#ifdef IFOPSH_WITH_ROCKSDB
#include <rocksdb/merge_operator.h>
-2
View File
@@ -25,9 +25,7 @@
#include <algorithm>
#include <boost/algorithm/string.hpp>
#include <cctype>
#include <cstdint>
#include <iterator>
#include <memory>
#include <string>
#include <vector>
-1
View File
@@ -26,7 +26,6 @@
#include <boost/shared_ptr.hpp>
#include <set>
#include <vector>
#include <algorithm>
namespace IfcParse {
class declaration;
-2
View File
@@ -30,8 +30,6 @@
#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