mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-06 16:01:36 +00:00
Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b9dbebccc | |||
| 7bcbe1b908 | |||
| 1ccd74e78a | |||
| 93c6350e0f | |||
| a139adaa2c | |||
| a4a806147d | |||
| 4962e3256d | |||
| 06d99feeea | |||
| f584a50fbb | |||
| 24a241addc | |||
| 365be8fb52 | |||
| eacff93945 | |||
| 9d956f18b7 | |||
| 8faf9ff43d | |||
| 87bca20df7 | |||
| a30546f1f2 | |||
| fbe6fe5384 | |||
| bd264f1d85 | |||
| 674ed36e41 | |||
| 1f2b20fd86 | |||
| 94fab271cd | |||
| 8583d0963f | |||
| 77a2284f8a | |||
| 4520a72152 | |||
| 25651a1507 | |||
| 94faaa3160 | |||
| 0e922074b9 | |||
| 90ea256cc3 | |||
| 387bd51b4a | |||
| de4c394b50 | |||
| ab64b652ff | |||
| 99bb1e30ad | |||
| 1707c36bd8 | |||
| 44f5ee028f | |||
| ed7b2fc233 | |||
| 2c18155d98 | |||
| 1e8c0b86a0 | |||
| ac11044261 | |||
| ab9152e32d | |||
| 18dc7abb06 | |||
| b7549f2476 | |||
| ba6cfe9c24 | |||
| b154cadf3b | |||
| 1ba9341201 | |||
| f0aec7b38e | |||
| 4cf34b69d2 | |||
| 44723e83b6 | |||
| 2272c35e9b | |||
| de29d12b00 | |||
| 9440bafc32 | |||
| 0d3543fa31 | |||
| e764559133 |
@@ -258,10 +258,14 @@ if(WITH_ROCKSDB)
|
||||
set(ROCKSDB_LIBRARIES "IFCOPENSHELL_RocksDB")
|
||||
target_compile_definitions(IFCOPENSHELL_RocksDB INTERFACE IFOPSH_WITH_ROCKSDB)
|
||||
set(SWIG_DEFINES ${SWIG_DEFINES} -DIFOPSH_WITH_ROCKSDB)
|
||||
# Shared binaries for `rocksdb` only support limited API (only `c.h`), but we use `db.h` API.
|
||||
# So rocksdb supported only as a static library.
|
||||
# See https://github.com/facebook/rocksdb/issues/981.
|
||||
target_link_libraries(IFCOPENSHELL_RocksDB INTERFACE RocksDB::rocksdb)
|
||||
if(TARGET RocksDB::rocksdb)
|
||||
target_link_libraries(IFCOPENSHELL_RocksDB INTERFACE RocksDB::rocksdb)
|
||||
elseif(TARGET RocksDB::rocksdb-shared)
|
||||
target_link_libraries(IFCOPENSHELL_RocksDB INTERFACE RocksDB::rocksdb-shared)
|
||||
else()
|
||||
message(FATAL_ERROR "RocksDB found but neither RocksDB::rocksdb nor RocksDB::rocksdb-shared target exists")
|
||||
endif()
|
||||
|
||||
if(WITH_ZSTD)
|
||||
# @todo do we actually need the zstd include dir or rather just pass
|
||||
|
||||
@@ -88,7 +88,15 @@ if(NOT HDF5_INCLUDE_DIR OR NOT HDF5_LIBRARY_DIR)
|
||||
mark_as_advanced(HDF5_DIR)
|
||||
if(HDF5_DIR)
|
||||
message(STATUS "HDF5: found config at '${HDF5_DIR}'.")
|
||||
set(HDF5_LIBRARIES hdf5_cpp-static)
|
||||
if(TARGET hdf5_cpp-static)
|
||||
set(HDF5_LIBRARIES hdf5_cpp-static)
|
||||
elseif(TARGET hdf5_cpp-shared)
|
||||
set(HDF5_LIBRARIES hdf5_cpp-shared)
|
||||
elseif(TARGET hdf5::hdf5_cpp-shared)
|
||||
set(HDF5_LIBRARIES hdf5::hdf5_cpp-shared)
|
||||
else()
|
||||
find_package(HDF5 REQUIRED COMPONENTS CXX)
|
||||
endif()
|
||||
else()
|
||||
# If it failed, still try to find as a module.
|
||||
# E.g. on Ubuntu `libhdf5-dev` doesn't provie hdf5-config.cmake.
|
||||
|
||||
@@ -29,18 +29,6 @@ from bpy_extras.io_utils import ExportHelper, ImportHelper
|
||||
|
||||
from . import handler, operator, parametric_lifecycle, prop, ui
|
||||
|
||||
|
||||
def _parametric_gizmo_preference_classes() -> list[type]:
|
||||
"""Resolves the registry-driven ``GizmoPreferences<X>`` classes for the
|
||||
``classes`` list below. ``import bonsai.tool`` is kept local to surface
|
||||
the load-order constraint: it relies on ``from . import handler, …``
|
||||
above having primed the
|
||||
``tool/ifc.py → bim/ifc.py → bim/handler.py → bonsai.tool`` cycle."""
|
||||
import bonsai.tool as tool
|
||||
|
||||
return tool.Parametric.iter_gizmo_preference_classes(ui)
|
||||
|
||||
|
||||
try:
|
||||
from bonsai.translations import translations_dict
|
||||
except ImportError:
|
||||
@@ -171,10 +159,6 @@ classes = [
|
||||
ui.BIM_UL_tab_visibilities,
|
||||
ui.BIM_UL_panel_visibilities,
|
||||
ui.DocPreferences,
|
||||
# Per-parametric-type ``GizmoPreferences<Name>`` classes — must register
|
||||
# before ``ui.GizmoPreferences`` which holds the matching PointerProperty
|
||||
# fields. Driven by ``tool.Parametric.EDIT_TYPES``.
|
||||
*_parametric_gizmo_preference_classes(),
|
||||
ui.GizmoPreferences,
|
||||
# ui.DefaultParameters and ui.BIM_ADDON_preferences are registered separately after modules (see late_classes below)
|
||||
# Tabs panel
|
||||
@@ -287,6 +271,7 @@ def register():
|
||||
parametric_lifecycle.install_parametric_lifecycle_handlers()
|
||||
bpy.app.handlers.load_post.append(handler.load_post)
|
||||
bpy.app.handlers.load_post.append(handler.loadIfcStore)
|
||||
bpy.app.handlers.save_post.append(handler.save_post)
|
||||
bpy.types.Scene.BIMProperties = bpy.props.PointerProperty(type=prop.BIMProperties)
|
||||
bpy.types.Scene.BIMSnapProperties = bpy.props.PointerProperty(type=prop.BIMSnapProperties)
|
||||
bpy.types.Scene.BIMSnapGroups = bpy.props.PointerProperty(type=prop.BIMSnapGroups)
|
||||
@@ -345,6 +330,7 @@ def unregister():
|
||||
parametric_lifecycle.uninstall_parametric_lifecycle_handlers()
|
||||
bpy.app.handlers.load_post.remove(handler.load_post)
|
||||
bpy.app.handlers.load_post.remove(handler.loadIfcStore)
|
||||
bpy.app.handlers.save_post.remove(handler.save_post)
|
||||
del bpy.types.Scene.BIMProperties
|
||||
del bpy.types.Collection.BIMCollectionProperties
|
||||
del bpy.types.Object.BIMObjectProperties
|
||||
|
||||
@@ -41,6 +41,10 @@ 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,
|
||||
@@ -48,7 +52,7 @@ from bonsai.bim.module.model.decorator import (
|
||||
WallAxisDecorator,
|
||||
WallFilletPreviewDecorator,
|
||||
)
|
||||
from bonsai.bim.module.model.preview_base import discard_pending_previews
|
||||
from bonsai.bim.module.model.wall import WallGizmoPreviewDecorator
|
||||
from bonsai.bim.module.nest.decorator import NestDecorator
|
||||
|
||||
cwd = os.path.dirname(os.path.realpath(__file__))
|
||||
@@ -116,19 +120,13 @@ def active_object_callback():
|
||||
|
||||
|
||||
def update_bim_tool_props():
|
||||
"""update BIM Tools props (such as extrusion_depth, length and x_angle) when active object changes"""
|
||||
obj = bpy.context.active_object
|
||||
|
||||
# bunch of checks to see if we're in a valid state
|
||||
if not obj:
|
||||
return
|
||||
mode = bpy.context.mode
|
||||
current_tool = bpy.context.workspace.tools.from_space_view3d_mode(mode)
|
||||
if not current_tool or current_tool.idname not in tool.Blender.get_list_of_tools():
|
||||
return
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element:
|
||||
"""Selection-driven BIM Tool sync: re-target user-intent enums
|
||||
(ifc_class, relating_type_id) AND refresh header values
|
||||
(extrusion_depth, length, x_angle) for the new active object."""
|
||||
ctx = _resolve_bim_tool_context()
|
||||
if ctx is None:
|
||||
return
|
||||
obj, current_tool, element = ctx
|
||||
|
||||
props = tool.Model.get_model_props()
|
||||
aprops = tool.Drawing.get_annotation_props()
|
||||
@@ -178,6 +176,48 @@ def update_bim_tool_props():
|
||||
if is_annotation_tool:
|
||||
return
|
||||
|
||||
_read_headers_into_props(obj, element)
|
||||
|
||||
|
||||
def refresh_bim_tool_headers():
|
||||
"""Push the active IFC entity's current header float values
|
||||
(extrusion_depth, length, x_angle) into ``BIMModelProperties``.
|
||||
Enum-safe: never writes user-intent enum slots, which are owned by
|
||||
the selection callback."""
|
||||
ctx = _resolve_bim_tool_context()
|
||||
if ctx is None:
|
||||
return
|
||||
obj, current_tool, element = ctx
|
||||
if current_tool.idname not in tool.Blender.get_property_header_tools():
|
||||
return
|
||||
_read_headers_into_props(obj, element)
|
||||
|
||||
|
||||
def _resolve_bim_tool_context():
|
||||
"""Return ``(obj, current_tool, element)`` when an active BIM workspace
|
||||
tool sees a resolvable IFC element; ``None`` otherwise. Defensive
|
||||
against stripped operator contexts — a missing ``active_object`` /
|
||||
``mode`` / ``workspace`` short-circuits to ``None`` instead of raising."""
|
||||
obj = tool.Blender.get_active_object()
|
||||
if not obj:
|
||||
return None
|
||||
mode = getattr(bpy.context, "mode", None)
|
||||
workspace = getattr(bpy.context, "workspace", None)
|
||||
if mode is None or workspace is None:
|
||||
return None
|
||||
current_tool = workspace.tools.from_space_view3d_mode(mode)
|
||||
if not current_tool or current_tool.idname not in tool.Blender.get_list_of_tools():
|
||||
return None
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element:
|
||||
return None
|
||||
return obj, current_tool, element
|
||||
|
||||
|
||||
def _read_headers_into_props(obj, element):
|
||||
"""Populate ``BIMModelProperties`` header values from the active
|
||||
object's IFC extrusion. Enum-safe: writes only header floats, never
|
||||
user-intent enum slots, so it is safe to call on the post-commit hook."""
|
||||
representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
|
||||
if not representation:
|
||||
return
|
||||
@@ -195,6 +235,7 @@ def update_bim_tool_props():
|
||||
if not AuthoringData.is_loaded:
|
||||
AuthoringData.load()
|
||||
|
||||
props = tool.Model.get_model_props()
|
||||
if AuthoringData.data["active_material_usage"] == "LAYER2":
|
||||
x_angle = get_x_angle(extrusion)
|
||||
axis = tool.Model.get_wall_axis(obj)["reference"]
|
||||
@@ -391,10 +432,33 @@ def subscribe_to_viewport_shading_changes():
|
||||
)
|
||||
|
||||
|
||||
@persistent
|
||||
def save_post(scene) -> None:
|
||||
"""After saving the .blend file, convert the stored IFC path to relative if enabled."""
|
||||
pprops = tool.Project.get_project_props()
|
||||
if not pprops.use_relative_project_path:
|
||||
return
|
||||
bim_props = tool.Blender.get_bim_props()
|
||||
ifc_path = bim_props.ifc_file
|
||||
if not ifc_path or not os.path.isabs(ifc_path):
|
||||
return
|
||||
blend_dir = bpy.path.abspath("//")
|
||||
if not blend_dir:
|
||||
return
|
||||
from pathlib import Path
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
try:
|
||||
rel_path = str(Path(ifc_path).relative_to(blend_dir))
|
||||
except ValueError:
|
||||
return # IFC file is not under the blend directory; keep absolute path
|
||||
bim_props.ifc_file = rel_path
|
||||
IfcStore.set_path(ifc_path) # keep IfcStore.path absolute for loading
|
||||
|
||||
|
||||
def _apply_save_file_invariants(scene: bpy.types.Scene) -> None:
|
||||
"""Invariants enforced on every load_post: msgbus subscription, IFC owner
|
||||
settings, scene-bound caches, draft-flag healing, multi-instance lock probe,
|
||||
and previews discarded so saved preview state never resurfaces on reopen."""
|
||||
settings, scene-bound caches, load-transient parametric state, and the
|
||||
multi-instance lock probe."""
|
||||
global global_subscription_owner
|
||||
active_object_key = bpy.types.LayerObjects, "active"
|
||||
bpy.msgbus.subscribe_rna(
|
||||
@@ -405,8 +469,7 @@ def _apply_save_file_invariants(scene: bpy.types.Scene) -> None:
|
||||
ifcopenshell.api.owner.settings.get_application = get_application
|
||||
AuthoringData.type_thumbnails = {}
|
||||
|
||||
tool.Parametric.heal_stale_edit_flags()
|
||||
discard_pending_previews(scene)
|
||||
tool.Parametric.on_load_post(scene)
|
||||
|
||||
if tool.Ifc.get() and bpy.data.is_saved:
|
||||
props = tool.Blender.get_bim_props()
|
||||
@@ -471,6 +534,9 @@ def _install_viewport_overlays() -> None:
|
||||
WallAxisDecorator.uninstall()
|
||||
SlabDirectionDecorator.uninstall()
|
||||
WallFilletPreviewDecorator.uninstall()
|
||||
WallGizmoPreviewDecorator.uninstall()
|
||||
ArrayPreviewDecorator.uninstall()
|
||||
ArraySelectionHighlightDecorator.uninstall()
|
||||
uninstall_decorator_cache_handlers()
|
||||
try:
|
||||
if georeference_props.should_visualise:
|
||||
@@ -489,6 +555,17 @@ def _install_viewport_overlays() -> None:
|
||||
# wall_fillet.is_active, so installation has no cost when no preview
|
||||
# is open. No corresponding addon-preference toggle.
|
||||
WallFilletPreviewDecorator.install(bpy.context)
|
||||
# Always-installed: draw_lines() self-polls on selection + hover state
|
||||
# for join / extend-to-wall / cursor-extend / cursor-split previews.
|
||||
# Free when no preview-eligible state is active.
|
||||
WallGizmoPreviewDecorator.install(bpy.context)
|
||||
# Always-installed: draw() self-polls on the active object's array
|
||||
# family membership, so installation has no cost when no array
|
||||
# element is selected.
|
||||
ArraySelectionHighlightDecorator.install(bpy.context)
|
||||
# Always-installed: draw() self-polls on props.is_editing — only
|
||||
# paints during an active array edit lifecycle.
|
||||
ArrayPreviewDecorator.install(bpy.context)
|
||||
finally:
|
||||
install_decorator_cache_handlers()
|
||||
|
||||
|
||||
@@ -552,7 +552,7 @@ class IfcStore:
|
||||
BrickStore.end_transaction()
|
||||
IfcStore.end_transaction(operator)
|
||||
bonsai.bim.handler.refresh_ui_data()
|
||||
tool.Parametric.refresh_post_commit()
|
||||
tool.Parametric.refresh_post_commit(operator)
|
||||
|
||||
if method == "MODAL":
|
||||
cls.modal_in_progress = False
|
||||
|
||||
@@ -1219,8 +1219,8 @@ class IfcImporter:
|
||||
if element not in elements_to_import:
|
||||
continue
|
||||
for i in range(len(data)):
|
||||
tool.Blender.Modifier.Array.set_children_lock_state(element, i, True)
|
||||
tool.Blender.Modifier.Array.constrain_children_to_parent(element)
|
||||
tool.Array.set_children_lock_state(element, i, True)
|
||||
tool.Array.constrain_children_to_parent(element)
|
||||
|
||||
def update_linked_aggregates(self):
|
||||
# TODO Remove this after a while. See commit 17d6b8a
|
||||
|
||||
@@ -141,6 +141,7 @@ classes = (
|
||||
gizmos.GizmoLockOpen,
|
||||
gizmos.GizmoLockClosed,
|
||||
gizmos.GizmoArc,
|
||||
gizmos.GizmoLinkToggle,
|
||||
gizmos.GizmoFillet,
|
||||
gizmos.GizmoWallCornerIcon,
|
||||
gizmos.GizmoWallTeeIcon,
|
||||
@@ -153,6 +154,7 @@ classes = (
|
||||
gizmos.GizmoArrayParent,
|
||||
gizmos.GizmoArrayAll,
|
||||
gizmos.GizmoArrayLayerIndicator,
|
||||
gizmos.GizmoCountLabel,
|
||||
gizmos.GizmoMerge,
|
||||
gizmos.GizmoSplit,
|
||||
gizmos.GizmoUnjoin,
|
||||
|
||||
@@ -35,6 +35,7 @@ __all__ = [ # noqa: RUF022 (unsorted `__all__`)
|
||||
"CoordinateSpace",
|
||||
"ModalState",
|
||||
"DimensionGizmoConfig",
|
||||
"SwingArcConfig",
|
||||
"ViewDirection",
|
||||
"GizmoModalContext",
|
||||
"get_modal_context",
|
||||
@@ -81,7 +82,7 @@ import math
|
||||
from collections.abc import Callable, Iterator
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, ClassVar, Literal, Protocol, runtime_checkable
|
||||
from typing import Any, ClassVar, Literal, Optional, Protocol, runtime_checkable
|
||||
|
||||
import blf
|
||||
import bpy
|
||||
@@ -104,16 +105,6 @@ from mathutils.kdtree import KDTree
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.drawing.shaders import ExtrusionGuidesShader
|
||||
|
||||
# Backward-compat re-exports — these mixins moved to bim.parametric_lifecycle
|
||||
# in the gizmos.py framework refactor. PR4 callers (CycleDoorType / CycleWindowType
|
||||
# / CycleStairType) still spell gizmo.CycleTypeMixin; the re-export keeps the
|
||||
# old access path alive until PR4 rewrites the import. PR5 cleanup drops these.
|
||||
from bonsai.bim.parametric_lifecycle import ( # noqa: F401, E402
|
||||
CycleTypeMixin,
|
||||
PickTypeMixin,
|
||||
TypeAccessorBase,
|
||||
)
|
||||
|
||||
SNAP_POINT_SIZE = 10.0
|
||||
SNAP_POINT_COLOR = (1.0, 0.5, 0.0, 1.0)
|
||||
SNAP_MAX_RADIUS = 50.0
|
||||
@@ -143,6 +134,16 @@ DOOR_SWING_ANGLE_MAX = 90.0
|
||||
# Default scale factor for billboarded icons (Blender-unit visual size).
|
||||
DEFAULT_BILLBOARD_SCALE = 0.5
|
||||
|
||||
# Shared gizmo color constants. Re-exported as class attributes on
|
||||
# BaseParametricGizmoGroup so callers can use either ``self.COLOR_GREEN``
|
||||
# from inside a gizmo group or the module-level constant from a class body
|
||||
# (e.g. IconSlot declarations) without a forward-reference issue. Match
|
||||
# Blender's axis convention: X=red, Y=green, Z=blue.
|
||||
COLOR_RED = (1.0, 0.2, 0.2)
|
||||
COLOR_GREEN = (0.1, 0.8, 0.1)
|
||||
COLOR_BLUE = (0.3, 0.3, 1.0)
|
||||
COLOR_NEUTRAL = (1.0, 1.0, 1.0)
|
||||
|
||||
PRECISION_MODE_MULTIPLIER = 0.1
|
||||
|
||||
RAY_CAST_DISTANCE = 1000
|
||||
@@ -1291,6 +1292,38 @@ class IconActionConfig:
|
||||
visibility_condition: Callable[[Any], bool] | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SwingArcConfig:
|
||||
"""Declarative config for one swing-arc panel — a pair of ``GizmoArc``
|
||||
instances representing a single hinged panel's two possible open sides.
|
||||
|
||||
Each entry produces two gizmos at setup time:
|
||||
- ``self.gizmo_swing_arc_<name>``: main arc on the active swing side
|
||||
- ``self.gizmo_swing_arc_<name>_flip``: Y-mirror of the main, on the
|
||||
opposite side of the hinge line
|
||||
|
||||
Both gizmos hide together when ``visibility_condition(props)`` is False.
|
||||
When visible, each arc's ``matrix_basis`` is:
|
||||
|
||||
Translation(hinge_x(props), hinge_y(props), 0)
|
||||
@ Scale(panel_width(props), 4)
|
||||
@ (Scale(-1, X) if x_mirror(props) else Identity)
|
||||
@ (Scale(-1, Y) if this is the flip arc else Identity)
|
||||
|
||||
The arc geometry (``GizmoArc.tris``) is a unit quarter-arc sweeping
|
||||
counterclockwise from +X to +Y with its hinge at the origin, so the
|
||||
transforms above translate the hinge into world position, scale to
|
||||
panel size, and mirror across the hinge line as needed.
|
||||
"""
|
||||
|
||||
name: str
|
||||
visibility_condition: Callable[[Any], bool]
|
||||
hinge_x: Callable[[Any], float]
|
||||
hinge_y: Callable[[Any], float]
|
||||
panel_width: Callable[[Any], float]
|
||||
x_mirror: Callable[[Any], bool]
|
||||
|
||||
|
||||
class SnapManager:
|
||||
"""Manages snap point visualization and mesh snapping with caching."""
|
||||
|
||||
@@ -1458,24 +1491,11 @@ class SnapManager:
|
||||
nearby_objects = []
|
||||
|
||||
for obj in mesh_objects:
|
||||
bbox_corners = [obj.matrix_world @ Vector(corner) for corner in obj.bound_box]
|
||||
if not bbox_corners:
|
||||
if not obj.bound_box:
|
||||
continue
|
||||
|
||||
bbox_min = Vector(
|
||||
(
|
||||
min(c.x for c in bbox_corners),
|
||||
min(c.y for c in bbox_corners),
|
||||
min(c.z for c in bbox_corners),
|
||||
)
|
||||
)
|
||||
bbox_max = Vector(
|
||||
(
|
||||
max(c.x for c in bbox_corners),
|
||||
max(c.y for c in bbox_corners),
|
||||
max(c.z for c in bbox_corners),
|
||||
)
|
||||
)
|
||||
bbox = tool.Blender.get_object_world_bounding_box(obj)
|
||||
bbox_min = bbox["min_point"]
|
||||
bbox_max = bbox["max_point"]
|
||||
|
||||
closest = Vector(
|
||||
(
|
||||
@@ -1682,6 +1702,33 @@ def get_screen_up(billboard_rot: Matrix) -> Vector:
|
||||
return billboard_rot @ Vector((0.0, 1.0, 0.0))
|
||||
|
||||
|
||||
# Screen-up distance lifted off floor-plane gizmo anchors in plan view. Matches
|
||||
# the inter-icon stack spacing used by wall-corner stacks so single icons and
|
||||
# stack bases sit at consistent screen-up positions when multiple groups render
|
||||
# around the same wall endpoint.
|
||||
DEFAULT_TOP_DOWN_CLEARANCE = 0.4
|
||||
|
||||
|
||||
def top_down_clearance(
|
||||
context: bpy.types.Context,
|
||||
billboard_rot: Matrix,
|
||||
distance: float = DEFAULT_TOP_DOWN_CLEARANCE,
|
||||
) -> Vector:
|
||||
"""Screen-up offset that keeps a floor-plane gizmo anchor visible in plan view.
|
||||
|
||||
In a top-down view the world-Z axis projects to ~zero on screen, so any
|
||||
icon anchored on the floor (wall endpoints, corners, connection points,
|
||||
the projected 3D cursor) sits directly on the click target it represents.
|
||||
Adding this offset before ``billboarded_at`` shifts the icon along the
|
||||
camera's up axis without changing the operator's world-space target.
|
||||
|
||||
Returns a zero vector outside the top-down cone so callers can apply it
|
||||
unconditionally."""
|
||||
if not tool.Blender.is_view_top_down(context):
|
||||
return Vector((0.0, 0.0, 0.0))
|
||||
return get_screen_up(billboard_rot) * distance
|
||||
|
||||
|
||||
# Dead-band on the screen-X delta — prevents flicker when the gizmo sits on the
|
||||
# element origin.
|
||||
EXTEND_FLIP_EPSILON = 1e-4
|
||||
@@ -3215,6 +3262,114 @@ class GizmoArc(StaticTrisGizmoMixin, bpy.types.Gizmo):
|
||||
tris = ARC_TRIS_DEFAULT
|
||||
|
||||
|
||||
def _link_toggle_icon_tris(broken: bool) -> tuple[tuple[float, float, float], ...]:
|
||||
"""Two filled dots joined by a horizontal connector. ``broken=False``
|
||||
draws a single continuous bar between the dots' inner edges;
|
||||
``broken=True`` shears the two halves vertically — the left dot AND
|
||||
its stub slip down as a unit, the right dot AND its stub slip up,
|
||||
with a horizontal gap at the centre.
|
||||
|
||||
Each half moves as a cohesive piece so the stub stays attached to its
|
||||
dot at the same y, reading as a snapped link whose two halves slid
|
||||
apart rather than as bent stubs jutting out of stationary dots."""
|
||||
dot_cx = 0.30
|
||||
dot_r = 0.10
|
||||
bar_half_thickness = 0.04
|
||||
# Inner edge of each dot — the intact bar joins the dot edges, not the
|
||||
# centres, so the dot + bar reads as one continuous shape.
|
||||
bar_inner_x = dot_cx - dot_r
|
||||
segments = 12
|
||||
# Vertical shear applied to each half when broken. Zero in the intact
|
||||
# form keeps both halves on the centerline.
|
||||
half_offset_y = 0.08 if broken else 0.0
|
||||
|
||||
tris: list[tuple[float, float, float]] = []
|
||||
for sign in (-1, 1):
|
||||
cx = sign * dot_cx
|
||||
cy = sign * half_offset_y
|
||||
for i in range(segments):
|
||||
a1 = (2.0 * math.pi) * (i / segments)
|
||||
a2 = (2.0 * math.pi) * ((i + 1) / segments)
|
||||
p1 = (cx + dot_r * math.cos(a1), cy + dot_r * math.sin(a1))
|
||||
p2 = (cx + dot_r * math.cos(a2), cy + dot_r * math.sin(a2))
|
||||
tris.append((cx, cy, 0.0))
|
||||
tris.append((p1[0], p1[1], 0.0))
|
||||
tris.append((p2[0], p2[1], 0.0))
|
||||
|
||||
if broken:
|
||||
# Stubs reach inward into the dot's interior so they read as rooted
|
||||
# in the dot rather than floating off its edge after the slip.
|
||||
stub_outer_x = 0.27
|
||||
stub_inner_x = 0.06
|
||||
tris.extend(
|
||||
rect_tris(
|
||||
-stub_outer_x,
|
||||
-half_offset_y - bar_half_thickness,
|
||||
-stub_inner_x,
|
||||
-half_offset_y + bar_half_thickness,
|
||||
)
|
||||
)
|
||||
tris.extend(
|
||||
rect_tris(
|
||||
stub_inner_x,
|
||||
half_offset_y - bar_half_thickness,
|
||||
stub_outer_x,
|
||||
half_offset_y + bar_half_thickness,
|
||||
)
|
||||
)
|
||||
else:
|
||||
tris.extend(rect_tris(-bar_inner_x, -bar_half_thickness, bar_inner_x, bar_half_thickness))
|
||||
|
||||
return tuple(tris)
|
||||
|
||||
|
||||
LINK_TRIS_INTACT = _link_toggle_icon_tris(broken=False)
|
||||
LINK_TRIS_BROKEN = _link_toggle_icon_tris(broken=True)
|
||||
|
||||
|
||||
class GizmoLinkToggle(StaticTrisGizmoMixin, bpy.types.Gizmo):
|
||||
"""Two-state link glyph: default reads as a connected link (two dots +
|
||||
intact connector); hover swaps to a broken link (same dots + severed
|
||||
connector) to signal that a click will sever the underlying connection.
|
||||
|
||||
Single-click gizmo — the target operator is bound via
|
||||
``target_set_operator`` by the owning group. The hover swap is purely
|
||||
visual; the click target is the same in both states. The glyph is
|
||||
feature-agnostic — any path / link / pair-of-connected-items context can
|
||||
reuse it for a sever-this-connection affordance."""
|
||||
|
||||
bl_idname = "VIEW3D_GT_link_toggle"
|
||||
__slots__ = ("custom_shape",)
|
||||
# Bbox source for the hit shape. The broken form's vertically-sheared
|
||||
# halves give it the larger bbox of the two states, so using it as the
|
||||
# hit-shape source guarantees the clickable area covers either form —
|
||||
# the cursor doesn't lose hover at the offset dots' outer edges.
|
||||
tris = LINK_TRIS_BROKEN
|
||||
|
||||
# Per-class batch cache: one entry per highlight state. The mixin parent
|
||||
# caches one batch per class via ``_get_static_tris_batch``; the per-state
|
||||
# swap needs a second batch, so this class keeps its own cache.
|
||||
_batch_cache: ClassVar[dict[bool, "gpu.types.GPUBatch"]] = {}
|
||||
|
||||
def draw(self, context: bpy.types.Context) -> None:
|
||||
broken = bool(self.is_highlight)
|
||||
batch = type(self)._batch_cache.get(broken)
|
||||
if batch is None:
|
||||
tris = LINK_TRIS_BROKEN if broken else LINK_TRIS_INTACT
|
||||
batch = batch_for_shader(_get_static_tris_shader(), "TRIS", {"pos": tris})
|
||||
type(self)._batch_cache[broken] = batch
|
||||
# Icon body forced fully opaque so the dark outline behind doesn't
|
||||
# bleed through and grey out the glyph.
|
||||
color = (*self.color_highlight, 1.0) if broken else (*self.color, 1.0)
|
||||
draw_tris_with_outline(
|
||||
batch,
|
||||
self.matrix_basis @ self.matrix_offset,
|
||||
color,
|
||||
self.outline_width,
|
||||
self.outline_alpha,
|
||||
)
|
||||
|
||||
|
||||
def _fillet_icon_tris() -> tuple[tuple[float, float, float], ...]:
|
||||
"""Filled L-glyph with a smoothly rounded corner — two perpendicular
|
||||
wall bars joined by a constant-thickness arc band."""
|
||||
@@ -3271,8 +3426,9 @@ class GizmoFillet(StaticTrisGizmoMixin, bpy.types.Gizmo):
|
||||
bl_idname = "VIEW3D_GT_fillet"
|
||||
__slots__ = ("custom_shape",)
|
||||
tris = FILLET_TRIS_DEFAULT
|
||||
# Stacked at ICON_STACK_OFFSET_Y above join in GizmoWallJoinIntersection;
|
||||
# full-bbox hit overlaps the sibling icons' bboxes and steals their clicks.
|
||||
# Stacked at ICON_STACK_OFFSET_Y above the join icon in the wall-join
|
||||
# gizmo group; full-bbox hit overlaps the sibling icons' bboxes and
|
||||
# steals their clicks.
|
||||
hit_uses_bbox = False
|
||||
|
||||
|
||||
@@ -3663,7 +3819,7 @@ class GizmoArrayAll(StaticTrisGizmoMixin, bpy.types.Gizmo):
|
||||
parent_element = tool.Ifc.get().by_guid(parent_guid)
|
||||
except RuntimeError:
|
||||
return
|
||||
from bonsai.bim.module.model.decorator import draw_array_layer_children_bbox
|
||||
from bonsai.bim.module.model.array import draw_array_layer_children_bbox
|
||||
|
||||
draw_array_layer_children_bbox(context, parent_element, layer_index)
|
||||
|
||||
@@ -3748,11 +3904,49 @@ class GizmoArrayLayerIndicator(bpy.types.Gizmo):
|
||||
parent_element = tool.Ifc.get_entity(obj)
|
||||
if parent_element is None:
|
||||
return
|
||||
from bonsai.bim.module.model.decorator import draw_array_layer_children_bbox
|
||||
from bonsai.bim.module.model.array import draw_array_layer_children_bbox
|
||||
|
||||
draw_array_layer_children_bbox(context, parent_element, self._layer_index)
|
||||
|
||||
|
||||
class GizmoCountLabel(bpy.types.Gizmo):
|
||||
"""``xN`` text label rendered from 7-segment digit triangles.
|
||||
|
||||
Mirrors a caller-supplied integer into a live count badge. No icon
|
||||
glyph; the gizmo is the number alone."""
|
||||
|
||||
bl_idname = "BIM_GT_count_label"
|
||||
|
||||
__slots__ = ("custom_shape", "_count", "_built_count", "_outlined_batch")
|
||||
|
||||
def setup(self) -> None:
|
||||
self._count = 0
|
||||
self._built_count = -1
|
||||
tris = _count_label_tris(self._count, 0.0, 0.0)
|
||||
self.custom_shape = self.new_custom_shape("TRIS", tris)
|
||||
self._outlined_batch = batch_for_shader(_get_static_tris_shader(), "TRIS", {"pos": tris})
|
||||
self._built_count = 0
|
||||
|
||||
def set_count(self, count: int) -> None:
|
||||
self._count = int(count)
|
||||
|
||||
def _ensure_shape(self) -> None:
|
||||
if self._built_count != self._count:
|
||||
tris = _count_label_tris(self._count, 0.0, 0.0)
|
||||
self.custom_shape = self.new_custom_shape("TRIS", tris)
|
||||
self._outlined_batch = batch_for_shader(_get_static_tris_shader(), "TRIS", {"pos": tris})
|
||||
self._built_count = self._count
|
||||
|
||||
def draw(self, context: bpy.types.Context) -> None:
|
||||
self._ensure_shape()
|
||||
color = (*self.color_highlight, 1.0) if self.is_highlight else (*self.color, 1.0)
|
||||
draw_tris_with_outline(self._outlined_batch, self.matrix_basis @ self.matrix_offset, color)
|
||||
|
||||
def draw_select(self, context: bpy.types.Context, select_id: int) -> None:
|
||||
self._ensure_shape()
|
||||
self.draw_custom_shape(self.custom_shape, select_id=select_id)
|
||||
|
||||
|
||||
class GizmoMerge(StaticTrisGizmoMixin, bpy.types.Gizmo):
|
||||
"""Two arrows pointing inward toward each other — conveys joining/merging elements."""
|
||||
|
||||
@@ -4049,7 +4243,8 @@ def _generate_menu_tris() -> tuple[tuple[float, float, float], ...]:
|
||||
class GizmoMenu(StaticTrisGizmoMixin, bpy.types.Gizmo):
|
||||
"""Hamburger-stack menu icon — 'open a picker to choose from many options'.
|
||||
|
||||
For enums with 5+ values; use ``GizmoCycle`` for 2-4."""
|
||||
For enums with 3+ values; use ``GizmoCycle`` for exactly 2 (where the
|
||||
advance-one-per-click semantic stays predictable)."""
|
||||
|
||||
bl_idname = "VIEW3D_GT_menu"
|
||||
|
||||
@@ -4816,12 +5011,130 @@ class BillboardingGizmoGroupMixin:
|
||||
"""Convenience wrapper over `setup_icon_gizmo` for subclasses."""
|
||||
return setup_icon_gizmo(self, gizmo_type, color, highlight_color, operator, alpha)
|
||||
|
||||
def get_decoration_colors(self) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
|
||||
"""Standard (default, highlight) color pair for active-state gizmos.
|
||||
Pulls from the addon preferences — same source consumed by every
|
||||
Bonsai decorator. Hover-class gizmos that should not pull focus
|
||||
should use ``get_unselected_decoration_colors`` instead."""
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
return prefs.decorations_colour[:3], prefs.decorator_color_selected[:3]
|
||||
|
||||
def get_unselected_decoration_colors(self) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
|
||||
"""Lower-priority (unselected default, highlight) pair for gizmos
|
||||
that surface on already-selected geometry and shouldn't compete
|
||||
visually with the selection outline (e.g. array-child navigation)."""
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
return prefs.decorator_color_unselected[:3], prefs.decorator_color_selected[:3]
|
||||
|
||||
def position_gizmos(self, context: bpy.types.Context) -> None:
|
||||
raise NotImplementedError(
|
||||
f"{type(self).__name__} must implement position_gizmos(context) when using BillboardingGizmoGroupMixin."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IconSlot:
|
||||
"""One slot in a parametric edit gizmo's icon toolbar row.
|
||||
|
||||
The slot's X coordinate is COMPUTED from its index in ``feature_slots`` —
|
||||
never set explicitly. Adding an icon is a one-line append; the layout
|
||||
manager resolves the X. Hidden slots STILL CONSUME their X position so
|
||||
toggling a visibility preference doesn't shift the row.
|
||||
|
||||
Fields:
|
||||
|
||||
- ``gizmo_idname`` — for single-icon slots, the full Blender gizmo
|
||||
idname. For multi-variant slots, EITHER a string PREFIX that auto-
|
||||
suffixes ``_<variant>`` per member (the common case — e.g.
|
||||
``"VIEW3D_GT_lock"`` + variants ``("open", "closed")`` becomes
|
||||
``VIEW3D_GT_lock_open`` / ``VIEW3D_GT_lock_closed``) OR a tuple of
|
||||
explicit idnames matching the variant count when the variants
|
||||
don't share a prefix. Attributes created on the gizmo group are
|
||||
``self.<name>_gizmo`` for single slots, ``self.<name>_<variant>_gizmo``
|
||||
for each variant in multi-variant slots.
|
||||
- ``variants`` — variant suffixes, e.g. ``("open", "closed")`` for a
|
||||
lock pair, ``("exterior", "center", "interior")`` for a baseline
|
||||
cycle. Empty tuple = single icon.
|
||||
- ``color`` — RGB tuple. ``None`` falls back to the gizmo group's default
|
||||
decoration color. Use the group's ``COLOR_RED`` / ``COLOR_GREEN`` /
|
||||
``COLOR_BLUE`` literals for state-coded icons.
|
||||
- ``extra_gap_before`` — extra spacing past the default uniform gap, in
|
||||
meters. Use sparingly — e.g. to visually separate a destructive
|
||||
action (trash) from the routine edit controls.
|
||||
- ``operator_props`` — tuple of (key, value) pairs forwarded to
|
||||
``target_set_operator``'s return value (e.g. ``increment=1`` for a
|
||||
+/- adjuster, ``property_name="..."`` for a generic toggle).
|
||||
- ``placeholder`` — when ``True``, the slot reserves an X position in
|
||||
the row but no auto-managed gizmo is created. Subclasses look the X
|
||||
up via ``_slot_x_positions()[name]`` to place their own dynamically-
|
||||
built gizmos (e.g. a live count label). ``gizmo_idname`` / ``operator``
|
||||
are unused for placeholders."""
|
||||
|
||||
name: str
|
||||
gizmo_idname: str | tuple[str, ...] = ""
|
||||
operator: str = ""
|
||||
# Matches DEFAULT_BILLBOARD_SCALE — the scale validate/cancel render at,
|
||||
# so slots that don't override land at the same visual size by default.
|
||||
# Helper icons (+/- count adjusters, lock pairs, delete) override with
|
||||
# smaller values (0.20 - 0.35) to signal secondary affordance.
|
||||
scale: float = DEFAULT_BILLBOARD_SCALE
|
||||
color: tuple[float, float, float] | None = None
|
||||
variants: tuple[str, ...] = ()
|
||||
extra_gap_before: float = 0.0
|
||||
operator_props: tuple[tuple[str, Any], ...] = ()
|
||||
placeholder: bool = False
|
||||
# Optional per-frame visibility predicate. Called with the gizmo group
|
||||
# instance as the sole argument; returning False hides this slot's gizmo
|
||||
# while still reserving its X position so the row layout doesn't shift.
|
||||
# Used for idle-row icons whose relevance depends on element state (e.g.
|
||||
# toggle_openings only when the host has openings).
|
||||
visible_when: Optional[Callable[[Any], bool]] = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# Validate shape at class-definition time so a typo doesn't surface
|
||||
# as a runtime error in the gizmo group's setup() three layers deep.
|
||||
if self.placeholder:
|
||||
return
|
||||
if self.variants:
|
||||
if isinstance(self.gizmo_idname, str):
|
||||
pass # prefix form — idname auto-suffixed per variant
|
||||
elif isinstance(self.gizmo_idname, tuple) and len(self.gizmo_idname) == len(self.variants):
|
||||
pass # explicit-tuple form
|
||||
else:
|
||||
raise TypeError(
|
||||
f"IconSlot({self.name!r}): variants={self.variants} requires gizmo_idname "
|
||||
f"to be either a string prefix (auto-suffixed as <prefix>_<variant>) or a "
|
||||
f"tuple of {len(self.variants)} explicit idnames, got {self.gizmo_idname!r}"
|
||||
)
|
||||
elif not isinstance(self.gizmo_idname, str) or not self.gizmo_idname:
|
||||
raise TypeError(
|
||||
f"IconSlot({self.name!r}): single-icon slot requires gizmo_idname str, "
|
||||
f"got {self.gizmo_idname!r} (set variants=(...) if you want a multi-variant "
|
||||
f"slot, or placeholder=True for a reserved-position slot)"
|
||||
)
|
||||
|
||||
def variant_idnames(self) -> tuple[str, ...]:
|
||||
"""Resolve per-variant gizmo idnames. For prefix form, suffix each
|
||||
variant onto the prefix; for tuple form, return as is. Single-icon
|
||||
slots return a one-element tuple containing the idname."""
|
||||
if not self.variants:
|
||||
assert isinstance(self.gizmo_idname, str)
|
||||
return (self.gizmo_idname,)
|
||||
if isinstance(self.gizmo_idname, str):
|
||||
return tuple(f"{self.gizmo_idname}_{variant}" for variant in self.variants)
|
||||
return self.gizmo_idname
|
||||
|
||||
def gizmo_attrs(self) -> tuple[str, ...]:
|
||||
"""Names of every ``self.*`` attribute this slot writes during setup.
|
||||
Returns one for a single slot, N for an N-variant slot, and an empty
|
||||
tuple for placeholder slots (which reserve X without an auto-gizmo)."""
|
||||
if self.placeholder:
|
||||
return ()
|
||||
if self.variants:
|
||||
return tuple(f"{self.name}_{variant}_gizmo" for variant in self.variants)
|
||||
return (f"{self.name}_gizmo",)
|
||||
|
||||
|
||||
class BaseParametricGizmoGroup:
|
||||
"""Base mixin for parametric element gizmo groups (doors, windows, stairs, etc.).
|
||||
|
||||
@@ -4897,11 +5210,13 @@ class BaseParametricGizmoGroup:
|
||||
"""
|
||||
|
||||
# === Gizmo Colors ===
|
||||
# Match Blender axis convention: X=red, Y=green, Z=blue
|
||||
COLOR_RED = (1.0, 0.2, 0.2)
|
||||
COLOR_GREEN = (0.1, 0.8, 0.1)
|
||||
COLOR_BLUE = (0.3, 0.3, 1.0)
|
||||
COLOR_NEUTRAL = (1.0, 1.0, 1.0)
|
||||
# Aliased to the module-level constants so subclass class bodies can
|
||||
# reference either spelling. Match Blender's axis convention:
|
||||
# X=red, Y=green, Z=blue.
|
||||
COLOR_RED = COLOR_RED
|
||||
COLOR_GREEN = COLOR_GREEN
|
||||
COLOR_BLUE = COLOR_BLUE
|
||||
COLOR_NEUTRAL = COLOR_NEUTRAL
|
||||
|
||||
# === Dimension Gizmo Layout (meters) ===
|
||||
ARROW_SCALE = 0.25 # Scale factor for arrow gizmos
|
||||
@@ -4920,17 +5235,21 @@ class BaseParametricGizmoGroup:
|
||||
ICON_VALIDATE_X = 0.0 # X position of validate (checkmark) icon
|
||||
ICON_CANCEL_X = 0.5 # X offset from validate for cancel (X) icon
|
||||
ICON_CYCLE_X = 0.87 # X offset from validate for cycle (arrow) icon
|
||||
# Rightmost local-X used by feature-specific icons (across both idle and
|
||||
# edit states). Subclasses override when they add icons past the cycle
|
||||
# slot at 0.87 — currently wall (rotate at 1.24) and stair (minus at
|
||||
# 1.98). Drives both the ARRAY button position (this class) AND the
|
||||
# array-layer-icons start position (``GizmoArrayEdition`` runtime lookup),
|
||||
# so non-colliding features get a tight layout while wall / stair shift
|
||||
# the array-related slots outward to avoid stomping on the rotate /
|
||||
# tread-lock / +/- icons.
|
||||
FEATURE_ICON_MAX_X: float = 0.87
|
||||
# Gap between the last feature icon and the ARRAY button (or the first
|
||||
# array layer icon in idle state).
|
||||
# Subclasses append to declare feature icons in the edit-mode toolbar row.
|
||||
# The layout manager assigns each slot an X position from its tuple
|
||||
# index — adding a new icon is a one-line append, no hardcoded X
|
||||
# constant, no "remember to bump the right edge" rule. The trailing
|
||||
# ARRAY button is positioned past the last slot automatically.
|
||||
feature_slots: ClassVar[tuple[IconSlot, ...]] = ()
|
||||
# Idle-mode pen-row extras (e.g. wall's toggle_openings). Each slot is
|
||||
# placed past the pen at uniform ``ICON_ARRAY_GAP`` spacing. Hidden during
|
||||
# edit — the validate/cancel row owns the X positions there. Peer gizmo
|
||||
# groups (e.g. ``GizmoArrayEdition``'s per-layer ARRAY icons) query
|
||||
# ``_idle_row_right_edge()`` to position past these without a hardcoded
|
||||
# per-feature table.
|
||||
idle_slots: ClassVar[tuple[IconSlot, ...]] = ()
|
||||
# Gap between adjacent slots past the leading validate/cancel/cycle
|
||||
# triplet, AND between the last slot and the ARRAY button.
|
||||
ICON_ARRAY_GAP: float = 0.37
|
||||
ICON_Z_OFFSET = 0.5 # Height above element for icons
|
||||
ICON_Y_OFFSET = GIZMO_OFFSET * 2 # Y offset to keep icons clear of geometry
|
||||
@@ -4952,6 +5271,62 @@ class BaseParametricGizmoGroup:
|
||||
super().__init_subclass__(**kwargs)
|
||||
BaseParametricGizmoGroup.REGISTRY.append(cls)
|
||||
|
||||
@classmethod
|
||||
def _slot_x_positions(cls) -> dict[str, float]:
|
||||
"""Map each ``feature_slot`` name to its X coordinate in the row.
|
||||
|
||||
Slots are laid out from the cycle position onward at uniform
|
||||
``ICON_ARRAY_GAP`` spacing, plus any per-slot ``extra_gap_before``.
|
||||
When the cycle slot is unused (no ``cycle_type_operator`` /
|
||||
``pick_type_operator``), the first feature slot collapses into the
|
||||
cycle position so the row stays tight — that's how wall's baseline
|
||||
triplet ends up at X=0.87 without a gap before it. Tuple order is
|
||||
the only thing that controls X; rearranging the tuple rearranges
|
||||
the row."""
|
||||
positions: dict[str, float] = {}
|
||||
has_cycle = bool(cls.cycle_type_operator) or bool(cls.pick_type_operator)
|
||||
next_x = (cls.ICON_CYCLE_X + cls.ICON_ARRAY_GAP) if has_cycle else cls.ICON_CYCLE_X
|
||||
for slot in cls.feature_slots:
|
||||
next_x += slot.extra_gap_before
|
||||
positions[slot.name] = next_x
|
||||
next_x += cls.ICON_ARRAY_GAP
|
||||
return positions
|
||||
|
||||
@classmethod
|
||||
def _feature_row_right_edge(cls) -> float:
|
||||
"""Right edge of the feature icon row, fed to the trailing ARRAY
|
||||
button's X. Computed strictly from slot order + gaps; empty
|
||||
``feature_slots`` collapses to the cycle position."""
|
||||
positions = cls._slot_x_positions()
|
||||
if not positions:
|
||||
return cls.ICON_CYCLE_X
|
||||
return max(positions.values())
|
||||
|
||||
@classmethod
|
||||
def _idle_slot_x_positions(cls) -> dict[str, float]:
|
||||
"""Map each ``idle_slot`` name to its X coordinate past the pen.
|
||||
|
||||
First idle slot lands at ``ICON_CANCEL_X`` (the cancel-slot position,
|
||||
unused in idle since validate/cancel are edit-only). Successive slots
|
||||
are spaced by ``ICON_ARRAY_GAP``, plus any per-slot ``extra_gap_before``."""
|
||||
positions: dict[str, float] = {}
|
||||
next_x = cls.ICON_CANCEL_X
|
||||
for slot in cls.idle_slots:
|
||||
next_x += slot.extra_gap_before
|
||||
positions[slot.name] = next_x
|
||||
next_x += cls.ICON_ARRAY_GAP
|
||||
return positions
|
||||
|
||||
@classmethod
|
||||
def _idle_row_right_edge(cls) -> float:
|
||||
"""Rightmost local-X reserved by this group's idle row. Returns the
|
||||
pen position (``ICON_VALIDATE_X``) when no idle slots are declared
|
||||
so peer queries always get a meaningful number."""
|
||||
positions = cls._idle_slot_x_positions()
|
||||
if not positions:
|
||||
return cls.ICON_VALIDATE_X
|
||||
return max(positions.values())
|
||||
|
||||
@classmethod
|
||||
def pick_visible_anchor(cls, context: bpy.types.Context, world_base: Vector, world_top: Vector) -> Vector:
|
||||
"""Choose between two anchor candidates so vertical separation stays
|
||||
@@ -5031,27 +5406,13 @@ class BaseParametricGizmoGroup:
|
||||
from_neg_y, from_neg_x = self.get_local_view_direction(context, world_matrix)
|
||||
return ViewDirection(from_negative_y=from_neg_y, from_negative_x=from_neg_x)
|
||||
|
||||
def update_gizmo_visibility(self, gizmo: bpy.types.Gizmo, is_editing: bool, pref_enabled: bool) -> bool:
|
||||
"""Update gizmo visibility based on modal state, editing state, and preference.
|
||||
|
||||
Consolidates the common pattern:
|
||||
if hidden_by_modal:
|
||||
gizmo.hide = True
|
||||
else:
|
||||
gizmo.hide = not is_editing or not pref_enabled
|
||||
|
||||
Args:
|
||||
gizmo: The gizmo to update visibility for
|
||||
is_editing: Whether the element is currently being edited
|
||||
pref_enabled: Whether this gizmo type is enabled in preferences
|
||||
|
||||
Returns:
|
||||
True if the gizmo is now visible (not hidden), False otherwise
|
||||
"""
|
||||
def update_gizmo_visibility(self, gizmo: bpy.types.Gizmo, is_editing: bool) -> bool:
|
||||
"""Hide ``gizmo`` when not editing or when a modal owns the viewport.
|
||||
Returns True if the gizmo is now visible."""
|
||||
if self.is_gizmo_hidden_by_modal(gizmo):
|
||||
gizmo.hide = True
|
||||
return False
|
||||
gizmo.hide = not is_editing or not pref_enabled
|
||||
gizmo.hide = not is_editing
|
||||
return not gizmo.hide
|
||||
|
||||
def get_y_position_for_view(
|
||||
@@ -5293,8 +5654,7 @@ class BaseParametricGizmoGroup:
|
||||
return False
|
||||
if cls.gizmo_pref_name:
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
feature_prefs = getattr(prefs.gizmos, cls.gizmo_pref_name, None)
|
||||
if feature_prefs is not None and not getattr(feature_prefs, "enabled", True):
|
||||
if not getattr(prefs.gizmos, cls.gizmo_pref_name, True):
|
||||
return False
|
||||
if len(tool.Blender.get_selected_objects()) != 1:
|
||||
return False
|
||||
@@ -5383,8 +5743,9 @@ class BaseParametricGizmoGroup:
|
||||
"""
|
||||
pass
|
||||
|
||||
# Subclass should define these class attributes for metadata-driven dispatch
|
||||
# If not defined, subclass must override get_props() and get_gizmo_prefs()
|
||||
# Subclass should define these class attributes for metadata-driven dispatch.
|
||||
# ``gizmo_pref_name`` matches a flat BoolProperty field on
|
||||
# ``GizmoPreferences`` and gates the whole gizmo group's poll.
|
||||
props_getter: Callable[[bpy.types.Object], bpy.types.PropertyGroup] | None = None
|
||||
gizmo_pref_name: str | None = None # e.g., "door"
|
||||
|
||||
@@ -5419,18 +5780,6 @@ class BaseParametricGizmoGroup:
|
||||
prefs = self.get_addon_prefs()
|
||||
return prefs.decorations_colour[:3], prefs.decorator_color_selected[:3]
|
||||
|
||||
def get_gizmo_prefs(self) -> Any:
|
||||
"""Get gizmo preferences for this element type.
|
||||
|
||||
Subclass can either:
|
||||
1. Define class attribute `gizmo_pref_name` (e.g., "door")
|
||||
2. Override this method directly
|
||||
"""
|
||||
if self.gizmo_pref_name:
|
||||
prefs = self.get_addon_prefs()
|
||||
return getattr(prefs.gizmos, self.gizmo_pref_name)
|
||||
raise NotImplementedError("Subclass must define gizmo_pref_name or override get_gizmo_prefs()")
|
||||
|
||||
def is_setup_complete(self) -> bool:
|
||||
"""Check if gizmo setup has been completed.
|
||||
|
||||
@@ -5654,6 +6003,34 @@ class BaseParametricGizmoGroup:
|
||||
"VIEW3D_GT_menu", default_color, self.pick_type_operator, highlight_color
|
||||
)
|
||||
|
||||
# Feature-specific edit-row icons. Subclasses declare them via
|
||||
# ``feature_slots``; multi-variant slots create one gizmo per
|
||||
# variant at the same X (e.g. a lock pair, a baseline triplet) and
|
||||
# the subclass picks which is visible per frame. Placeholder slots
|
||||
# only reserve an X position — the subclass creates its own gizmo
|
||||
# there in ``setup_element_specific_gizmos``.
|
||||
for slot in self.feature_slots:
|
||||
if slot.placeholder:
|
||||
continue
|
||||
slot_color = slot.color if slot.color is not None else default_color
|
||||
kwargs = dict(slot.operator_props)
|
||||
for attr, idname in zip(slot.gizmo_attrs(), slot.variant_idnames()):
|
||||
gz = self.create_icon_gizmo(idname, slot_color, slot.operator, **kwargs)
|
||||
setattr(self, attr, gz)
|
||||
|
||||
# Idle-mode pen-row extras. Same creation path as feature_slots; the
|
||||
# IDLE branch of ``update_editing_gizmos`` positions and visibility-
|
||||
# gates them, the EDIT branch hides them so the validate/cancel row
|
||||
# owns the X positions.
|
||||
for slot in self.idle_slots:
|
||||
if slot.placeholder:
|
||||
continue
|
||||
slot_color = slot.color if slot.color is not None else default_color
|
||||
kwargs = dict(slot.operator_props)
|
||||
for attr, idname in zip(slot.gizmo_attrs(), slot.variant_idnames()):
|
||||
gz = self.create_icon_gizmo(idname, slot_color, slot.operator, **kwargs)
|
||||
setattr(self, attr, gz)
|
||||
|
||||
# ARRAY button — visible during the feature edit lifecycle only (positioned by
|
||||
# ``update_editing_gizmos``). Click commits the current edit and adds a
|
||||
# Blender-vanilla-defaulted array (count=2, X-offset = bbox extent). The
|
||||
@@ -5916,14 +6293,65 @@ class BaseParametricGizmoGroup:
|
||||
billboard_rot=billboard_rot,
|
||||
scale=0.30,
|
||||
)
|
||||
# Array gizmo integration is in-progress: the icon binds to
|
||||
# bim.add_array_from_feature_edit but the array-from-parametric-draft
|
||||
# operator + per-feature gizmo positioning haven't fully landed.
|
||||
# Force-hide the icon while parametric-item editing is active to
|
||||
# keep the user from triggering a half-wired add-array flow. Drop
|
||||
# this gate when array integration completes.
|
||||
# Feature slots: per-class IconSlot tuples driven by tuple order.
|
||||
# Whole-feature visibility is gated upstream by ``poll()`` against
|
||||
# ``prefs.gizmos.<feature>``; positioning happens unconditionally
|
||||
# whenever the gizmo group polls visible.
|
||||
slot_positions = self._slot_x_positions()
|
||||
for slot in self.feature_slots:
|
||||
if slot.placeholder:
|
||||
continue
|
||||
slot_x = self.ICON_VALIDATE_X + slot_positions[slot.name]
|
||||
attrs = slot.gizmo_attrs()
|
||||
if slot.variants:
|
||||
# Multi-variant slot: write matrix on every variant member
|
||||
# at the same anchor so a state flip never reveals a stale
|
||||
# pose. The subclass's per-frame hook picks which member
|
||||
# is visible — this loop doesn't toggle hide flags.
|
||||
world_pos = mw @ Vector((slot_x, icon_y, icon_z))
|
||||
matrix = billboarded_at(world_pos, billboard_rot, scale=slot.scale)
|
||||
for attr in attrs:
|
||||
gz = getattr(self, attr, None)
|
||||
if gz is not None:
|
||||
gz.matrix_basis = matrix
|
||||
continue
|
||||
gz = getattr(self, attrs[0], None)
|
||||
if gz is None:
|
||||
continue
|
||||
gz.hide = self.is_gizmo_hidden_by_modal(gz)
|
||||
self.set_icon_gizmo_position(
|
||||
attrs[0],
|
||||
mw=mw,
|
||||
x=slot_x,
|
||||
y=icon_y,
|
||||
z=icon_z,
|
||||
billboard_rot=billboard_rot,
|
||||
scale=slot.scale,
|
||||
)
|
||||
# ARRAY button sits past the last feature-specific icon. Slot-based
|
||||
# subclasses derive the right edge from the slot count.
|
||||
if hasattr(self, "array_gizmo"):
|
||||
self.array_gizmo.hide = True
|
||||
self.array_gizmo.hide = self.is_gizmo_hidden_by_modal(self.array_gizmo)
|
||||
# 30% smaller than the editing-icon-row default (0.50 → 0.35):
|
||||
# the array button is a tertiary affordance compared to the
|
||||
# primary pen / validate / cancel triad, and the smaller
|
||||
# footprint keeps the edit-mode row from sprawling.
|
||||
self.set_icon_gizmo_position(
|
||||
"array_gizmo",
|
||||
mw=mw,
|
||||
x=self.ICON_VALIDATE_X + self._feature_row_right_edge() + self.ICON_ARRAY_GAP,
|
||||
y=icon_y,
|
||||
z=icon_z,
|
||||
billboard_rot=billboard_rot,
|
||||
scale=0.35,
|
||||
)
|
||||
# Idle-row icons are hidden in edit — validate / cancel sit at
|
||||
# the same X positions, so showing both would stack icons.
|
||||
for slot in self.idle_slots:
|
||||
for attr in slot.gizmo_attrs():
|
||||
gz = getattr(self, attr, None)
|
||||
if gz is not None:
|
||||
gz.hide = True
|
||||
else:
|
||||
# ``hide_pen_button = True`` keeps the pen permanently hidden — for
|
||||
# groups whose edit-mode entry is already provided by another widget
|
||||
@@ -5942,8 +6370,41 @@ class BaseParametricGizmoGroup:
|
||||
self.cancel_gizmo.hide = True
|
||||
if self.cycle_type_operator or self.pick_type_operator:
|
||||
self.cycle_gizmo.hide = True
|
||||
for slot in self.feature_slots:
|
||||
for attr in slot.gizmo_attrs():
|
||||
gz = getattr(self, attr, None)
|
||||
if gz is not None:
|
||||
gz.hide = True
|
||||
if hasattr(self, "array_gizmo"):
|
||||
self.array_gizmo.hide = True
|
||||
# Idle slots: position past the pen, apply per-slot visible_when
|
||||
# so state-dependent icons (e.g. toggle_openings) only render
|
||||
# when relevant. Hidden slots STILL consume their X position so
|
||||
# the row layout doesn't shift when state flips.
|
||||
idle_positions = self._idle_slot_x_positions()
|
||||
for slot in self.idle_slots:
|
||||
if slot.placeholder:
|
||||
continue
|
||||
slot_x = self.ICON_VALIDATE_X + idle_positions[slot.name]
|
||||
gate = slot.visible_when
|
||||
visible = True if gate is None else bool(gate(self))
|
||||
for attr in slot.gizmo_attrs():
|
||||
gz = getattr(self, attr, None)
|
||||
if gz is None:
|
||||
continue
|
||||
if not visible:
|
||||
gz.hide = True
|
||||
continue
|
||||
gz.hide = self.is_gizmo_hidden_by_modal(gz)
|
||||
self.set_icon_gizmo_position(
|
||||
attr,
|
||||
mw=mw,
|
||||
x=slot_x,
|
||||
y=icon_y,
|
||||
z=icon_z,
|
||||
billboard_rot=billboard_rot,
|
||||
scale=slot.scale,
|
||||
)
|
||||
|
||||
def draw_prepare(self, context: bpy.types.Context) -> None:
|
||||
"""Called before drawing - updates gizmos to face camera.
|
||||
|
||||
@@ -80,9 +80,9 @@ class ViewportData:
|
||||
modes.append(edit_mode)
|
||||
elif element.is_a("IfcGridAxis"):
|
||||
modes.append(edit_mode)
|
||||
elif tool.Blender.Modifier.is_roof(element):
|
||||
elif tool.Parametric.is_roof(element):
|
||||
modes.append(edit_mode)
|
||||
elif tool.Blender.Modifier.is_railing(element):
|
||||
elif tool.Parametric.is_railing(element):
|
||||
modes.append(edit_mode)
|
||||
elif item_mode not in modes:
|
||||
modes.append(item_mode)
|
||||
|
||||
@@ -1027,10 +1027,10 @@ class OverrideDelete(bpy.types.Operator):
|
||||
|
||||
for array_parent in array_parents:
|
||||
array_parent_obj = tool.Ifc.get_object(array_parent)
|
||||
data = [(i, data) for i, data in enumerate(tool.Blender.Modifier.Array.get_modifiers_data(array_parent))]
|
||||
data = [(i, data) for i, data in enumerate(tool.Array.get_modifiers_data(array_parent))]
|
||||
# NOTE: there is a way to remove arrays more precisely but it's more complex
|
||||
for i, modifier_data in reversed(data):
|
||||
children = set(tool.Blender.Modifier.Array.get_children_objects(modifier_data))
|
||||
children = set(tool.Array.get_children_objects(modifier_data))
|
||||
if children.issubset(selected_objects):
|
||||
with context.temp_override(active_object=array_parent_obj):
|
||||
bpy.ops.bim.remove_array(item=i)
|
||||
@@ -1289,9 +1289,7 @@ class OverrideDuplicateMove(bpy.types.Operator):
|
||||
all_objects_to_select.add(part_obj)
|
||||
|
||||
# Non-IFC duplicates aren't tracked in old_to_new but are left selected by duplicate_ifc_objects
|
||||
all_objects_to_select.update(
|
||||
obj for obj in context.selected_objects if not tool.Ifc.get_entity(obj)
|
||||
)
|
||||
all_objects_to_select.update(obj for obj in context.selected_objects if not tool.Ifc.get_entity(obj))
|
||||
|
||||
# Deselect everything first
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
@@ -2497,9 +2495,9 @@ class OverrideModeSetObject(bpy.types.Operator, tool.Ifc.Operator):
|
||||
profile = tool.Ifc.get().by_id(profile_id)
|
||||
if tool.Ifc.get_object(profile): # We are editing an arbitrary profile
|
||||
bpy.ops.bim.edit_arbitrary_profile()
|
||||
elif tool.Blender.Modifier.is_railing(element):
|
||||
elif tool.Parametric.is_railing(element):
|
||||
bpy.ops.bim.finish_editing_railing_path()
|
||||
elif tool.Blender.Modifier.is_roof(element):
|
||||
elif tool.Parametric.is_roof(element):
|
||||
bpy.ops.bim.finish_editing_roof_path()
|
||||
elif tool.Model.get_usage_type(element) == "PROFILE":
|
||||
bpy.ops.bim.edit_extrusion_axis()
|
||||
|
||||
@@ -31,6 +31,7 @@ from . import (
|
||||
external,
|
||||
grid,
|
||||
handler,
|
||||
host_add_opening_gizmo,
|
||||
mep,
|
||||
opening,
|
||||
product,
|
||||
@@ -50,19 +51,28 @@ from . import (
|
||||
|
||||
classes = (
|
||||
array.AddArray,
|
||||
array.DisableEditingArray,
|
||||
array.EditArray,
|
||||
array.CancelEditingArray,
|
||||
array.EnableEditingArray,
|
||||
array.FinishEditingArray,
|
||||
array.ApplyArray,
|
||||
array.RegenerateArray,
|
||||
array.RemoveArray,
|
||||
array.SelectAllArrayObjects,
|
||||
array.SelectArrayParent,
|
||||
array.ArrayParentGizmoClick,
|
||||
array.EditArrayFromChild,
|
||||
array.Input3DCursorXArray,
|
||||
array.Input3DCursorYArray,
|
||||
array.Input3DCursorZArray,
|
||||
array.EnableEditingParametric,
|
||||
array.AddArrayFromFeatureEdit,
|
||||
array.ArrayGizmoClick,
|
||||
array.ToggleArrayMethod,
|
||||
array.RemoveArrayLayerFromEdit,
|
||||
array.InputArrayCount,
|
||||
array.AdjustArrayCount,
|
||||
array.GizmoArrayEdition,
|
||||
array.GizmoArrayChild,
|
||||
product.AddDefaultType,
|
||||
product.AddEmptyType,
|
||||
product.AddOccurrence,
|
||||
@@ -91,12 +101,15 @@ classes = (
|
||||
wall.ExtendWallToCursor,
|
||||
wall.FinishEditingWall,
|
||||
wall.FlipWall,
|
||||
wall.GizmoWallAddOpening,
|
||||
host_add_opening_gizmo.GizmoHostAddOpening,
|
||||
host_add_opening_gizmo.GizmoHostToggleOpenings,
|
||||
wall.GizmoWallEdition,
|
||||
wall.GizmoWallExtendVertically,
|
||||
wall.GizmoWallFilletPreview,
|
||||
wall.GizmoWallFilletReedit,
|
||||
wall.GizmoWallFilletToggleOpenings,
|
||||
wall.GizmoWallJoinIntersection,
|
||||
wall.GizmoWallLinkToggle,
|
||||
wall.GizmoWallUnjoinSingle,
|
||||
wall.JoinWallsIntersection,
|
||||
wall.MergeWall,
|
||||
@@ -105,7 +118,6 @@ classes = (
|
||||
wall.RotateWall90,
|
||||
wall.SplitWall,
|
||||
wall.SplitWallAtCursor,
|
||||
wall.ToggleWallOpenings,
|
||||
wall.UnjoinWallPathConnection,
|
||||
wall.UnjoinWalls,
|
||||
wall.EnableWallFilletPreview,
|
||||
@@ -124,6 +136,7 @@ classes = (
|
||||
opening.RemoveBoolean,
|
||||
opening.SelectBoolean,
|
||||
opening.ShowOpenings,
|
||||
opening.ToggleHostOpenings,
|
||||
opening.UpdateOpeningsFocus,
|
||||
profile.ChangeCardinalPoint,
|
||||
profile.ChangeProfileDepth,
|
||||
@@ -198,7 +211,8 @@ classes = (
|
||||
stair.ToggleStairProperty,
|
||||
stair.AdjustStairTreads,
|
||||
stair.SetStairTreads,
|
||||
stair.CycleStairType,
|
||||
stair.InputStairTreads,
|
||||
stair.PickStairType,
|
||||
stair.GizmoStairEdition,
|
||||
sverchok_modifier.CreateNewSverchokGraph,
|
||||
sverchok_modifier.UpdateDataFromSverchok,
|
||||
@@ -211,7 +225,7 @@ classes = (
|
||||
window.FinishEditingWindow,
|
||||
window.EnableEditingWindow,
|
||||
window.RemoveWindow,
|
||||
window.CycleWindowType,
|
||||
window.PickWindowType,
|
||||
window.GizmoWindowEdition,
|
||||
door.BIM_OT_add_door,
|
||||
door.AddDoor,
|
||||
@@ -220,7 +234,7 @@ classes = (
|
||||
door.EnableEditingDoor,
|
||||
door.RemoveDoor,
|
||||
door.ToggleDoorSwing,
|
||||
door.CycleDoorType,
|
||||
door.PickDoorType,
|
||||
door.GizmoDoorEdition,
|
||||
railing.BIM_OT_add_railing,
|
||||
railing.CopyRailingParameters,
|
||||
@@ -237,11 +251,13 @@ classes = (
|
||||
roof.AddRoof,
|
||||
roof.CancelEditingRoof,
|
||||
roof.CopyRoofParameters,
|
||||
roof.CycleRoofGenerationMethod,
|
||||
roof.FinishEditingRoof,
|
||||
roof.EnableEditingRoof,
|
||||
roof.CancelEditingRoofPath,
|
||||
roof.FinishEditingRoofPath,
|
||||
roof.EnableEditingRoofPath,
|
||||
roof.GizmoRoofEdition,
|
||||
roof.RemoveRoof,
|
||||
roof.SetGableRoofEdgeAngle,
|
||||
mep.MEPAddObstruction,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2067,6 +2067,47 @@ def _stroke_lines_alpha(
|
||||
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.
|
||||
|
||||
@@ -2177,3 +2218,59 @@ class WallFilletPreviewDecorator(tool.Blender.ViewportDecorator):
|
||||
d1 = (p1.x - intersection[0]) ** 2 + (p1.y - intersection[1]) ** 2 + (p1.z - intersection[2]) ** 2
|
||||
d2 = (p2.x - intersection[0]) ** 2 + (p2.y - intersection[1]) ** 2 + (p2.z - intersection[2]) ** 2
|
||||
return p2 if d2 >= d1 else p1
|
||||
|
||||
|
||||
_BBOX_EDGES = (
|
||||
(0, 1), (1, 2), (2, 3), (3, 0),
|
||||
(4, 5), (5, 6), (6, 7), (7, 4),
|
||||
(0, 4), (1, 5), (2, 6), (3, 7),
|
||||
) # fmt: skip
|
||||
|
||||
|
||||
def bbox_world_edges(
|
||||
obj: bpy.types.Object,
|
||||
) -> list[tuple[tuple[float, float, float], tuple[float, float, float]]]:
|
||||
"""Return world-space (start, end) tuples for the 12 edges of ``obj``'s
|
||||
bounding box. Empty list if the object has no bound_box (e.g. Empties)."""
|
||||
if not obj.bound_box:
|
||||
return []
|
||||
mw = obj.matrix_world
|
||||
corners = [mw @ Vector(c) for c in obj.bound_box]
|
||||
return [(tuple(corners[a]), tuple(corners[b])) for a, b in _BBOX_EDGES]
|
||||
|
||||
|
||||
def draw_polyline_segments(
|
||||
context: bpy.types.Context,
|
||||
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]],
|
||||
color_rgb: tuple[float, float, float],
|
||||
alpha: float,
|
||||
line_width: float,
|
||||
) -> None:
|
||||
"""Render ``segments`` as one anti-aliased LINES batch in world space."""
|
||||
if not segments:
|
||||
return
|
||||
verts: list[tuple[float, float, float]] = []
|
||||
indices: list[tuple[int, int]] = []
|
||||
for start, end in segments:
|
||||
base = len(verts)
|
||||
verts.append(start)
|
||||
verts.append(end)
|
||||
indices.append((base, base + 1))
|
||||
if not tool.Blender.validate_shader_batch_data(verts, indices):
|
||||
return
|
||||
region = getattr(context, "region", None)
|
||||
if region is None:
|
||||
return
|
||||
shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR")
|
||||
shader.bind()
|
||||
shader.uniform_float("viewportSize", (region.width, region.height))
|
||||
shader.uniform_float("lineWidth", line_width)
|
||||
shader.uniform_float("color", (*color_rgb, alpha))
|
||||
batch = batch_for_shader(shader, "LINES", {"pos": verts}, indices=indices)
|
||||
gpu.state.blend_set("ALPHA")
|
||||
batch.draw(shader)
|
||||
gpu.state.blend_set("NONE")
|
||||
|
||||
|
||||
_BBOX_HIGHLIGHT_LINE_WIDTH = 1.8
|
||||
_BBOX_HIGHLIGHT_LINE_ALPHA = 0.8
|
||||
|
||||
@@ -37,8 +37,9 @@ import bonsai.core.root
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.drawing import gizmos as gizmo
|
||||
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
|
||||
from bonsai.bim.module.model.wall_offset_gizmos import WALL_OFFSET_GIZMO_CONFIGS
|
||||
from bonsai.bim.module.model.window import create_bm_box, create_bm_window
|
||||
from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin
|
||||
from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin, PickTypeMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bonsai.bim.module.model.prop import BIMDoorProperties
|
||||
@@ -580,7 +581,7 @@ class _DoorEditMixin(FeatureModifierEditMixin):
|
||||
|
||||
@classmethod
|
||||
def _is_element_type(cls, element):
|
||||
return tool.Blender.Modifier.is_door(element)
|
||||
return tool.Parametric.is_door(element)
|
||||
|
||||
@classmethod
|
||||
def _get_props(cls, obj: bpy.types.Object):
|
||||
@@ -629,7 +630,7 @@ class RemoveDoor(bpy.types.Operator, tool.Ifc.Operator):
|
||||
def remove_door_on_object(self, obj: bpy.types.Object) -> None:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
assert element
|
||||
if not tool.Blender.Modifier.is_door(element):
|
||||
if not tool.Parametric.is_door(element):
|
||||
return
|
||||
props = tool.Model.get_door_props(obj)
|
||||
props.is_editing = False
|
||||
@@ -644,12 +645,8 @@ class RemoveDoor(bpy.types.Operator, tool.Ifc.Operator):
|
||||
|
||||
|
||||
class ToggleDoorSwing(bpy.types.Operator, tool.Ifc.Operator):
|
||||
"""Toggle door swing direction and optionally flip door geometry.
|
||||
|
||||
Shift+Click (when flip_geometry=True): Flip geometry only without changing door direction"""
|
||||
|
||||
bl_idname = "bim.toggle_door_swing"
|
||||
bl_label = "Toggle Door Swing"
|
||||
bl_label = "Change Door Swing"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
flip_geometry: bpy.props.BoolProperty(name="Flip Geometry", default=False)
|
||||
@@ -660,6 +657,15 @@ class ToggleDoorSwing(bpy.types.Operator, tool.Ifc.Operator):
|
||||
name="Skip Direction Change", default=False, options={"HIDDEN", "SKIP_SAVE"}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def description(cls, context: bpy.types.Context, properties: bpy.types.OperatorProperties) -> str:
|
||||
if properties.flip_geometry:
|
||||
return (
|
||||
"Swing the door from the opposite side of the wall. "
|
||||
"Shift+click: mirror the door without changing which side it opens to"
|
||||
)
|
||||
return "Move the door hinge to the opposite side"
|
||||
|
||||
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]:
|
||||
self.skip_direction_change = event.shift
|
||||
return self.execute(context)
|
||||
@@ -686,7 +692,7 @@ class ToggleDoorSwing(bpy.types.Operator, tool.Ifc.Operator):
|
||||
if not element:
|
||||
return {"CANCELLED"}
|
||||
|
||||
is_door = tool.Blender.Modifier.is_door(element)
|
||||
is_door = tool.Parametric.is_door(element)
|
||||
|
||||
if self.flip_geometry:
|
||||
tool.Geometry.flip_object(obj, self.flip_local_axes)
|
||||
@@ -700,11 +706,11 @@ class ToggleDoorSwing(bpy.types.Operator, tool.Ifc.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class CycleDoorType(bpy.types.Operator, tool.Ifc.Operator, gizmo.CycleTypeMixin):
|
||||
"""Cycle through available door types. Shift+click to cycle in reverse."""
|
||||
class PickDoorType(bpy.types.Operator, tool.Ifc.Operator, PickTypeMixin):
|
||||
"""Pick a door type from a popup menu."""
|
||||
|
||||
bl_idname = "bim.cycle_door_type"
|
||||
bl_label = "Cycle Door Type"
|
||||
bl_idname = "bim.pick_door_type"
|
||||
bl_label = "Pick Door Type"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
element_checker = tool.Parametric.is_door
|
||||
@@ -713,7 +719,7 @@ class CycleDoorType(bpy.types.Operator, tool.Ifc.Operator, gizmo.CycleTypeMixin)
|
||||
type_attr = "door_type"
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
return self._cycle_type(context)
|
||||
return self._pick_type(context)
|
||||
|
||||
|
||||
class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
@@ -726,7 +732,7 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
enable_editing_operator = "bim.enable_editing_door"
|
||||
finish_editing_operator = "bim.finish_editing_door"
|
||||
cancel_editing_operator = "bim.cancel_editing_door"
|
||||
cycle_type_operator = "bim.cycle_door_type"
|
||||
pick_type_operator = "bim.pick_door_type"
|
||||
|
||||
# Declarative dimension gizmo configuration with visibility and position
|
||||
# matrix_position lambdas replace the get_dimension_matrix_* methods
|
||||
@@ -833,6 +839,36 @@ 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
|
||||
@@ -840,7 +876,7 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
|
||||
@classmethod
|
||||
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
return tool.Blender.Modifier.is_door(element)
|
||||
return tool.Parametric.is_door(element)
|
||||
|
||||
def get_icon_y_extent(self, props: "BIMDoorProperties") -> tuple[float, float]:
|
||||
"""Get Y extents for door icon positioning.
|
||||
@@ -858,22 +894,20 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
return (furthest_y, furthest_y)
|
||||
|
||||
def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None:
|
||||
"""Create door-specific swing arc gizmos."""
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
inactive_color = prefs.decorator_color_background[:3]
|
||||
special_color = prefs.decorator_color_special[:3]
|
||||
"""Create one (main, flip) swing-arc pair per ``swing_arc_props`` entry.
|
||||
|
||||
self.gizmo_door_type = self.create_arc_gizmo(
|
||||
special_color,
|
||||
"bim.toggle_door_swing",
|
||||
flip_geometry=False,
|
||||
)
|
||||
self.gizmo_flip_arc = self.create_arc_gizmo(
|
||||
inactive_color,
|
||||
"bim.toggle_door_swing",
|
||||
flip_geometry=True,
|
||||
flip_local_axes="XY",
|
||||
)
|
||||
Stored as ``self.gizmo_swing_arc_<name>`` and ``self.gizmo_swing_arc_<name>_flip``
|
||||
and pinned to ``SWING_ARC_SELECT_BIAS`` so other door gizmos win selection."""
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
main_color = prefs.decorator_color_special[:3]
|
||||
flip_color = prefs.decorator_color_background[:3]
|
||||
for cfg in self.swing_arc_props:
|
||||
main = self.create_arc_gizmo(main_color, self.swing_arc_operator, flip_geometry=False)
|
||||
flip = self.create_arc_gizmo(flip_color, self.swing_arc_operator, flip_geometry=True)
|
||||
for gz in (main, flip):
|
||||
gz.select_bias = self.SWING_ARC_SELECT_BIAS
|
||||
setattr(self, f"gizmo_swing_arc_{cfg.name}", main)
|
||||
setattr(self, f"gizmo_swing_arc_{cfg.name}_flip", flip)
|
||||
|
||||
def _refresh_element_specific(
|
||||
self, context: bpy.types.Context, mw: Matrix, props: "BIMDoorProperties" # noqa: ARG002
|
||||
@@ -892,29 +926,23 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
self._update_view_dependent_dimensions(context, mw, props)
|
||||
|
||||
def update_swing_gizmos(self, mw: Matrix, props: "BIMDoorProperties") -> None:
|
||||
"""Update swing gizmo position and color based on editing state."""
|
||||
prefs = self.get_addon_prefs()
|
||||
door_gizmo_prefs = prefs.gizmos.door
|
||||
|
||||
door_type_visible = self.update_gizmo_visibility(
|
||||
self.gizmo_door_type, props.is_editing, door_gizmo_prefs.swing_arc
|
||||
)
|
||||
flip_arc_visible = self.update_gizmo_visibility(
|
||||
self.gizmo_flip_arc, props.is_editing, door_gizmo_prefs.flip_arc
|
||||
)
|
||||
|
||||
if not door_type_visible and not flip_arc_visible:
|
||||
return
|
||||
|
||||
swing_x_offset = props.overall_width if "RIGHT" in props.door_type else 0.0
|
||||
base_swing_transform = Matrix.Translation(V_(swing_x_offset, props.lining_offset, 0)) @ Matrix.Scale(
|
||||
props.overall_width, 4
|
||||
)
|
||||
|
||||
if door_type_visible:
|
||||
self.gizmo_door_type.matrix_basis = mw @ base_swing_transform
|
||||
self.gizmo_door_type.color = prefs.decorations_colour[:3]
|
||||
|
||||
if flip_arc_visible:
|
||||
mirror_y = Matrix.Scale(-1, 4, (0, 1, 0))
|
||||
self.gizmo_flip_arc.matrix_basis = mw @ base_swing_transform @ mirror_y
|
||||
"""Position each declared swing-arc pair per its config + props state."""
|
||||
mirror_y = Matrix.Scale(-1, 4, (0, 1, 0))
|
||||
for cfg in self.swing_arc_props:
|
||||
main = getattr(self, f"gizmo_swing_arc_{cfg.name}")
|
||||
flip = getattr(self, f"gizmo_swing_arc_{cfg.name}_flip")
|
||||
show = cfg.visibility_condition(props)
|
||||
main_visible = self.update_gizmo_visibility(main, show)
|
||||
flip_visible = self.update_gizmo_visibility(flip, show)
|
||||
if not (main_visible or flip_visible):
|
||||
continue
|
||||
x_flip = Matrix.Scale(-1, 4, (1, 0, 0)) if cfg.x_mirror(props) else Matrix.Identity(4)
|
||||
transform = (
|
||||
Matrix.Translation(V_(cfg.hinge_x(props), cfg.hinge_y(props), 0))
|
||||
@ Matrix.Scale(cfg.panel_width(props), 4)
|
||||
@ x_flip
|
||||
)
|
||||
if main_visible:
|
||||
main.matrix_basis = mw @ transform
|
||||
if flip_visible:
|
||||
flip.matrix_basis = mw @ transform @ mirror_y
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Generic single-click "Add Opening" gizmo for hosts (walls, slabs, roofs).
|
||||
|
||||
One GizmoGroup serves every IFC host type that exposes ``HasOpenings``:
|
||||
parametric LAYER2 walls, any ``IfcSlab``, and any ``IfcRoof``. The poll
|
||||
guards host-host pairings so this gizmo never overlaps with the existing
|
||||
wall-join / extend-vertically gizmos. The positioner dispatches on element
|
||||
type — walls use axis-projection + camera-facing-Y math (which requires the
|
||||
parametric layer-set); slabs and roofs use a world-Z face bias driven by
|
||||
the void object's elevation against the host's bounding box."""
|
||||
|
||||
import bpy
|
||||
from mathutils import Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.drawing import gizmos as gizmo
|
||||
from bonsai.bim.module.model.wall import (
|
||||
_get_wall_geom_cached,
|
||||
_wall_camera_facing_icon_y,
|
||||
_wall_gizmo_poll_gate,
|
||||
_WallGeomCachedBillboardingMixin,
|
||||
)
|
||||
|
||||
|
||||
def is_supported_host(element) -> bool:
|
||||
"""Total predicate (None → False). Walls accept either a parametric
|
||||
LAYER2 wall OR a fillet-corner wall (both expose a usable axis +
|
||||
layer-set for the anchor math); slabs and roofs only need the bound
|
||||
box so any IfcSlab / IfcRoof qualifies regardless of parametric
|
||||
modifier state."""
|
||||
if element is None:
|
||||
return False
|
||||
return tool.Parametric.is_path_connectable_wall(element) or element.is_a("IfcSlab") or element.is_a("IfcRoof")
|
||||
|
||||
|
||||
def _resolve_active_host(context: bpy.types.Context, n_selected: int):
|
||||
"""Shared poll prologue: gizmo gate + selection cardinality + active-in-
|
||||
selected + IFC entity lookup + supported-host predicate. Returns the
|
||||
active element on success, ``None`` on any failure — callers chain their
|
||||
feature-specific checks past the early-return."""
|
||||
if not _wall_gizmo_poll_gate(context):
|
||||
return None
|
||||
selected = tool.Blender.get_selected_objects()
|
||||
if len(selected) != n_selected:
|
||||
return None
|
||||
active = context.active_object
|
||||
if active is None or active not in selected:
|
||||
return None
|
||||
element = tool.Ifc.get_entity(active)
|
||||
if not element or not is_supported_host(element):
|
||||
return None
|
||||
return element
|
||||
|
||||
|
||||
class GizmoHostAddOpening(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin):
|
||||
"""Activates when a host element (wall / slab / roof) is the active object
|
||||
and exactly one other selected object is *not* itself a host.
|
||||
|
||||
Renders a single ``VIEW3D_GT_add_opening`` icon at the void object's
|
||||
projected location on the host. A click dispatches ``bim.add_opening``,
|
||||
which handles any element exposing the ``HasOpenings`` inverse.
|
||||
|
||||
Per-frame positioning keeps the icon facing the camera as the viewport
|
||||
orbits."""
|
||||
|
||||
bl_idname = "OBJECT_GGT_bim_host_add_opening"
|
||||
bl_label = "Host Add Opening Gizmo"
|
||||
bl_space_type = "VIEW_3D"
|
||||
bl_region_type = "WINDOW"
|
||||
bl_options = {"3D", "PERSISTENT"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: bpy.types.Context) -> bool:
|
||||
element = _resolve_active_host(context, n_selected=2)
|
||||
if element is None:
|
||||
return False
|
||||
# The operator itself filters on HasOpenings, but checking here keeps
|
||||
# the icon from appearing on host classes that can't accept openings
|
||||
# in the active IFC schema.
|
||||
if not hasattr(element, "HasOpenings"):
|
||||
return False
|
||||
active = context.active_object
|
||||
other = next(o for o in tool.Blender.get_selected_objects() if o is not active)
|
||||
# Host + host pairings are claimed by host-specific gizmos (wall-join,
|
||||
# extend-vertical, …) — suppress here so the add-opening icon never
|
||||
# stacks on top of them.
|
||||
if is_supported_host(tool.Ifc.get_entity(other)):
|
||||
return False
|
||||
return True
|
||||
|
||||
def setup(self, context: bpy.types.Context) -> None:
|
||||
default_color, highlight_color = self.get_decoration_colors()
|
||||
self.add_opening_icon = self.setup_icon_gizmo(
|
||||
"VIEW3D_GT_add_opening", default_color, highlight_color, "bim.add_opening"
|
||||
)
|
||||
|
||||
def position_gizmos(self, context: bpy.types.Context) -> None:
|
||||
host_obj = context.active_object
|
||||
if not host_obj:
|
||||
return
|
||||
selected = tool.Blender.get_selected_objects()
|
||||
other = next((o for o in selected if o is not host_obj), None)
|
||||
if not other:
|
||||
return
|
||||
element = tool.Ifc.get_entity(host_obj)
|
||||
if not element:
|
||||
return
|
||||
|
||||
if tool.Parametric.is_path_connectable_wall(element):
|
||||
world_pos = wall_anchor(context, self, host_obj, other)
|
||||
else:
|
||||
world_pos = layer3_anchor(host_obj, other)
|
||||
if world_pos is None:
|
||||
return
|
||||
self.add_opening_icon.matrix_basis = gizmo.billboarded_at(world_pos, gizmo.get_billboard_rotation(context))
|
||||
|
||||
|
||||
def wall_anchor(
|
||||
context: bpy.types.Context, group: bpy.types.GizmoGroup, wall_obj: bpy.types.Object, other: bpy.types.Object
|
||||
) -> Vector | None:
|
||||
"""World-space anchor for the add-opening icon on a wall host: void origin
|
||||
projected onto the wall reference-line X (clamped to wall extents), lifted to
|
||||
the camera-facing wall-local Y."""
|
||||
geom = _get_wall_geom_cached(group, wall_obj)
|
||||
if not geom:
|
||||
return None
|
||||
mw = wall_obj.matrix_world
|
||||
wall_local = mw.inverted() @ other.matrix_world.translation
|
||||
local_x = max(geom["anchor_x"], min(wall_local.x, geom["anchor_x"] + geom["length"]))
|
||||
icon_y = _wall_camera_facing_icon_y(context, mw, geom)
|
||||
base_world = mw @ Vector((local_x, icon_y, 0.0))
|
||||
top_world = mw @ Vector((local_x, icon_y, geom["height"] + gizmo.BaseParametricGizmoGroup.ICON_Z_OFFSET))
|
||||
return gizmo.BaseParametricGizmoGroup.pick_visible_anchor(context, base_world, top_world)
|
||||
|
||||
|
||||
def layer3_anchor(host_obj: bpy.types.Object, other: bpy.types.Object) -> Vector:
|
||||
"""World-space anchor for the add-opening icon on a LAYER3 host (slab / roof):
|
||||
void's world XY, lifted just above the host's top face. Predictable height
|
||||
regardless of where the void sits vertically — clicking the icon places the
|
||||
opening at the void's XY, and the operator handles the actual cut depth."""
|
||||
bbox = tool.Blender.get_object_world_bounding_box(host_obj)
|
||||
anchor_xy = other.matrix_world.translation.xy
|
||||
top_z = bbox["max_z"] + gizmo.BaseParametricGizmoGroup.ICON_Z_OFFSET
|
||||
return Vector((anchor_xy.x, anchor_xy.y, top_z))
|
||||
|
||||
|
||||
def host_toggle_anchor(host_obj: bpy.types.Object) -> Vector:
|
||||
"""Object origin XY, lifted just above the topmost mesh vertex. Tracks
|
||||
the parametric origin (useful reference even when the mesh extends
|
||||
asymmetrically) and the visible top face (stays clear of sloped or
|
||||
stepped bodies)."""
|
||||
origin = host_obj.matrix_world.translation
|
||||
top_z = tool.Blender.get_object_world_bounding_box(host_obj)["max_z"] + gizmo.BaseParametricGizmoGroup.ICON_Z_OFFSET
|
||||
return Vector((origin.x, origin.y, top_z))
|
||||
|
||||
|
||||
class GizmoHostToggleOpenings(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin):
|
||||
"""Fallback toggle-openings icon for hosts that lack their own
|
||||
parametric-edit toolbar — slabs today, plus any foreign-authored
|
||||
IfcRoof that carries no BBIM_Roof pset (so ``GizmoRoofEdition`` doesn't
|
||||
poll for it). Walls and parametric roofs already render an idle-row
|
||||
toggle next to the pen and are excluded from this poll.
|
||||
|
||||
When slab parametric-edit lands the slab branch will pen-row-handle
|
||||
its own toggle; updating the exclusion predicate here is the only
|
||||
migration step needed."""
|
||||
|
||||
bl_idname = "OBJECT_GGT_bim_host_toggle_openings"
|
||||
bl_label = "Host Toggle Openings Gizmo"
|
||||
bl_space_type = "VIEW_3D"
|
||||
bl_region_type = "WINDOW"
|
||||
bl_options = {"3D", "PERSISTENT"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: bpy.types.Context) -> bool:
|
||||
element = _resolve_active_host(context, n_selected=1)
|
||||
if element is None:
|
||||
return False
|
||||
if not tool.Geometry.has_openings(element):
|
||||
return False
|
||||
# Skip when a per-feature parametric-edit gizmo already surfaces
|
||||
# an idle-row toggle for this element — walls and parametric roofs
|
||||
# both render their own toggle in the pen row.
|
||||
if tool.Parametric.is_path_connectable_wall(element):
|
||||
return False
|
||||
if tool.Parametric.is_roof(element):
|
||||
return False
|
||||
return True
|
||||
|
||||
def setup(self, context: bpy.types.Context) -> None:
|
||||
default_color, highlight_color = self.get_decoration_colors()
|
||||
self.toggle_openings_icon = self.setup_icon_gizmo(
|
||||
"VIEW3D_GT_add_opening", default_color, highlight_color, "bim.toggle_host_openings"
|
||||
)
|
||||
|
||||
def position_gizmos(self, context: bpy.types.Context) -> None:
|
||||
host_obj = context.active_object
|
||||
if not host_obj:
|
||||
return
|
||||
self.toggle_openings_icon.matrix_basis = gizmo.billboarded_at(
|
||||
host_toggle_anchor(host_obj), gizmo.get_billboard_rotation(context)
|
||||
)
|
||||
@@ -229,9 +229,15 @@ class FilledOpeningGenerator:
|
||||
filling_obj: bpy.types.Object,
|
||||
voided_obj: bpy.types.Object,
|
||||
target: Optional[Vector] = None,
|
||||
preserve_placement: bool = False,
|
||||
) -> Union[None, str]:
|
||||
"""
|
||||
:param target: Target opening position. If ommited, cursor position is used.
|
||||
:param preserve_placement: If True, keep ``filling_obj.matrix_world`` as-is
|
||||
and skip the snap-to-wall-axis / rl1-rl2 Z-default logic. The opening
|
||||
is still created at the filling's current world position. Useful
|
||||
when the caller (e.g. the SHIFT-add-opening gizmo flow) has
|
||||
already positioned the filling intentionally.
|
||||
:return: None if there was no errors, otherwise returns a string with error message.
|
||||
"""
|
||||
props = tool.Model.get_model_props()
|
||||
@@ -253,7 +259,7 @@ class FilledOpeningGenerator:
|
||||
should_set_z_level = False
|
||||
|
||||
# Sometimes, the voided_obj may be an aggregate, which won't have any representation.
|
||||
if voided_obj.data:
|
||||
if not preserve_placement and voided_obj.data:
|
||||
raycast = voided_obj.closest_point_on_mesh(voided_obj.matrix_world.inverted() @ target, distance=0.01)
|
||||
if not raycast[0]:
|
||||
target = filling_obj.matrix_world.translation.copy()
|
||||
@@ -737,6 +743,29 @@ class AddBoolean(Operator, tool.Ifc.Operator):
|
||||
tool.Root.reload_item_decorator()
|
||||
|
||||
|
||||
class ToggleHostOpenings(Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.toggle_host_openings"
|
||||
bl_label = "Toggle Openings"
|
||||
bl_description = "Show or hide opening fills (doors and windows) in the viewport\n\nHotkey: Alt+O"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
if not tool.Model.has_selected_ifc_objects():
|
||||
cls.poll_message_set("No IFC objects selected.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
# Opening visibility is independent of host geometry — don't commit any
|
||||
# active parametric edit; the user can keep editing the host.
|
||||
if tool.Model.get_model_props().openings:
|
||||
bpy.ops.bim.edit_openings(apply_all=True)
|
||||
else:
|
||||
bpy.ops.bim.show_openings()
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class ShowOpenings(Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.show_openings"
|
||||
bl_label = "Show Openings"
|
||||
|
||||
@@ -103,8 +103,12 @@ def update_type_page(self: "BIMModelProperties", context: bpy.types.Context) ->
|
||||
|
||||
|
||||
def update_relating_array_from_object(self: "BIMArrayProperties", context: bpy.types.Context) -> None:
|
||||
bpy.ops.bim.enable_editing_array(item=self.is_editing)
|
||||
return
|
||||
# Skip the cleanup-time clear: Finish/Cancel sets relating_array_object back to None,
|
||||
# which has no source to hydrate from. Only the user-driven pick (None → some array)
|
||||
# should auto-enter edit on the picked source's layer 0.
|
||||
if self.relating_array_object is None:
|
||||
return
|
||||
bpy.ops.bim.enable_editing_array(item=0)
|
||||
|
||||
|
||||
def is_object_array_applicable(self: "BIMArrayProperties", obj: bpy.types.Object) -> bool:
|
||||
@@ -397,8 +401,13 @@ class BIMModelProperties(PropertyGroup):
|
||||
|
||||
|
||||
class BIMArrayProperties(PropertyGroup):
|
||||
is_editing: bpy.props.IntProperty(
|
||||
default=-1, description="Currently edited array index. -1 if not in array editing mode."
|
||||
is_editing: bpy.props.BoolProperty(
|
||||
default=False,
|
||||
description="True while an array layer is in parametric edit mode. The specific layer is in editing_item_index.",
|
||||
)
|
||||
editing_item_index: bpy.props.IntProperty(
|
||||
default=-1,
|
||||
description="Index of the array layer currently being edited; -1 when not in edit mode.",
|
||||
)
|
||||
count: bpy.props.IntProperty(name="Count", default=0, min=0)
|
||||
x: bpy.props.FloatProperty(name="X", default=0, subtype="DISTANCE")
|
||||
@@ -414,6 +423,15 @@ class BIMArrayProperties(PropertyGroup):
|
||||
name="Method",
|
||||
default="OFFSET",
|
||||
)
|
||||
per_child_opening: bpy.props.BoolProperty(
|
||||
name="Per-Child Opening",
|
||||
description=(
|
||||
"When the array parent fills a wall (or any voidable host), give each array child its own opening + "
|
||||
"filling pair so the host is cut once per child. Disable to leave the host uncut by the children — "
|
||||
"only the parent's original opening remains"
|
||||
),
|
||||
default=True,
|
||||
)
|
||||
relating_array_object: bpy.props.PointerProperty(
|
||||
type=bpy.types.Object,
|
||||
name="Copy Array Properties",
|
||||
@@ -422,13 +440,15 @@ class BIMArrayProperties(PropertyGroup):
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
is_editing: int
|
||||
is_editing: bool
|
||||
editing_item_index: int
|
||||
count: int
|
||||
x: float
|
||||
y: float
|
||||
z: float
|
||||
use_local_space: bool
|
||||
method: Literal["OFFSET", "DISTRIBUTE"]
|
||||
per_child_opening: bool
|
||||
sync_children: bool
|
||||
relating_array_object: Union[bpy.types.Object, None]
|
||||
|
||||
|
||||
@@ -415,7 +415,7 @@ class _RailingEditMixin(PathPreservingEditMixin):
|
||||
|
||||
@classmethod
|
||||
def _is_element_type(cls, element):
|
||||
return tool.Blender.Modifier.is_railing(element)
|
||||
return tool.Parametric.is_railing(element)
|
||||
|
||||
@classmethod
|
||||
def _get_props(cls, obj: bpy.types.Object):
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import json
|
||||
from math import cos, pi, radians, tan
|
||||
from typing import Any, Literal, Union
|
||||
from math import atan2, cos, degrees, pi, radians, tan
|
||||
from typing import Any, ClassVar, Literal, Union
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
@@ -32,9 +32,11 @@ from mathutils import Quaternion, Vector
|
||||
|
||||
import bonsai.core.root
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.drawing import gizmos as gizmo
|
||||
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig, IconSlot
|
||||
from bonsai.bim.module.model.data import RoofData, refresh
|
||||
from bonsai.bim.module.model.decorator import ProfileDecorator
|
||||
from bonsai.bim.parametric_lifecycle import PathPreservingEditMixin
|
||||
from bonsai.bim.parametric_lifecycle import CycleTypeMixin, PathPreservingEditMixin
|
||||
|
||||
# reference:
|
||||
# https://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcRoof.htm
|
||||
@@ -211,7 +213,13 @@ def generate_hipped_roof_bmesh(
|
||||
|
||||
new_verts = [bm.verts.new(v) for v in verts]
|
||||
new_edges = [bm.edges.new([new_verts[vi] for vi in edge]) for edge in edges]
|
||||
new_faces = [bm.faces.new([new_verts[vi] for vi in face]) for face in faces]
|
||||
# Skip degenerate faces. ``bpypolyskel.polygonize`` can emit a face whose
|
||||
# vertex list contains the same index twice on certain footprint /
|
||||
# slope combinations (the straight-skeleton collapses two ridge events
|
||||
# onto the same vertex). ``bm.faces.new`` rejects those with
|
||||
# ``found the same (BMVert) used multiple times``; dropping them keeps
|
||||
# the rest of the roof intact instead of aborting the whole rebuild.
|
||||
new_faces = [bm.faces.new([new_verts[vi] for vi in face]) for face in faces if len(set(face)) == len(face)]
|
||||
|
||||
if mode == "HEIGHT": # Calculate the angle we ended up with.
|
||||
new_faces[0].normal_update()
|
||||
@@ -397,6 +405,11 @@ def generate_hipped_roof_bmesh(
|
||||
if is_internal:
|
||||
faces_to_delete.add(face)
|
||||
bmesh.ops.delete(bm, geom=list(faces_to_delete), context="FACES")
|
||||
# Final pass: ``remove_doubles`` + internal-face deletion above can leave
|
||||
# the bottom slab faces flipped at low slopes, where the kernel's
|
||||
# "outward" inference becomes ambiguous on near-flat geometry. Recompute
|
||||
# once more on the final topology so the eave plane points down.
|
||||
bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:])
|
||||
return bm
|
||||
|
||||
|
||||
@@ -618,7 +631,7 @@ class _RoofEditMixin(PathPreservingEditMixin):
|
||||
|
||||
@classmethod
|
||||
def _is_element_type(cls, element):
|
||||
return tool.Blender.Modifier.is_roof(element)
|
||||
return tool.Parametric.is_roof(element)
|
||||
|
||||
@classmethod
|
||||
def _get_props(cls, obj: bpy.types.Object):
|
||||
@@ -636,32 +649,142 @@ class _RoofEditMixin(PathPreservingEditMixin):
|
||||
def _update_modifier_bmesh(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
|
||||
update_roof_modifier_bmesh(obj)
|
||||
|
||||
@classmethod
|
||||
def _restore_viewport_after_cancel(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
|
||||
"""Rebuild the roof bmesh from the just-restored draft props so the
|
||||
viewport reverts to the pre-edit geometry. Same helper the modal
|
||||
edits use, just driven by the cancelled props instead of in-flight
|
||||
drag values."""
|
||||
update_roof_modifier_bmesh(obj)
|
||||
|
||||
class EnableEditingRoof(_RoofEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.enable_editing_roof"
|
||||
bl_label = "Enable Editing Roof"
|
||||
|
||||
EnableEditingRoof, FinishEditingRoof, CancelEditingRoof = tool.Parametric.build_edit_lifecycle(
|
||||
"roof",
|
||||
_RoofEditMixin,
|
||||
labels=(
|
||||
("Enable Editing Roof", ""),
|
||||
("Finish Editing Roof", ""),
|
||||
("Cancel Editing Roof", ""),
|
||||
),
|
||||
module_name=__name__,
|
||||
)
|
||||
|
||||
|
||||
# Fixed horizontal run for the slope gizmo: the draggable value is the
|
||||
# vertical rise at this distance from the anchor, in the rise/run convention.
|
||||
_ROOF_SLOPE_REFERENCE_RUN = 1.0
|
||||
# One degree shy of vertical; avoids tan() blow-up when the user drags the
|
||||
# rise handle past the gizmo's anchor.
|
||||
_ROOF_MAX_SLOPE_ANGLE = pi / 2 - 0.001
|
||||
|
||||
|
||||
def _roof_has_openings() -> bool:
|
||||
"""``visible_when`` predicate for the toggle_openings idle slot. True iff
|
||||
the active object's IFC element exposes a non-empty HasOpenings inverse."""
|
||||
obj = bpy.context.active_object
|
||||
if obj is None:
|
||||
return False
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if element is None:
|
||||
return False
|
||||
return tool.Geometry.has_openings(element)
|
||||
|
||||
|
||||
class CycleRoofGenerationMethod(bpy.types.Operator, tool.Ifc.Operator, CycleTypeMixin):
|
||||
"""Cycle the roof generation method (HEIGHT ↔ ANGLE). Shift+click cycles in reverse."""
|
||||
|
||||
bl_idname = "bim.cycle_roof_generation_method"
|
||||
bl_label = "Cycle Roof Generation Method"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def _execute(self, context):
|
||||
return self._enable_targets(context)
|
||||
element_checker = tool.Parametric.is_roof
|
||||
props_getter = tool.Model.get_roof_props
|
||||
type_literal = tool.Model.RoofGenerationMethod
|
||||
type_attr = "generation_method"
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
return self._cycle_type(context)
|
||||
|
||||
|
||||
class CancelEditingRoof(_RoofEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.cancel_editing_roof"
|
||||
bl_label = "Cancel Editing Roof"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
class GizmoRoofEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
bl_idname = "OBJECT_GGT_bim_roof_edition"
|
||||
bl_label = "Roof Editing Gizmo"
|
||||
bl_space_type = "VIEW_3D"
|
||||
bl_region_type = "WINDOW"
|
||||
bl_options = {"3D", "PERSISTENT"}
|
||||
|
||||
def _execute(self, context):
|
||||
return self._cancel_targets(context)
|
||||
enable_editing_operator = "bim.enable_editing_roof"
|
||||
finish_editing_operator = "bim.finish_editing_roof"
|
||||
cancel_editing_operator = "bim.cancel_editing_roof"
|
||||
cycle_type_operator = "bim.cycle_roof_generation_method"
|
||||
|
||||
# Positions for all three dimensions are set per-frame by the position
|
||||
# override below; no static ``matrix_position`` is needed.
|
||||
dimension_gizmo_props = [
|
||||
DimensionGizmoConfig(
|
||||
attr_name="height",
|
||||
axis=(0, 0, 1),
|
||||
min_value=0.01,
|
||||
visibility_condition=lambda p: p.generation_method == "HEIGHT",
|
||||
),
|
||||
DimensionGizmoConfig(
|
||||
attr_name="angle",
|
||||
axis=(0, 0, 1),
|
||||
prop_name="Slope",
|
||||
min_value=0.0,
|
||||
visibility_condition=lambda p: p.generation_method == "ANGLE",
|
||||
compute_value=lambda p: tan(p.angle) * _ROOF_SLOPE_REFERENCE_RUN,
|
||||
apply_value=lambda p, rise: setattr(
|
||||
p, "angle", min(_ROOF_MAX_SLOPE_ANGLE, max(0.0, atan2(rise, _ROOF_SLOPE_REFERENCE_RUN)))
|
||||
),
|
||||
text_formatter=lambda p, rise: (f"{tool.Unit.format_distance(rise)} ({degrees(p.angle):.1f}°)"),
|
||||
),
|
||||
DimensionGizmoConfig(
|
||||
attr_name="roof_thickness",
|
||||
axis=(0, 0, -1),
|
||||
min_value=0.001,
|
||||
# The line shows the perpendicular slab thickness (matching the
|
||||
# pset value and the drag delta); the true vertical span is
|
||||
# ``roof_thickness / cos(angle)``, longer than what is drawn.
|
||||
),
|
||||
]
|
||||
|
||||
class FinishEditingRoof(_RoofEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.finish_editing_roof"
|
||||
bl_label = "Finish Editing Roof"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
props_getter = tool.Model.get_roof_props
|
||||
gizmo_pref_name = "roof"
|
||||
|
||||
def _execute(self, context):
|
||||
return self._finish_targets(context)
|
||||
idle_slots: ClassVar[tuple[IconSlot, ...]] = (
|
||||
IconSlot(
|
||||
name="toggle_openings",
|
||||
gizmo_idname="VIEW3D_GT_add_opening",
|
||||
operator="bim.toggle_host_openings",
|
||||
visible_when=lambda gg: _roof_has_openings(),
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
return tool.Parametric.is_roof(element)
|
||||
|
||||
def _update_dimension_gizmo_positions(self, context: bpy.types.Context, mw, props) -> None: # noqa: ARG002
|
||||
"""Anchor every dimension gizmo at the object origin. Each gizmo's
|
||||
declared axis (height/slope along +Z, thickness along -Z) separates
|
||||
them in 3D so they don't visually collide despite sharing a
|
||||
position; the height + slope gizmos themselves are mutually
|
||||
exclusive via ``visibility_condition`` on ``generation_method``."""
|
||||
origin = Vector((0.0, 0.0, 0.0))
|
||||
self.set_dimension_gizmo_position("height", mw, origin, (0, 0, 1))
|
||||
self.set_dimension_gizmo_position("angle", mw, origin, (0, 0, 1))
|
||||
self.set_dimension_gizmo_position("roof_thickness", mw, origin, (0, 0, -1))
|
||||
|
||||
def get_element_height(self, props) -> float: # noqa: ARG002
|
||||
"""Object-local Z of the mesh's topmost vertex, so the pen / validate /
|
||||
cancel / cycle row anchors visibly above sloped or stepped roof
|
||||
bodies rather than at the parametric ``props.height`` which may not
|
||||
match the rendered apex on ANGLE-generation roofs."""
|
||||
obj = bpy.context.active_object
|
||||
if obj is None or not getattr(obj, "bound_box", None):
|
||||
return 1.0
|
||||
return max(c[2] for c in obj.bound_box)
|
||||
|
||||
|
||||
class EnableEditingRoofPath(bpy.types.Operator, tool.Ifc.Operator):
|
||||
|
||||
@@ -31,7 +31,13 @@ from mathutils import Matrix, Vector
|
||||
import bonsai.core.root
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.drawing import gizmos as gizmo
|
||||
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
|
||||
from bonsai.bim.module.drawing.gizmos import (
|
||||
COLOR_GREEN,
|
||||
COLOR_RED,
|
||||
DimensionGizmoConfig,
|
||||
IconSlot,
|
||||
)
|
||||
from bonsai.bim.parametric_lifecycle import IntegerInputDialogMixin, PickTypeMixin
|
||||
from bonsai.tool.numeric_input import (
|
||||
IntegerInputState,
|
||||
run_integer_input_modal,
|
||||
@@ -39,7 +45,7 @@ from bonsai.tool.numeric_input import (
|
||||
)
|
||||
|
||||
V_ = tool.Blender.V_
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from bmesh.types import BMVert
|
||||
from bpy.props import IntProperty
|
||||
@@ -378,6 +384,20 @@ class AdjustStairTreads(bpy.types.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class InputStairTreads(IntegerInputDialogMixin, bpy.types.Operator):
|
||||
"""Popup-dialog entry point for typing a new ``number_of_treads`` value.
|
||||
Bound to the world-space ``xN`` count label in the stair edit row."""
|
||||
|
||||
bl_idname = "bim.input_stair_treads"
|
||||
bl_label = "Set Number of Treads"
|
||||
bl_description = "Type the number of treads for this stair"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
number_of_treads: IntProperty(name="Number of Treads", default=1, min=1)
|
||||
attr_name = "number_of_treads"
|
||||
props_getter = staticmethod(tool.Model.get_stair_props)
|
||||
|
||||
|
||||
class SetStairTreads(bpy.types.Operator):
|
||||
"""Set the number of treads to a specific value."""
|
||||
|
||||
@@ -423,11 +443,11 @@ class SetStairTreads(bpy.types.Operator):
|
||||
return f"Number of Treads: {input_str}_{validity} | Enter to confirm, Esc to cancel"
|
||||
|
||||
|
||||
class CycleStairType(bpy.types.Operator, gizmo.CycleTypeMixin):
|
||||
"""Cycle through stair types. Shift+click to cycle in reverse."""
|
||||
class PickStairType(bpy.types.Operator, PickTypeMixin):
|
||||
"""Pick a stair type from a popup menu."""
|
||||
|
||||
bl_idname = "bim.cycle_stair_type"
|
||||
bl_label = "Cycle Stair Type"
|
||||
bl_idname = "bim.pick_stair_type"
|
||||
bl_label = "Pick Stair Type"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
props_getter = tool.Model.get_stair_props
|
||||
@@ -436,7 +456,7 @@ class CycleStairType(bpy.types.Operator, gizmo.CycleTypeMixin):
|
||||
skip_element_check = True
|
||||
|
||||
def execute(self, context: bpy.types.Context) -> set[str]:
|
||||
return self._cycle_type(context)
|
||||
return self._pick_type(context)
|
||||
|
||||
|
||||
# Tread run accessors - callbacks that delegate to BIMStairProperties methods
|
||||
@@ -462,20 +482,47 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
bl_region_type = "WINDOW"
|
||||
bl_options = {"3D", "PERSISTENT"}
|
||||
|
||||
# === Stair-Specific Icon Layout (meters) ===
|
||||
# Additional icons for stair editing, positioned after standard icons:
|
||||
# [Validate] [Cancel] [Cycle] [TreadLock] [Plus] [Minus]
|
||||
ICON_TREAD_LOCK_X = 1.24 # X position for tread lock toggle icon
|
||||
ICON_PLUS_X = 1.61 # X position for add tread (+) icon
|
||||
ICON_MINUS_X = 1.98 # X position for remove tread (-) icon
|
||||
# === Stair-Specific Icon Layout ===
|
||||
# Row order: [Validate] [Cancel] [Cycle] [TreadLock] [xN] [Plus] [Minus]
|
||||
# The base class assigns X positions from ``feature_slots`` tuple order —
|
||||
# adding an icon is a one-line append, no hardcoded X constant.
|
||||
ICON_PLUS_MINUS_SCALE = 0.24 # Scale for plus/minus icons (slightly larger)
|
||||
ICON_CYCLE_SCALE = 0.3 # Scale for cycle type icon
|
||||
ICON_COUNT_LABEL_SCALE = 0.36 # Scale for the xN tread-count label
|
||||
ICON_Z_OFFSET = 0.5 # Z offset above geometry for editing icons
|
||||
|
||||
feature_slots: ClassVar[tuple[IconSlot, ...]] = (
|
||||
IconSlot(
|
||||
name="tread_lock",
|
||||
gizmo_idname="VIEW3D_GT_lock",
|
||||
variants=("open", "closed"),
|
||||
operator="bim.toggle_stair_property",
|
||||
color=(1.0, 1.0, 1.0),
|
||||
operator_props=(("property_name", "custom_tread_lock"),),
|
||||
),
|
||||
IconSlot(name="tread_count_label", placeholder=True),
|
||||
IconSlot(
|
||||
name="plus",
|
||||
gizmo_idname="VIEW3D_GT_plus",
|
||||
operator="bim.adjust_stair_treads",
|
||||
scale=ICON_PLUS_MINUS_SCALE,
|
||||
color=COLOR_GREEN,
|
||||
operator_props=(("increment", 1),),
|
||||
),
|
||||
IconSlot(
|
||||
name="minus",
|
||||
gizmo_idname="VIEW3D_GT_minus",
|
||||
operator="bim.adjust_stair_treads",
|
||||
scale=ICON_PLUS_MINUS_SCALE,
|
||||
color=COLOR_RED,
|
||||
operator_props=(("increment", -1),),
|
||||
),
|
||||
)
|
||||
|
||||
enable_editing_operator = "bim.enable_editing_stair"
|
||||
finish_editing_operator = "bim.finish_editing_stair"
|
||||
cancel_editing_operator = "bim.cancel_editing_stair"
|
||||
cycle_type_operator = "bim.cycle_stair_type"
|
||||
pick_type_operator = "bim.pick_stair_type"
|
||||
|
||||
def get_icon_y_extent(self, props: "BIMStairProperties") -> tuple[float, float]:
|
||||
"""Get Y extents for stair icon positioning.
|
||||
@@ -585,28 +632,31 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
|
||||
@classmethod
|
||||
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
return tool.Blender.Modifier.is_stair(element)
|
||||
return tool.Parametric.is_stair(element)
|
||||
|
||||
def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None:
|
||||
"""Create stair-specific icon gizmos (lock, plus, minus)."""
|
||||
self.lock_gizmo = self.create_icon_gizmo(
|
||||
"VIEW3D_GT_lock",
|
||||
self.COLOR_BLUE,
|
||||
"""Create the total-length lock as an open/closed pair plus the
|
||||
``xN`` tread-count label. Lock click toggles
|
||||
``props.total_length_lock``; the per-frame update hook picks which
|
||||
member is visible. Anchored to the stair's far X end (not the edit
|
||||
row) so it's positioned by ``_update_lock_gizmo_position`` rather
|
||||
than the toolbar slot system.
|
||||
|
||||
The count label binds to ``bim.input_stair_treads`` (popup dialog)
|
||||
for click-to-type input and sits at the X reserved by the
|
||||
``tread_count_label`` placeholder slot in ``feature_slots``."""
|
||||
self.total_length_lock_open_gizmo, self.total_length_lock_closed_gizmo = self.create_icon_gizmo_lock_pair(
|
||||
"bim.toggle_stair_property",
|
||||
self.COLOR_BLUE,
|
||||
property_name="total_length_lock",
|
||||
)
|
||||
self.tread_lock_gizmo = self.create_icon_gizmo(
|
||||
"VIEW3D_GT_lock",
|
||||
(1.0, 1.0, 1.0),
|
||||
"bim.toggle_stair_property",
|
||||
property_name="custom_tread_lock",
|
||||
)
|
||||
self.plus_gizmo = self.create_icon_gizmo(
|
||||
"VIEW3D_GT_plus", self.COLOR_GREEN, "bim.adjust_stair_treads", increment=1
|
||||
)
|
||||
self.minus_gizmo = self.create_icon_gizmo(
|
||||
"VIEW3D_GT_minus", self.COLOR_RED, "bim.adjust_stair_treads", increment=-1
|
||||
)
|
||||
default_color, highlight_color = self.get_decoration_colors()
|
||||
self.tread_count_label_gizmo = self.gizmos.new("BIM_GT_count_label")
|
||||
self.tread_count_label_gizmo.use_draw_scale = False
|
||||
self.tread_count_label_gizmo.color = default_color
|
||||
self.tread_count_label_gizmo.color_highlight = highlight_color
|
||||
self.tread_count_label_gizmo.alpha = 0.8
|
||||
self.tread_count_label_gizmo.target_set_operator("bim.input_stair_treads")
|
||||
|
||||
def _refresh_element_specific(
|
||||
self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties" # noqa: ARG002
|
||||
@@ -618,30 +668,43 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
self.update_tread_count_gizmos(props)
|
||||
|
||||
def update_lock_gizmo(self, props: "BIMStairProperties") -> None:
|
||||
"""Update lock gizmo color and visibility. Positioning is handled
|
||||
per-frame by the dimension-positioning hook."""
|
||||
gizmo_prefs = self.get_gizmo_prefs()
|
||||
if not self.update_gizmo_visibility(self.lock_gizmo, props.is_editing, gizmo_prefs.lock):
|
||||
return # Hidden, skip color update
|
||||
self.lock_gizmo.color = self.COLOR_RED if props.total_length_lock else self.COLOR_GREEN
|
||||
"""Show the open/closed total-length lock variant matching
|
||||
``props.total_length_lock``. Positioning is handled per-frame by
|
||||
the dimension-positioning hook."""
|
||||
if not hasattr(self, "total_length_lock_open_gizmo"):
|
||||
return
|
||||
if not props.is_editing:
|
||||
self.total_length_lock_open_gizmo.hide = True
|
||||
self.total_length_lock_closed_gizmo.hide = True
|
||||
return
|
||||
self.total_length_lock_open_gizmo.hide = props.total_length_lock
|
||||
self.total_length_lock_closed_gizmo.hide = not props.total_length_lock
|
||||
|
||||
def update_tread_lock_gizmo(self, props: "BIMStairProperties") -> None:
|
||||
"""Update visibility of tread lock gizmo. Positioning is handled in _update_editing_icon_positions."""
|
||||
if not hasattr(self, "tread_lock_gizmo"):
|
||||
"""Show the open/closed lock variant matching ``props.custom_tread_lock``.
|
||||
|
||||
Both pair members share an X position (set by the base's slot
|
||||
positioning); this picks which one is visible per frame so a state
|
||||
flip can't reveal both at once."""
|
||||
if not hasattr(self, "tread_lock_open_gizmo"):
|
||||
return
|
||||
gizmo_prefs = self.get_gizmo_prefs()
|
||||
self.update_gizmo_visibility(self.tread_lock_gizmo, props.is_editing, gizmo_prefs.lock)
|
||||
if not props.is_editing:
|
||||
self.tread_lock_open_gizmo.hide = True
|
||||
self.tread_lock_closed_gizmo.hide = True
|
||||
return
|
||||
self.tread_lock_open_gizmo.hide = props.custom_tread_lock
|
||||
self.tread_lock_closed_gizmo.hide = not props.custom_tread_lock
|
||||
|
||||
def update_tread_count_gizmos(self, props: "BIMStairProperties") -> None:
|
||||
"""Update visibility of +/- tread count gizmos. Positioning is handled in _update_editing_icon_positions."""
|
||||
"""Update visibility of the +/- tread count gizmos and the ``xN``
|
||||
label. Positioning is handled in ``_update_editing_icon_positions``."""
|
||||
if not hasattr(self, "plus_gizmo") or not hasattr(self, "minus_gizmo"):
|
||||
return
|
||||
gizmo_prefs = self.get_gizmo_prefs()
|
||||
self.update_gizmo_visibility(self.plus_gizmo, props.is_editing, gizmo_prefs.plus)
|
||||
self.update_gizmo_visibility(self.plus_gizmo, props.is_editing)
|
||||
# Minus has additional condition: number_of_treads > 1
|
||||
self.update_gizmo_visibility(
|
||||
self.minus_gizmo, props.is_editing and props.number_of_treads > 1, gizmo_prefs.minus
|
||||
)
|
||||
self.update_gizmo_visibility(self.minus_gizmo, props.is_editing and props.number_of_treads > 1)
|
||||
if hasattr(self, "tread_count_label_gizmo"):
|
||||
self.update_gizmo_visibility(self.tread_count_label_gizmo, props.is_editing)
|
||||
|
||||
def _update_dimension_gizmo_positions(
|
||||
self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties" # noqa: ARG002
|
||||
@@ -719,10 +782,12 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
billboard_rot: Matrix,
|
||||
total_run: float,
|
||||
) -> None:
|
||||
"""Update lock gizmo position based on Y view direction."""
|
||||
"""Update lock gizmo pair position based on Y view direction. Writes
|
||||
the matrix on both members so a state flip can't reveal a stale pose."""
|
||||
y_pos = self.get_y_position_for_view(props, viewing_from_negative_y, use_offset=True)
|
||||
self.set_icon_gizmo_position(
|
||||
"lock_gizmo",
|
||||
self.set_icon_gizmo_pair_position(
|
||||
"total_length_lock_open_gizmo",
|
||||
"total_length_lock_closed_gizmo",
|
||||
mw,
|
||||
total_run + self.ICON_Z_OFFSET,
|
||||
y_pos,
|
||||
@@ -734,30 +799,47 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
def _update_editing_icon_positions(
|
||||
self, mw: Matrix, props: "BIMStairProperties", viewing_from_negative_y: bool, billboard_rot: Matrix
|
||||
) -> None:
|
||||
"""Update editing icon positions, flipping Y based on viewing angle."""
|
||||
"""Reposition the editing icons at stair's view-dependent Y. The base
|
||||
class's update_editing_gizmos already placed them at the default
|
||||
``get_icon_y_offset`` Y — this overrides with the stair-specific
|
||||
``get_icon_y_for_view`` flip so the icons land on the side the
|
||||
camera is looking from."""
|
||||
if not props.is_editing:
|
||||
return
|
||||
|
||||
icon_z = props.height + self.ICON_Z_OFFSET
|
||||
y_pos = self.get_icon_y_for_view(props, viewing_from_negative_y)
|
||||
slot_x = self._slot_x_positions()
|
||||
|
||||
self.set_icon_gizmo_position("validate_gizmo", mw, 0, y_pos, icon_z, billboard_rot)
|
||||
self.set_icon_gizmo_position("cancel_gizmo", mw, self.ICON_CANCEL_X, y_pos, icon_z, billboard_rot)
|
||||
self.set_icon_gizmo_position(
|
||||
"cycle_gizmo", mw, self.ICON_CYCLE_X, y_pos, icon_z, billboard_rot, scale=self.ICON_CYCLE_SCALE
|
||||
)
|
||||
self.set_icon_gizmo_position(
|
||||
"tread_lock_gizmo",
|
||||
self.set_icon_gizmo_pair_position(
|
||||
"tread_lock_open_gizmo",
|
||||
"tread_lock_closed_gizmo",
|
||||
mw,
|
||||
self.ICON_TREAD_LOCK_X,
|
||||
slot_x["tread_lock"],
|
||||
y_pos,
|
||||
icon_z - self.EDITING_ICON_SCALE / 2,
|
||||
billboard_rot,
|
||||
scale=self.EDITING_ICON_SCALE,
|
||||
)
|
||||
self.set_icon_gizmo_position(
|
||||
"plus_gizmo", mw, self.ICON_PLUS_X, y_pos, icon_z, billboard_rot, scale=self.ICON_PLUS_MINUS_SCALE
|
||||
"plus_gizmo", mw, slot_x["plus"], y_pos, icon_z, billboard_rot, scale=self.ICON_PLUS_MINUS_SCALE
|
||||
)
|
||||
self.set_icon_gizmo_position(
|
||||
"minus_gizmo", mw, self.ICON_MINUS_X, y_pos, icon_z, billboard_rot, scale=self.ICON_PLUS_MINUS_SCALE
|
||||
"minus_gizmo", mw, slot_x["minus"], y_pos, icon_z, billboard_rot, scale=self.ICON_PLUS_MINUS_SCALE
|
||||
)
|
||||
if hasattr(self, "tread_count_label_gizmo"):
|
||||
self.tread_count_label_gizmo.set_count(int(props.number_of_treads))
|
||||
self.set_icon_gizmo_position(
|
||||
"tread_count_label_gizmo",
|
||||
mw,
|
||||
slot_x["tread_count_label"],
|
||||
y_pos,
|
||||
icon_z,
|
||||
billboard_rot,
|
||||
scale=self.ICON_COUNT_LABEL_SCALE,
|
||||
)
|
||||
|
||||
@@ -238,11 +238,11 @@ class BIM_PT_array(bpy.types.Panel):
|
||||
|
||||
for i, array in enumerate(ArrayData.data["parameters"]["data_dict"]):
|
||||
box = self.layout.box()
|
||||
if props.is_editing == i:
|
||||
if props.editing_item_index == i:
|
||||
row = box.row(align=True)
|
||||
row.prop(props, "count", icon="MOD_ARRAY")
|
||||
row.operator("bim.edit_array", icon="CHECKMARK", text="").item = i
|
||||
row.operator("bim.disable_editing_array", icon="CANCEL", text="")
|
||||
row.operator("bim.finish_editing_array", icon="CHECKMARK", text="")
|
||||
row.operator("bim.cancel_editing_array", icon="CANCEL", text="")
|
||||
row = box.row(align=True)
|
||||
row.prop(props, "method")
|
||||
row = box.row(align=True)
|
||||
@@ -365,7 +365,7 @@ class BIM_PT_wall(bpy.types.Panel):
|
||||
if not obj:
|
||||
return False
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
return bool(element) and tool.Blender.Modifier.is_wall(element)
|
||||
return bool(element) and tool.Parametric.is_wall(element)
|
||||
|
||||
def draw(self, context):
|
||||
obj = context.active_object
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,279 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Four wall-offset dimension gizmos (left / right / top / bottom) shared by door and
|
||||
window edit gizmo groups — both fillings sit in a LAYER2 wall and the offset math is
|
||||
identical.
|
||||
|
||||
The compute side returns a *signed* value on the X axis (negative when the filling
|
||||
is 180°-flipped onto the wall's opposite face) so the gizmo framework auto-flips
|
||||
the rendered arrow; the apply side takes ``abs(value)`` because the user-facing
|
||||
offset is always positive. Z-axis values are unsigned in both directions.
|
||||
|
||||
Fillings are assumed to align with the wall's local X axis to within ±90° — the
|
||||
parametric door/window construction path enforces this, and the X-sign math
|
||||
falls back to +1 if ``col[0].x`` lands on the ambiguous zero (filling rotated
|
||||
exactly 90° in the wall plane).
|
||||
|
||||
Every public entry point falls back to a safe no-op when the host-wall chain
|
||||
cannot be resolved: reads return 0.0, writes do nothing, and gizmo anchors
|
||||
return a filling-relative position. This keeps the gizmos non-crashing when a
|
||||
filling momentarily loses its host (e.g. mid-edit, partially-loaded files).
|
||||
|
||||
``_GEOM_CACHE`` is module-scoped and persists across tests — tests must call
|
||||
``clear_caches()`` between cases."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, NamedTuple, Protocol
|
||||
|
||||
from mathutils import Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import bpy
|
||||
|
||||
|
||||
class FillingProps(Protocol):
|
||||
"""Structural subset of door/window props this module touches."""
|
||||
|
||||
id_data: bpy.types.Object
|
||||
overall_width: float
|
||||
overall_height: float
|
||||
|
||||
|
||||
# Wall-local frame axis indices. Y (depth) is unused — fillings sit on the wall's centreline.
|
||||
_AXIS_X = 0
|
||||
_AXIS_Z = 2
|
||||
|
||||
|
||||
class _HostWallGeom(NamedTuple):
|
||||
"""Cached host-wall geometry in SI metres, wall-local frame. ``height`` is
|
||||
the vertical projection (already accounts for slanted extrusions)."""
|
||||
|
||||
wall_obj: bpy.types.Object
|
||||
height: float
|
||||
axis_min_x: float
|
||||
axis_max_x: float
|
||||
|
||||
|
||||
class _AxisExtent(NamedTuple):
|
||||
"""``[low, high]`` interval on one wall-local axis; low = near end.
|
||||
|
||||
``x_sign`` is +1 / -1 for an X-axis filling extent only (carries the
|
||||
180° auto-flip); always 1.0 elsewhere."""
|
||||
|
||||
low: float
|
||||
high: float
|
||||
x_sign: float = 1.0
|
||||
|
||||
|
||||
class _Edge(NamedTuple):
|
||||
"""Wall edge a gizmo measures to. ``is_max_end=True`` picks right/top, else left/bottom."""
|
||||
|
||||
axis_index: int
|
||||
is_max_end: bool
|
||||
|
||||
|
||||
_LEFT = _Edge(axis_index=_AXIS_X, is_max_end=False)
|
||||
_RIGHT = _Edge(axis_index=_AXIS_X, is_max_end=True)
|
||||
_BOTTOM = _Edge(axis_index=_AXIS_Z, is_max_end=False)
|
||||
_TOP = _Edge(axis_index=_AXIS_Z, is_max_end=True)
|
||||
|
||||
|
||||
# Avoids repeating the host-wall chain walk + LAYER2 geometry read per gizmo per frame.
|
||||
_GEOM_CACHE = tool.Parametric.GenerationKeyedCache()
|
||||
|
||||
|
||||
def clear_caches() -> None:
|
||||
_GEOM_CACHE.clear()
|
||||
|
||||
|
||||
def _host_wall_geom(filling_obj: bpy.types.Object) -> _HostWallGeom | None:
|
||||
"""Cached host-wall geometry for a filling, or ``None`` if any link in
|
||||
filling → opening → wall → LAYER2 extrusion → scene-object resolution breaks."""
|
||||
return _GEOM_CACHE.get_or_compute(filling_obj.name, lambda: _compute_host_wall_geom(filling_obj))
|
||||
|
||||
|
||||
def _compute_host_wall_geom(filling_obj: bpy.types.Object) -> _HostWallGeom | None:
|
||||
element = tool.Ifc.get_entity(filling_obj)
|
||||
if not element:
|
||||
return None
|
||||
host_wall = tool.Spatial.get_host_wall(element)
|
||||
if not host_wall:
|
||||
return None
|
||||
wall_obj = tool.Ifc.get_object(host_wall)
|
||||
length_height = tool.Wall.get_length_and_height(host_wall)
|
||||
axis_extent = tool.Wall.get_axis_local_extent(host_wall)
|
||||
# x_angle is None for non-LAYER2 walls — gates entry; the value itself is unused.
|
||||
if not (wall_obj and length_height and axis_extent and tool.Wall.get_x_angle(host_wall) is not None):
|
||||
return None
|
||||
_, height = length_height
|
||||
axis_min_x, axis_max_x = axis_extent
|
||||
return _HostWallGeom(wall_obj=wall_obj, height=height, axis_min_x=axis_min_x, axis_max_x=axis_max_x)
|
||||
|
||||
|
||||
def _filling_axis_extent(props: FillingProps, host_wall_obj: bpy.types.Object, axis_index: int) -> _AxisExtent:
|
||||
"""Filling footprint on the wall's local axis.
|
||||
|
||||
X-axis extent carries the filling's orientation sign (180° flip onto
|
||||
the opposite face) in ``x_sign``."""
|
||||
filling_in_wall = host_wall_obj.matrix_world.inverted() @ props.id_data.matrix_world
|
||||
origin = filling_in_wall.translation[axis_index]
|
||||
if axis_index == _AXIS_X:
|
||||
# col[0].x is the X-component of the filling's local X axis in the wall-local frame:
|
||||
# +1 when filling's +X aligns with wall's +X, -1 after a 180° Z-flip.
|
||||
x_sign = 1.0 if filling_in_wall.col[0].x >= 0.0 else -1.0
|
||||
signed_width = x_sign * props.overall_width
|
||||
return _AxisExtent(origin + min(0.0, signed_width), origin + max(0.0, signed_width), x_sign)
|
||||
return _AxisExtent(origin, origin + props.overall_height)
|
||||
|
||||
|
||||
def _wall_axis_extent(geom: _HostWallGeom, axis_index: int) -> _AxisExtent:
|
||||
"""Wall span on one local axis: X = IFC axis-line endpoints (not mesh bound-box,
|
||||
which drifts on trimmed walls); Z = 0 → wall height."""
|
||||
if axis_index == _AXIS_X:
|
||||
return _AxisExtent(geom.axis_min_x, geom.axis_max_x)
|
||||
return _AxisExtent(0.0, geom.height)
|
||||
|
||||
|
||||
def _offset_from_extents(filling: _AxisExtent, wall: _AxisExtent, is_max_end: bool) -> float:
|
||||
"""Distance from the wall edge to the filling's matching edge on the same axis."""
|
||||
if is_max_end:
|
||||
return wall.high - filling.high
|
||||
return filling.low - wall.low
|
||||
|
||||
|
||||
def _translate_along_wall_axis(
|
||||
props: FillingProps, host_wall_obj: bpy.types.Object, delta: float, axis_index: int
|
||||
) -> None:
|
||||
"""Shift the filling by ``delta`` SI metres along the wall's local axis. Drag
|
||||
operates in the filling's intent frame, not Blender's world frame, so a rotated
|
||||
host wall still tracks correctly."""
|
||||
if delta == 0.0:
|
||||
return
|
||||
direction_world = host_wall_obj.matrix_world.to_3x3().col[axis_index].normalized()
|
||||
props.id_data.matrix_world.translation = props.id_data.matrix_world.translation + direction_world * delta
|
||||
|
||||
|
||||
def _get_offset(props: FillingProps, edge: _Edge) -> float:
|
||||
"""SI distance from the wall edge to the filling's matching edge on the same axis."""
|
||||
geom = _host_wall_geom(props.id_data)
|
||||
if not geom:
|
||||
return 0.0
|
||||
filling = _filling_axis_extent(props, geom.wall_obj, edge.axis_index)
|
||||
wall = _wall_axis_extent(geom, edge.axis_index)
|
||||
return _offset_from_extents(filling, wall, edge.is_max_end)
|
||||
|
||||
|
||||
def _set_offset(props: FillingProps, edge: _Edge, value: float) -> None:
|
||||
"""Translate the filling so its offset to ``edge`` becomes ``max(0, value)`` SI metres.
|
||||
Max-end edges (right/top) translate in the opposite direction of near-end edges."""
|
||||
geom = _host_wall_geom(props.id_data)
|
||||
if not geom:
|
||||
return
|
||||
current = _get_offset(props, edge)
|
||||
target = max(0.0, value)
|
||||
delta = (current - target) if edge.is_max_end else (target - current)
|
||||
_translate_along_wall_axis(props, geom.wall_obj, delta, edge.axis_index)
|
||||
|
||||
|
||||
def has_host_wall(props: FillingProps) -> bool:
|
||||
"""True when the filling resolves to a LAYER2 host wall present in the scene."""
|
||||
return _host_wall_geom(props.id_data) is not None
|
||||
|
||||
|
||||
def _edge_position(props: FillingProps, edge: _Edge) -> Vector:
|
||||
"""Gizmo anchor in filling-local space, at the wall edge, pointing toward the filling."""
|
||||
geom = _host_wall_geom(props.id_data)
|
||||
if not geom:
|
||||
if edge.axis_index == _AXIS_X:
|
||||
return Vector((0.0, 0.0, props.overall_height / 2))
|
||||
return Vector((props.overall_width / 2, 0.0, props.overall_height if edge.is_max_end else 0.0))
|
||||
wall = _wall_axis_extent(geom, edge.axis_index)
|
||||
edge_value = wall.high if edge.is_max_end else wall.low
|
||||
if edge.axis_index == _AXIS_X:
|
||||
wall_edge_world = geom.wall_obj.matrix_world @ Vector((edge_value, 0.0, 0.0))
|
||||
pos = props.id_data.matrix_world.inverted() @ wall_edge_world
|
||||
return Vector((pos.x, 0.0, props.overall_height / 2))
|
||||
# LAYER2 wall matrix_world is upright, so wall-local Z and filling-local Z differ
|
||||
# only by the filling's Z origin in the wall frame.
|
||||
filling_z_in_wall = _filling_axis_extent(props, geom.wall_obj, axis_index=_AXIS_Z).low
|
||||
return Vector((props.overall_width / 2, 0.0, edge_value - filling_z_in_wall))
|
||||
|
||||
|
||||
def _compute_value(props: FillingProps, edge: _Edge) -> float:
|
||||
"""Renderer-side value. X-axis edges return a signed value so the gizmo's
|
||||
auto-flip kicks in for fillings on the wall's opposite face; Z-axis returns unsigned."""
|
||||
geom = _host_wall_geom(props.id_data)
|
||||
if not geom:
|
||||
return 0.0
|
||||
filling = _filling_axis_extent(props, geom.wall_obj, edge.axis_index)
|
||||
wall = _wall_axis_extent(geom, edge.axis_index)
|
||||
return filling.x_sign * _offset_from_extents(filling, wall, edge.is_max_end)
|
||||
|
||||
|
||||
def _apply_value(props: FillingProps, edge: _Edge, value: float) -> None:
|
||||
"""Drag-end commit; X-axis takes ``abs(value)`` since the negative sign in compute
|
||||
is a rendering hint only (user-facing offset is always positive)."""
|
||||
if edge.axis_index == _AXIS_X:
|
||||
_set_offset(props, edge, abs(value))
|
||||
else:
|
||||
_set_offset(props, edge, value)
|
||||
|
||||
|
||||
# attr_name identifies the gizmo within its group; values flow through
|
||||
# compute/apply, not via a registered property.
|
||||
WALL_OFFSET_GIZMO_CONFIGS: list[DimensionGizmoConfig] = [
|
||||
DimensionGizmoConfig(
|
||||
attr_name="host_wall_offset_left",
|
||||
axis=(1, 0, 0),
|
||||
visibility_condition=has_host_wall,
|
||||
compute_value=lambda p: _compute_value(p, _LEFT),
|
||||
apply_value=lambda p, v: _apply_value(p, _LEFT, v),
|
||||
matrix_position=lambda p: _edge_position(p, _LEFT),
|
||||
),
|
||||
DimensionGizmoConfig(
|
||||
attr_name="host_wall_offset_right",
|
||||
axis=(-1, 0, 0),
|
||||
visibility_condition=has_host_wall,
|
||||
compute_value=lambda p: _compute_value(p, _RIGHT),
|
||||
apply_value=lambda p, v: _apply_value(p, _RIGHT, v),
|
||||
matrix_position=lambda p: _edge_position(p, _RIGHT),
|
||||
),
|
||||
DimensionGizmoConfig(
|
||||
attr_name="host_wall_offset_bottom",
|
||||
axis=(0, 0, 1),
|
||||
visibility_condition=has_host_wall,
|
||||
compute_value=lambda p: _compute_value(p, _BOTTOM),
|
||||
apply_value=lambda p, v: _apply_value(p, _BOTTOM, v),
|
||||
matrix_position=lambda p: _edge_position(p, _BOTTOM),
|
||||
),
|
||||
DimensionGizmoConfig(
|
||||
attr_name="host_wall_offset_top",
|
||||
axis=(0, 0, -1),
|
||||
visibility_condition=has_host_wall,
|
||||
compute_value=lambda p: _compute_value(p, _TOP),
|
||||
apply_value=lambda p, v: _apply_value(p, _TOP, v),
|
||||
matrix_position=lambda p: _edge_position(p, _TOP),
|
||||
),
|
||||
]
|
||||
@@ -39,7 +39,8 @@ import bonsai.core.root
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.drawing import gizmos as gizmo
|
||||
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
|
||||
from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin
|
||||
from bonsai.bim.module.model.wall_offset_gizmos import WALL_OFFSET_GIZMO_CONFIGS
|
||||
from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin, PickTypeMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bonsai.bim.module.model.prop import BIMWindowProperties
|
||||
@@ -491,7 +492,7 @@ class _WindowEditMixin(FeatureModifierEditMixin):
|
||||
|
||||
@classmethod
|
||||
def _is_element_type(cls, element):
|
||||
return tool.Blender.Modifier.is_window(element)
|
||||
return tool.Parametric.is_window(element)
|
||||
|
||||
@classmethod
|
||||
def _get_props(cls, obj: bpy.types.Object):
|
||||
@@ -551,11 +552,11 @@ class RemoveWindow(bpy.types.Operator, tool.Ifc.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class CycleWindowType(bpy.types.Operator, tool.Ifc.Operator, gizmo.CycleTypeMixin):
|
||||
"""Cycle through available window types. Shift+click to cycle in reverse."""
|
||||
class PickWindowType(bpy.types.Operator, tool.Ifc.Operator, PickTypeMixin):
|
||||
"""Pick a window type from a popup menu."""
|
||||
|
||||
bl_idname = "bim.cycle_window_type"
|
||||
bl_label = "Cycle Window Type"
|
||||
bl_idname = "bim.pick_window_type"
|
||||
bl_label = "Pick Window Type"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
element_checker = tool.Parametric.is_window
|
||||
@@ -564,7 +565,7 @@ class CycleWindowType(bpy.types.Operator, tool.Ifc.Operator, gizmo.CycleTypeMixi
|
||||
type_attr = "window_type"
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
return self._cycle_type(context)
|
||||
return self._pick_type(context)
|
||||
|
||||
|
||||
# Frame accessor factory - creates callbacks that delegate to BIMWindowProperties methods
|
||||
@@ -602,7 +603,7 @@ class GizmoWindowEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
enable_editing_operator = "bim.enable_editing_window"
|
||||
finish_editing_operator = "bim.finish_editing_window"
|
||||
cancel_editing_operator = "bim.cancel_editing_window"
|
||||
cycle_type_operator = "bim.cycle_window_type"
|
||||
pick_type_operator = "bim.pick_window_type"
|
||||
|
||||
# matrix_position lambdas replace the get_dimension_matrix_* methods
|
||||
dimension_gizmo_props = [
|
||||
@@ -743,6 +744,7 @@ 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
|
||||
@@ -750,7 +752,7 @@ class GizmoWindowEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
|
||||
@classmethod
|
||||
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
return tool.Blender.Modifier.is_window(element)
|
||||
return tool.Parametric.is_window(element)
|
||||
|
||||
def get_icon_y_extent(self, props: "BIMWindowProperties") -> tuple[float, float]:
|
||||
"""Get Y extents for window icon positioning.
|
||||
|
||||
@@ -969,7 +969,9 @@ class EditObjectUI:
|
||||
|
||||
if PortData.data["total_ports"] > 0:
|
||||
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
|
||||
add_layout_hotkey_operator(row, "Regen", "S_G", bpy.ops.bim.regenerate_distribution_element.__doc__, ui_context)
|
||||
add_layout_hotkey_operator(
|
||||
row, "Regen", "S_G", bpy.ops.bim.regenerate_distribution_element.__doc__, ui_context
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def draw_void(cls, context, row):
|
||||
@@ -1442,10 +1444,7 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bpy.ops.bim.enable_editing_extrusion_axis()
|
||||
|
||||
def hotkey_A_O(self):
|
||||
if tool.Model.get_model_props().openings:
|
||||
bpy.ops.bim.edit_openings(apply_all=True)
|
||||
else:
|
||||
bpy.ops.bim.show_openings()
|
||||
bpy.ops.bim.toggle_host_openings()
|
||||
|
||||
def hotkey_C_E(self):
|
||||
if not bpy.context.selected_objects:
|
||||
|
||||
@@ -964,11 +964,11 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
|
||||
use_relative_path: bpy.props.BoolProperty(
|
||||
name="Use Relative Path",
|
||||
description="Store the IFC project path relative to the .blend file. Requires .blend file to be saved",
|
||||
default=False,
|
||||
default=True,
|
||||
)
|
||||
should_start_fresh_session: bpy.props.BoolProperty(
|
||||
name="Should Start Fresh Session",
|
||||
description="Clear current Blender session before loading IFC. Not supported with 'Use Relative Path' option",
|
||||
description="Clear current Blender session before loading IFC",
|
||||
default=True,
|
||||
)
|
||||
import_without_ifc_data: bpy.props.BoolProperty(
|
||||
@@ -1076,9 +1076,6 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
|
||||
bpy.app.handlers.load_post.remove(load_handler)
|
||||
self.finish_loading_project(context)
|
||||
|
||||
if self.use_relative_path:
|
||||
self.should_start_fresh_session = False
|
||||
|
||||
if self.should_start_fresh_session:
|
||||
# WARNING: wm.read_homefile clears context which could lead to some
|
||||
# operators to fail:
|
||||
@@ -1143,8 +1140,6 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
|
||||
return ImportHelper.invoke(self, context, event)
|
||||
|
||||
def draw(self, context):
|
||||
if self.use_relative_path:
|
||||
self.should_start_fresh_session = False
|
||||
self.layout.prop(self, "is_advanced")
|
||||
self.layout.prop(self, "should_start_fresh_session")
|
||||
self.layout.prop(self, "import_without_ifc_data")
|
||||
@@ -1875,7 +1870,7 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
|
||||
json_version: bpy.props.EnumProperty(items=[("4", "4", ""), ("5a", "5a", "")], name="IFC JSON Version")
|
||||
json_compact: bpy.props.BoolProperty(name="Export Compact IFCJSON", default=False)
|
||||
should_save_as: bpy.props.BoolProperty(name="Should Save As", default=False, options={"HIDDEN"})
|
||||
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False)
|
||||
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=True)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
filter_glob: str
|
||||
|
||||
@@ -444,7 +444,7 @@ class BIMProjectProperties(PropertyGroup):
|
||||
items=get_parent_libaries,
|
||||
)
|
||||
|
||||
use_relative_project_path: BoolProperty(name="Use Relative Project Path", default=False)
|
||||
use_relative_project_path: BoolProperty(name="Use Relative Project Path", default=True)
|
||||
should_save_metadata_for_this_file: BoolProperty(
|
||||
name="Save Session Data for This File",
|
||||
description="Enable saving session data (window layout, settings) to a metadata blend file for this specific IFC file",
|
||||
|
||||
@@ -36,9 +36,17 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_description = (
|
||||
"Apply opening objects to an Element.\n\n"
|
||||
"The Element and the openings to be applied should be selected. The order of selection is not important.\n"
|
||||
"Opening can be just a Blender mesh object."
|
||||
"Opening can be just a Blender mesh object.\n\n"
|
||||
"Shift+click: keep the filling at its current matrix_world — skip the wall-axis snap "
|
||||
"and the rl1/rl2 Z-elevation default that the regular click applies."
|
||||
)
|
||||
|
||||
# Toggled by ``invoke`` when the user holds SHIFT during a gizmo / hotkey
|
||||
# click. The filling-opening generator gates its snap-to-wall-axis block
|
||||
# on this flag. HIDDEN + SKIP_SAVE so the flag doesn't surface in the F6
|
||||
# redo panel or persist into saved keymaps.
|
||||
preserve_placement: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"})
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
if len(context.selected_objects) < 2:
|
||||
@@ -46,6 +54,10 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
|
||||
return False
|
||||
return True
|
||||
|
||||
def invoke(self, context, event):
|
||||
self.preserve_placement = bool(event.shift)
|
||||
return self.execute(context)
|
||||
|
||||
def _execute(self, context):
|
||||
selected_objects = context.selected_objects
|
||||
target_object = selected_objects[0]
|
||||
@@ -68,7 +80,12 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
|
||||
elif not element1.is_a("IfcOpeningElement") and not element2.is_a("IfcOpeningElement"):
|
||||
if element1.is_a("IfcWindow") or element1.is_a("IfcDoor"): # Add a fill to an element.
|
||||
obj1, obj2 = obj2, obj1
|
||||
FilledOpeningGenerator().generate(obj2, obj1, target=obj2.matrix_world.translation)
|
||||
FilledOpeningGenerator().generate(
|
||||
obj2,
|
||||
obj1,
|
||||
target=obj2.matrix_world.translation,
|
||||
preserve_placement=self.preserve_placement,
|
||||
)
|
||||
continue
|
||||
elif element1.is_a("IfcOpeningElement") or element2.is_a("IfcOpeningElement"):
|
||||
if element1.is_a("IfcOpeningElement"): # Reassign an opening to another element.
|
||||
|
||||
@@ -219,7 +219,7 @@ class SelectIfcFile(bpy.types.Operator, IFCFileSelector, ImportHelper):
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = f"Select a different IFC file.\n{tool.Blender.operator_invoke_filepath_hotkeys_description}"
|
||||
filter_glob: bpy.props.StringProperty(default="*.ifc;*.ifczip;*.ifcxml", options={"HIDDEN"})
|
||||
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False)
|
||||
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=True)
|
||||
filename_ext = ".ifc"
|
||||
|
||||
def execute(self, context):
|
||||
|
||||
@@ -61,7 +61,7 @@ Pattern selection (which approach a new feature should adopt):
|
||||
The authoritative list of registered parametric types — and which use
|
||||
`build_edit_lifecycle` vs. standalone operators — lives in
|
||||
`tool/parametric.py`'s `EDIT_TYPES` and is enforced by the registry
|
||||
contract tests in `test/bim/test_parametric_registry.py`.
|
||||
contract tests.
|
||||
|
||||
This module hosts operator-side mixins that import ``bonsai.tool`` freely.
|
||||
The lightweight parametric registry consumed at addon-enable time must stay
|
||||
@@ -528,6 +528,51 @@ class PickTypeMixin(TypeAccessorBase):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class IntegerInputDialogMixin:
|
||||
"""Operator mixin that mirrors a per-feature ``IntProperty`` on the
|
||||
operator into a draft attribute on the active object's parametric props,
|
||||
via Blender's ``invoke_props_dialog`` popup.
|
||||
|
||||
Subclasses declare:
|
||||
|
||||
- ``attr_name`` — name of the IntProperty on the subclass AND of the
|
||||
attribute on the resolved props (same name on both sides).
|
||||
- ``props_getter`` — ``staticmethod(tool.Model.get_<feature>_props)``.
|
||||
- ``requires_editing`` — True iff the operator must no-op outside an
|
||||
active edit lifecycle. Default False.
|
||||
- ``value_min`` — minimum value to clamp to. Default 1."""
|
||||
|
||||
attr_name: ClassVar[str] = ""
|
||||
props_getter: ClassVar[Callable[[bpy.types.Object], bpy.types.PropertyGroup]]
|
||||
requires_editing: ClassVar[bool] = False
|
||||
value_min: ClassVar[int] = 1
|
||||
|
||||
def _resolve_props(self, context: bpy.types.Context) -> bpy.types.PropertyGroup | None:
|
||||
"""Return the active object's parametric props if the operator is
|
||||
allowed to fire, ``None`` otherwise (caller bails with ``CANCELLED``)."""
|
||||
obj = context.active_object
|
||||
if not obj:
|
||||
return None
|
||||
props = self.props_getter(obj)
|
||||
if self.requires_editing and not props.is_editing:
|
||||
return None
|
||||
return props
|
||||
|
||||
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]: # noqa: ARG002
|
||||
props = self._resolve_props(context)
|
||||
if props is None:
|
||||
return {"CANCELLED"}
|
||||
setattr(self, self.attr_name, max(self.value_min, getattr(props, self.attr_name)))
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
def execute(self, context: bpy.types.Context) -> set[str]:
|
||||
props = self._resolve_props(context)
|
||||
if props is None:
|
||||
return {"CANCELLED"}
|
||||
setattr(props, self.attr_name, max(self.value_min, getattr(self, self.attr_name)))
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
# --- Undo-resync registry ----------------------------------------------------
|
||||
#
|
||||
# Per-type regenerators called from ``resync_parametric_drafts_after_undo``
|
||||
|
||||
+26
-240
@@ -94,7 +94,10 @@ class IFCFileSelector:
|
||||
filepath = self.get_filepath_abs()
|
||||
|
||||
if self.use_relative_path:
|
||||
filepath = filepath.relative_to(bpy.path.abspath("//"))
|
||||
try:
|
||||
filepath = filepath.relative_to(bpy.path.abspath("//"))
|
||||
except ValueError:
|
||||
pass # IFC file is not under the blend directory; keep absolute path
|
||||
return filepath.as_posix().replace("\\", "/")
|
||||
|
||||
def draw(self, context: bpy.types.Context) -> None:
|
||||
@@ -275,202 +278,33 @@ class BIM_UL_panel_visibilities(bpy.types.UIList):
|
||||
row.prop(item, "is_bookmarked", text="", icon="SOLO_ON" if item.is_bookmarked else "SOLO_OFF", emboss=False)
|
||||
|
||||
|
||||
class GizmoPreferencesDoor(bpy.types.PropertyGroup):
|
||||
"""Property group for door gizmo visibility settings."""
|
||||
|
||||
overall_height: BoolProperty(name="Overall Height", default=True)
|
||||
overall_width: BoolProperty(name="Overall Width", default=True)
|
||||
threshold_thickness: BoolProperty(name="Threshold Thickness", default=True)
|
||||
threshold_depth: BoolProperty(name="Threshold Depth", default=True)
|
||||
threshold_offset: BoolProperty(name="Threshold Offset", default=True)
|
||||
lining_offset: BoolProperty(name="Lining Offset", default=True)
|
||||
lining_depth: BoolProperty(name="Lining Depth", default=True)
|
||||
lining_thickness: BoolProperty(name="Lining Thickness", default=True)
|
||||
transom_offset: BoolProperty(name="Transom Offset", default=True)
|
||||
transom_thickness: BoolProperty(name="Transom Thickness", default=True)
|
||||
casing_thickness: BoolProperty(name="Casing Thickness", default=True)
|
||||
casing_depth: BoolProperty(name="Casing Depth", default=True)
|
||||
swing_arc: BoolProperty(name="Swing Arc", default=True, description="Show door swing direction arc")
|
||||
flip_arc: BoolProperty(name="Flip Arc", default=True, description="Show flip door orientation arc")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
overall_height: bool
|
||||
overall_width: bool
|
||||
threshold_thickness: bool
|
||||
threshold_depth: bool
|
||||
threshold_offset: bool
|
||||
lining_offset: bool
|
||||
lining_depth: bool
|
||||
lining_thickness: bool
|
||||
transom_offset: bool
|
||||
transom_thickness: bool
|
||||
casing_thickness: bool
|
||||
casing_depth: bool
|
||||
swing_arc: bool
|
||||
flip_arc: bool
|
||||
|
||||
|
||||
class GizmoPreferencesWindow(bpy.types.PropertyGroup):
|
||||
"""Property group for window gizmo visibility settings."""
|
||||
|
||||
overall_height: BoolProperty(name="Overall Height", default=True)
|
||||
overall_width: BoolProperty(name="Overall Width", default=True)
|
||||
lining_offset: BoolProperty(name="Lining Offset", default=True)
|
||||
lining_depth: BoolProperty(name="Lining Depth", default=True)
|
||||
lining_thickness: BoolProperty(name="Lining Thickness", default=True)
|
||||
lining_to_panel_offset_x: BoolProperty(name="Lining to Panel Offset X", default=True)
|
||||
lining_to_panel_offset_y: BoolProperty(name="Lining to Panel Offset Y", default=True)
|
||||
frame_depth: BoolProperty(name="Frame Depth", default=True)
|
||||
frame_thickness: BoolProperty(name="Frame Thickness", default=True)
|
||||
mullion_thickness: BoolProperty(name="Mullion Thickness", default=True)
|
||||
first_mullion_offset: BoolProperty(name="First Mullion Offset", default=True)
|
||||
second_mullion_offset: BoolProperty(name="Second Mullion Offset", default=True)
|
||||
transom_thickness: BoolProperty(name="Transom Thickness", default=True)
|
||||
first_transom_offset: BoolProperty(name="First Transom Offset", default=True)
|
||||
second_transom_offset: BoolProperty(name="Second Transom Offset", default=True)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
overall_height: bool
|
||||
overall_width: bool
|
||||
lining_offset: bool
|
||||
lining_depth: bool
|
||||
lining_thickness: bool
|
||||
lining_to_panel_offset_x: bool
|
||||
lining_to_panel_offset_y: bool
|
||||
frame_depth: bool
|
||||
frame_thickness: bool
|
||||
mullion_thickness: bool
|
||||
first_mullion_offset: bool
|
||||
second_mullion_offset: bool
|
||||
transom_thickness: bool
|
||||
first_transom_offset: bool
|
||||
second_transom_offset: bool
|
||||
|
||||
|
||||
class GizmoPreferencesStair(bpy.types.PropertyGroup):
|
||||
"""Property group for stair gizmo visibility settings."""
|
||||
|
||||
width: BoolProperty(name="Width", default=True)
|
||||
height: BoolProperty(name="Height", default=True)
|
||||
tread_run: BoolProperty(name="Tread Run", default=True)
|
||||
tread_depth: BoolProperty(name="Tread Depth", default=True)
|
||||
riser_height: BoolProperty(name="Riser Height", default=True)
|
||||
nosing_length: BoolProperty(name="Nosing Length", default=True)
|
||||
nosing_depth: BoolProperty(name="Nosing Depth", default=True)
|
||||
total_length_target: BoolProperty(name="Total Length Target", default=True)
|
||||
base_slab_depth: BoolProperty(name="Base Slab Depth", default=True)
|
||||
top_slab_depth: BoolProperty(name="Top Slab Depth", default=True)
|
||||
lock: BoolProperty(name="Total Length Lock", default=True)
|
||||
plus: BoolProperty(name="Add Tread (+)", default=True)
|
||||
minus: BoolProperty(name="Remove Tread (-)", default=True)
|
||||
cycle: BoolProperty(name="Cycle Stair Type", default=True)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
width: bool
|
||||
height: bool
|
||||
tread_run: bool
|
||||
tread_depth: bool
|
||||
riser_height: bool
|
||||
nosing_length: bool
|
||||
nosing_depth: bool
|
||||
total_length_target: bool
|
||||
base_slab_depth: bool
|
||||
top_slab_depth: bool
|
||||
lock: bool
|
||||
plus: bool
|
||||
minus: bool
|
||||
cycle: bool
|
||||
|
||||
|
||||
class GizmoPreferencesWall(bpy.types.PropertyGroup):
|
||||
"""Property group for wall gizmo visibility settings."""
|
||||
|
||||
length: BoolProperty(
|
||||
name="Length",
|
||||
default=True,
|
||||
description="Show the length dimension gizmo along the wall axis.",
|
||||
)
|
||||
height: BoolProperty(
|
||||
name="Height",
|
||||
default=True,
|
||||
description="Show the height dimension gizmo at the wall's start endpoint.",
|
||||
)
|
||||
height_end: BoolProperty(
|
||||
name="Height (far end, walls > 5m)",
|
||||
default=True,
|
||||
description=(
|
||||
"Show a second height gizmo at the wall's far end so long walls don't "
|
||||
"require panning to reach the handle."
|
||||
),
|
||||
)
|
||||
x_angle: BoolProperty(
|
||||
name="Slope",
|
||||
default=True,
|
||||
description="Show the slope gizmo at the wall top measuring horizontal displacement of the top face.",
|
||||
)
|
||||
cycle: BoolProperty(
|
||||
name="Cycle Offset Baseline",
|
||||
default=True,
|
||||
description="Show the baseline-state icon (Exterior / Centreline / Interior) in the editing icon row.",
|
||||
)
|
||||
scissors: BoolProperty(
|
||||
name="Split at cursor",
|
||||
default=True,
|
||||
description="Show the split icon at the 3D cursor when it lies within the wall's length range.",
|
||||
)
|
||||
extend: BoolProperty(
|
||||
name="Extend length to cursor X",
|
||||
default=True,
|
||||
description="Show the extend-length icon at the 3D cursor's projected wall-axis X.",
|
||||
)
|
||||
extend_height: BoolProperty(
|
||||
name="Extend height to cursor Z",
|
||||
default=True,
|
||||
description="Show the extend-height icon at the 3D cursor's Z, on the wall axis.",
|
||||
)
|
||||
rotate: BoolProperty(
|
||||
name="Rotate 90°",
|
||||
default=True,
|
||||
description="Show the rotate-90 icon in the editing icon row (rotates the wall around its Z axis).",
|
||||
)
|
||||
toggle_openings: BoolProperty(
|
||||
name="Toggle Openings",
|
||||
default=True,
|
||||
description="Show the toggle-openings icon next to the pen (toggles opening fill visibility in the viewport).",
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
length: bool
|
||||
height: bool
|
||||
height_end: bool
|
||||
x_angle: bool
|
||||
cycle: bool
|
||||
scissors: bool
|
||||
extend: bool
|
||||
extend_height: bool
|
||||
rotate: bool
|
||||
toggle_openings: bool
|
||||
|
||||
|
||||
class GizmoPreferences(bpy.types.PropertyGroup):
|
||||
"""Property group for all gizmo visibility settings."""
|
||||
"""Aggregator for parametric gizmo visibility settings. One flat bool per
|
||||
parametric feature; controls whether that feature's gizmo group polls
|
||||
visible in the viewport."""
|
||||
|
||||
draw_gizmos_in_3d_viewport: BoolProperty(
|
||||
name="Draw Gizmos In 3D Viewport",
|
||||
default=True,
|
||||
description="Show interactive gizmos in the 3D viewport for parametric elements",
|
||||
)
|
||||
door: bpy.props.PointerProperty(type=GizmoPreferencesDoor)
|
||||
window: bpy.props.PointerProperty(type=GizmoPreferencesWindow)
|
||||
stair: bpy.props.PointerProperty(type=GizmoPreferencesStair)
|
||||
wall: bpy.props.PointerProperty(type=GizmoPreferencesWall)
|
||||
door: BoolProperty(name="Door", default=True)
|
||||
window: BoolProperty(name="Window", default=True)
|
||||
stair: BoolProperty(name="Stair", default=True)
|
||||
railing: BoolProperty(name="Railing", default=True)
|
||||
roof: BoolProperty(name="Roof", default=True)
|
||||
array: BoolProperty(name="Array", default=True)
|
||||
wall: BoolProperty(name="Wall", default=True)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
draw_gizmos_in_3d_viewport: bool
|
||||
door: GizmoPreferencesDoor
|
||||
window: GizmoPreferencesWindow
|
||||
stair: GizmoPreferencesStair
|
||||
wall: GizmoPreferencesWall
|
||||
door: bool
|
||||
window: bool
|
||||
stair: bool
|
||||
railing: bool
|
||||
roof: bool
|
||||
array: bool
|
||||
wall: bool
|
||||
|
||||
|
||||
class DocPreferences(bpy.types.PropertyGroup):
|
||||
@@ -918,61 +752,13 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
|
||||
)
|
||||
|
||||
def draw_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
|
||||
"""Render one enabled-toggle per parametric feature."""
|
||||
layout.label(text="Toggle visibility of gizmos in editing mode")
|
||||
box = layout.box()
|
||||
bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Door", self.draw_door_gizmo_parameters)
|
||||
bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Window", self.draw_window_gizmo_parameters)
|
||||
bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Stair", self.draw_stair_gizmo_parameters)
|
||||
bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Wall", self.draw_wall_gizmo_parameters)
|
||||
|
||||
def _draw_parametric_gizmo_parameters(
|
||||
self,
|
||||
layout: bpy.types.UILayout,
|
||||
gizmo_pg: bpy.types.PropertyGroup,
|
||||
dimension_gizmo_class: type,
|
||||
special_gizmo_names: frozenset[str] = frozenset(),
|
||||
) -> None:
|
||||
"""Draw the per-element gizmo visibility toggles. Surfaces every annotation
|
||||
on ``gizmo_pg`` that either maps to one of ``dimension_gizmo_class``'s
|
||||
dimension gizmos or is named in ``special_gizmo_names`` (non-dimension icons
|
||||
like baseline cycle, scissors, rotate, …)."""
|
||||
visible_names = {p.attr_name for p in dimension_gizmo_class.dimension_gizmo_props} | special_gizmo_names
|
||||
try:
|
||||
annotations = gizmo_pg.__annotations__
|
||||
except AttributeError:
|
||||
annotations = type(gizmo_pg).__annotations__
|
||||
for prop in annotations:
|
||||
if prop in visible_names:
|
||||
layout.prop(gizmo_pg, prop)
|
||||
|
||||
def draw_door_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
|
||||
from bonsai.bim.module.model.door import GizmoDoorEdition
|
||||
|
||||
self._draw_parametric_gizmo_parameters(
|
||||
layout, self.gizmos.door, GizmoDoorEdition, frozenset({"swing_arc", "flip_arc"})
|
||||
)
|
||||
|
||||
def draw_window_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
|
||||
from bonsai.bim.module.model.window import GizmoWindowEdition
|
||||
|
||||
self._draw_parametric_gizmo_parameters(layout, self.gizmos.window, GizmoWindowEdition)
|
||||
|
||||
def draw_stair_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
|
||||
from bonsai.bim.module.model.stair import GizmoStairEdition
|
||||
|
||||
self._draw_parametric_gizmo_parameters(
|
||||
layout, self.gizmos.stair, GizmoStairEdition, frozenset({"lock", "plus", "minus", "cycle"})
|
||||
)
|
||||
|
||||
def draw_wall_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
|
||||
from bonsai.bim.module.model.wall import GizmoWallEdition
|
||||
|
||||
self._draw_parametric_gizmo_parameters(
|
||||
layout,
|
||||
self.gizmos.wall,
|
||||
GizmoWallEdition,
|
||||
frozenset({"cycle", "scissors", "extend", "extend_height", "rotate", "toggle_openings"}),
|
||||
)
|
||||
annotations = type(self.gizmos).__annotations__
|
||||
for feature in tool.Parametric.EDIT_TYPES:
|
||||
if feature.name in annotations:
|
||||
box.prop(self.gizmos, feature.name)
|
||||
|
||||
def draw_model_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
|
||||
layout.prop(self, "occurrence_name_style")
|
||||
|
||||
@@ -20,8 +20,6 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
import ifcopenshell.util.element
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
@@ -58,31 +56,12 @@ def copy_class(
|
||||
geometry.change_object_data(obj, data, is_global=True)
|
||||
geometry.rename_object(data, geometry.get_representation_name(ifc.get_entity(data)))
|
||||
# Only assign styles if element doesn't get them from material
|
||||
if not _has_material_styles(ifc, new):
|
||||
if not root.has_material_styles(new):
|
||||
root.assign_body_styles(new, obj)
|
||||
collector.assign(obj)
|
||||
return new
|
||||
|
||||
|
||||
def _has_material_styles(ifc: type[tool.Ifc], element: ifcopenshell.entity_instance) -> bool:
|
||||
"""Check if element has styles defined through its material.
|
||||
|
||||
Returns True if any constituent material has a style representation,
|
||||
which means styles should NOT be applied directly to the geometry.
|
||||
"""
|
||||
materials = ifcopenshell.util.element.get_materials(element)
|
||||
|
||||
if not materials:
|
||||
return False
|
||||
|
||||
# Check if any of the constituent materials have styles
|
||||
for material in materials:
|
||||
if hasattr(material, "HasRepresentation") and material.HasRepresentation:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def assign_class(
|
||||
ifc: type[tool.Ifc],
|
||||
collector: type[tool.Collector],
|
||||
|
||||
@@ -64,7 +64,7 @@ def assign_container(
|
||||
spatial.disable_editing(obj)
|
||||
all_elements.add(root_element)
|
||||
all_elements.update(spatial.get_decomposition(root_element))
|
||||
if products := [e for e in root_elements if spatial.can_contain(container, root_element)]:
|
||||
if products := [e for e in root_elements if spatial.can_contain(container, e)]:
|
||||
ifc.run("spatial.assign_container", products=products, relating_structure=container)
|
||||
for element in all_elements:
|
||||
if obj := ifc.get_object(element):
|
||||
|
||||
@@ -459,7 +459,6 @@ class Geometry:
|
||||
def has_axis_representation(cls, element): pass
|
||||
def has_data_users(cls, data): pass
|
||||
def has_material_style_override(cls, obj): pass
|
||||
def has_material_styles(cls, element): pass
|
||||
def import_representation_parameters(cls, data): pass
|
||||
def is_body_representation(cls, representation): pass
|
||||
def is_box_representation(cls, representation): pass
|
||||
@@ -796,7 +795,7 @@ class Profile:
|
||||
@interface
|
||||
class Parametric:
|
||||
def get_geom_generation(cls) -> int: pass
|
||||
def refresh_post_commit(cls) -> None: pass
|
||||
def refresh_post_commit(cls, operator) -> None: pass
|
||||
|
||||
|
||||
@interface
|
||||
@@ -888,6 +887,7 @@ class Root:
|
||||
def get_object_name(cls, obj): pass
|
||||
def get_object_representation(cls, obj): pass
|
||||
def get_representation_context(cls, representation): pass
|
||||
def has_material_styles(cls, element): pass
|
||||
def is_containable(cls, element): pass
|
||||
def is_drawing_annotation(cls, element): pass
|
||||
def is_element_a(cls, element, ifc_class): pass
|
||||
|
||||
@@ -229,15 +229,22 @@ class Blender(bonsai.core.tool.Blender):
|
||||
|
||||
@classmethod
|
||||
def get_active_object(cls, is_selected: bool = False) -> Union[bpy.types.Object, None]:
|
||||
"""Gets the active object
|
||||
"""Return the active object, or ``None`` when the current context
|
||||
exposes neither ``active_object`` nor a ``view_layer`` (stripped
|
||||
operator contexts).
|
||||
|
||||
:param is_selected: If true, the active object also needs to be selected.
|
||||
"""
|
||||
if obj := (getattr(bpy.context, "active_object", None) or bpy.context.view_layer.objects.active):
|
||||
if not is_selected:
|
||||
return obj
|
||||
if obj.select_get():
|
||||
return obj
|
||||
obj = getattr(bpy.context, "active_object", None)
|
||||
if obj is None:
|
||||
view_layer = getattr(bpy.context, "view_layer", None)
|
||||
if view_layer is not None:
|
||||
obj = view_layer.objects.active
|
||||
if obj is None:
|
||||
return None
|
||||
if is_selected and not obj.select_get():
|
||||
return None
|
||||
return obj
|
||||
|
||||
@classmethod
|
||||
def get_selected_objects(cls, include_active: bool = True) -> set[bpy.types.Object]:
|
||||
@@ -880,19 +887,57 @@ class Blender(bonsai.core.tool.Blender):
|
||||
# ( 1.0, 1.0, -1.0), # 7
|
||||
# ]
|
||||
bound_box = obj.bound_box
|
||||
min_pt = Vector(bound_box[0])
|
||||
max_pt = Vector(bound_box[6])
|
||||
bbox_dict = {
|
||||
"min_x": bound_box[0][0],
|
||||
"max_x": bound_box[6][0],
|
||||
"min_y": bound_box[0][1],
|
||||
"max_y": bound_box[6][1],
|
||||
"min_z": bound_box[0][2],
|
||||
"max_z": bound_box[6][2],
|
||||
"min_point": Vector(bound_box[0]),
|
||||
"max_point": Vector(bound_box[6]),
|
||||
"center": (Vector(bound_box[6]) + Vector(bound_box[0])) / 2,
|
||||
"min_x": min_pt.x,
|
||||
"max_x": max_pt.x,
|
||||
"min_y": min_pt.y,
|
||||
"max_y": max_pt.y,
|
||||
"min_z": min_pt.z,
|
||||
"max_z": max_pt.z,
|
||||
"min_point": min_pt,
|
||||
"max_point": max_pt,
|
||||
"center": (max_pt + min_pt) / 2,
|
||||
# Intrinsic per-axis size in object-local space. Distinct from
|
||||
# ``obj.dimensions``, which folds object-level scale into its
|
||||
# output; this is the raw mesh bbox extent.
|
||||
"dimensions": (max_pt.x - min_pt.x, max_pt.y - min_pt.y, max_pt.z - min_pt.z),
|
||||
}
|
||||
return bbox_dict
|
||||
|
||||
@classmethod
|
||||
def get_object_world_bounding_box(cls, obj: bpy.types.Object) -> dict[str, Union[float, Vector]]:
|
||||
"""Same shape as ``get_object_bounding_box`` but with ``matrix_world``
|
||||
applied — extents are computed across the 8 transformed corners, so
|
||||
a rotated or scaled object reports its actual world-axis AABB rather
|
||||
than the misleading transform of the local-space corners.
|
||||
|
||||
``bound_box[0]`` / ``bound_box[6]`` are the local min/max corners but
|
||||
do NOT correspond to the world AABB extremes once the object is
|
||||
rotated, so min/max must be taken per-axis across all 8 corners."""
|
||||
corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
|
||||
xs = [c.x for c in corners]
|
||||
ys = [c.y for c in corners]
|
||||
zs = [c.z for c in corners]
|
||||
min_point = Vector((min(xs), min(ys), min(zs)))
|
||||
max_point = Vector((max(xs), max(ys), max(zs)))
|
||||
return {
|
||||
"min_x": min_point.x,
|
||||
"max_x": max_point.x,
|
||||
"min_y": min_point.y,
|
||||
"max_y": max_point.y,
|
||||
"min_z": min_point.z,
|
||||
"max_z": max_point.z,
|
||||
"min_point": min_point,
|
||||
"max_point": max_point,
|
||||
"center": (min_point + max_point) / 2,
|
||||
# World-axis-aligned per-axis size. For rotated objects this is
|
||||
# the AABB extent, not the intrinsic mesh size (use the local
|
||||
# variant for that).
|
||||
"dimensions": (max_point.x - min_point.x, max_point.y - min_point.y, max_point.z - min_point.z),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def select_and_activate_single_object(cls, context: bpy.types.Context, active_object: bpy.types.Object) -> None:
|
||||
for obj in context.selected_objects:
|
||||
@@ -1332,74 +1377,6 @@ class Blender(bonsai.core.tool.Blender):
|
||||
return True
|
||||
|
||||
class Modifier:
|
||||
# ----------------------------------------------------------------------
|
||||
# FIXME(PR5): backward-compat shims for callers still using the
|
||||
# pre-refactor API. The is_<type> predicates now live on tool.Parametric;
|
||||
# the Array helper bag now lives on tool.Array. PR4 migrates each caller;
|
||||
# this whole shim block is removed in PR5's cleanup.
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def is_door(cls, element: entity_instance) -> bool:
|
||||
return tool.Parametric.is_door(element)
|
||||
|
||||
@classmethod
|
||||
def is_railing(cls, element: entity_instance) -> bool:
|
||||
return tool.Parametric.is_railing(element)
|
||||
|
||||
@classmethod
|
||||
def is_roof(cls, element: entity_instance) -> bool:
|
||||
return tool.Parametric.is_roof(element)
|
||||
|
||||
@classmethod
|
||||
def is_stair(cls, element: entity_instance) -> bool:
|
||||
return tool.Parametric.is_stair(element)
|
||||
|
||||
@classmethod
|
||||
def is_wall(cls, element: entity_instance) -> bool:
|
||||
return tool.Parametric.is_wall(element)
|
||||
|
||||
@classmethod
|
||||
def is_window(cls, element: entity_instance) -> bool:
|
||||
return tool.Parametric.is_window(element)
|
||||
|
||||
class Array:
|
||||
@classmethod
|
||||
def bake_children_transform(cls, parent_element: ifcopenshell.entity_instance, item: int) -> None:
|
||||
tool.Array.bake_children_transform(parent_element, item)
|
||||
|
||||
@classmethod
|
||||
def constrain_children_to_parent(cls, parent_element: ifcopenshell.entity_instance) -> None:
|
||||
tool.Array.constrain_children_to_parent(parent_element)
|
||||
|
||||
@classmethod
|
||||
def get_all_children_objects(cls, parent_element: ifcopenshell.entity_instance) -> list:
|
||||
return tool.Array.get_all_children_objects(parent_element)
|
||||
|
||||
@classmethod
|
||||
def get_all_objects(cls, parent_element: ifcopenshell.entity_instance) -> list:
|
||||
return tool.Array.get_all_objects(parent_element)
|
||||
|
||||
@classmethod
|
||||
def get_children_objects(cls, modifier_data: dict) -> list:
|
||||
return tool.Array.get_children_objects(modifier_data)
|
||||
|
||||
@classmethod
|
||||
def get_modifiers_data(cls, parent_element: ifcopenshell.entity_instance):
|
||||
return tool.Array.get_modifiers_data(parent_element)
|
||||
|
||||
@classmethod
|
||||
def remove_constraints(cls, parent_element: ifcopenshell.entity_instance) -> None:
|
||||
tool.Array.remove_constraints(parent_element)
|
||||
|
||||
@classmethod
|
||||
def set_children_lock_state(
|
||||
cls, parent_element: ifcopenshell.entity_instance, item: int, lock: bool
|
||||
) -> None:
|
||||
tool.Array.set_children_lock_state(parent_element, item, lock)
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def try_applying_edit_mode(cls, obj: bpy.types.Object, element: entity_instance) -> bool:
|
||||
"""Tries to validate the current BIM modifier parameters for the active object
|
||||
@@ -2018,6 +1995,18 @@ class Blender(bonsai.core.tool.Blender):
|
||||
dct = {cls.bl_idname: cls.ifc_element_type for cls in (BimTool.__subclasses__())}
|
||||
return types.MappingProxyType(dct)
|
||||
|
||||
@classmethod
|
||||
@lru_cache
|
||||
def get_property_header_tools(cls) -> frozenset[str]:
|
||||
"""``BimTool`` plus its parametric subclasses — the workspace
|
||||
tools whose 3D-view / N-panel header surfaces BIM Tool property
|
||||
floats (extrusion_depth, length, x_angle). ``AnnotationTool``
|
||||
and the non-``BimTool`` workspace tools (spatial / structural /
|
||||
cad / covering) are excluded by construction."""
|
||||
from bonsai.bim.module.model.workspace import BimTool
|
||||
|
||||
return frozenset(cls.bl_idname for cls in (BimTool.__subclasses__() + [BimTool]))
|
||||
|
||||
@classmethod
|
||||
def get_object_constraint_props(cls, obj: bpy.types.Object) -> BIMObjectConstraintProperties:
|
||||
return obj.BIMObjectConstraintProperties # pyright: ignore[reportAttributeAccessIssue]
|
||||
|
||||
@@ -849,15 +849,6 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def has_material_styles(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
"""True when any of ``element``'s materials exposes an
|
||||
``IfcSurfaceStyle``. Gate body-style assignment to avoid double-styling."""
|
||||
return any(
|
||||
tool.Material.get_style(material) is not None
|
||||
for material in ifcopenshell.util.element.get_materials(element)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def reimport_element_representations(
|
||||
cls, obj: bpy.types.Object, representation: ifcopenshell.entity_instance, apply_openings: bool = True
|
||||
|
||||
@@ -1216,7 +1216,7 @@ class Loader(bonsai.core.tool.Loader):
|
||||
) -> bool:
|
||||
items = [i["item"] for i in ifcopenshell.util.representation.resolve_items(representation)]
|
||||
if len(items) == 1 and items[0].is_a("IfcSweptDiskSolid"):
|
||||
if tool.Blender.Modifier.is_railing(element):
|
||||
if tool.Parametric.is_railing(element):
|
||||
return False
|
||||
return True
|
||||
elif len(items) and ( # See #2508 why we accommodate for invalid IFCs here
|
||||
@@ -1224,7 +1224,7 @@ class Loader(bonsai.core.tool.Loader):
|
||||
and len({i.is_a() for i in items}) == 1
|
||||
and len({i.Radius for i in items}) == 1
|
||||
):
|
||||
if tool.Blender.Modifier.is_railing(element):
|
||||
if tool.Parametric.is_railing(element):
|
||||
return False
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -227,10 +227,8 @@ class Misc(bonsai.core.tool.Misc):
|
||||
|
||||
@classmethod
|
||||
def set_object_origin_to_bottom(cls, obj: bpy.types.Object) -> None:
|
||||
absolute_bound_box = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
|
||||
min_z = min([c[2] for c in absolute_bound_box])
|
||||
new_origin = obj.matrix_world.translation.copy()
|
||||
new_origin[2] = min_z
|
||||
new_origin[2] = tool.Blender.get_object_world_bounding_box(obj)["min_z"]
|
||||
assert isinstance(obj.data, bpy.types.Mesh)
|
||||
obj.data.transform(
|
||||
Matrix.Translation(
|
||||
@@ -249,11 +247,8 @@ class Misc(bonsai.core.tool.Misc):
|
||||
|
||||
@classmethod
|
||||
def scale_object_to_height(cls, obj: bpy.types.Object, height: float) -> None:
|
||||
absolute_bound_box = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
|
||||
max_z = max([c[2] for c in absolute_bound_box])
|
||||
min_z = min([c[2] for c in absolute_bound_box])
|
||||
current_absolute_height = max_z - min_z
|
||||
scale_factor = height / current_absolute_height
|
||||
bbox = tool.Blender.get_object_world_bounding_box(obj)
|
||||
scale_factor = height / (bbox["max_z"] - bbox["min_z"])
|
||||
obj.matrix_world @= Matrix.Scale(
|
||||
scale_factor, 4, obj.matrix_world.inverted().to_quaternion() @ Vector((0, 0, 1))
|
||||
)
|
||||
|
||||
@@ -2943,7 +2943,7 @@ class Model(bonsai.core.tool.Model):
|
||||
def offset_wall(cls, wall: bpy.types.Object, baseline: Literal["EXTERIOR", "INTERIOR", "CENTER"]) -> None:
|
||||
element = tool.Ifc.get_entity(wall)
|
||||
usage = ifcopenshell.util.element.get_material(element)
|
||||
if not usage.is_a("IfcMaterialLayerSetUsage"):
|
||||
if usage is None or not usage.is_a("IfcMaterialLayerSetUsage"):
|
||||
return
|
||||
layer_set = usage.ForLayerSet
|
||||
if baseline == "CENTER":
|
||||
|
||||
@@ -147,17 +147,17 @@ class Parametric(bonsai.core.tool.Parametric):
|
||||
self._data.clear()
|
||||
self._gen = None
|
||||
|
||||
# FIXME(PR4): array / pipe_segment / duct_segment land with their
|
||||
# finish/cancel operators in PR4. Adding them to EDIT_TYPES without those
|
||||
# operators makes auto-commit-on-save dispatch bim.finish_editing_<name>
|
||||
# for objects flagged as in-edit, which then raises because the operator
|
||||
# doesn't exist. PR4 re-adds the three entries together with the operators.
|
||||
# FIXME(PR5): pipe_segment / duct_segment land with their finish/cancel
|
||||
# operators in the MEP slice of PR5 (PR5d). Until then they stay out of
|
||||
# EDIT_TYPES so auto-commit-on-save doesn't try to dispatch a
|
||||
# non-existent operator.
|
||||
EDIT_TYPES: list[ParametricObject] = [
|
||||
ParametricObject("door", has_non_editable_path=True, supports_build_edit_lifecycle=True),
|
||||
ParametricObject("window", has_non_editable_path=True, supports_build_edit_lifecycle=True),
|
||||
ParametricObject("stair", has_non_editable_path=True, supports_build_edit_lifecycle=True),
|
||||
ParametricObject("railing", supports_build_edit_lifecycle=True),
|
||||
ParametricObject("roof", supports_build_edit_lifecycle=True),
|
||||
ParametricObject("array", supports_build_edit_lifecycle=True),
|
||||
ParametricObject("wall"),
|
||||
]
|
||||
|
||||
@@ -169,6 +169,7 @@ class Parametric(bonsai.core.tool.Parametric):
|
||||
STAIR: ClassVar[ParametricObject]
|
||||
RAILING: ClassVar[ParametricObject]
|
||||
ROOF: ClassVar[ParametricObject]
|
||||
ARRAY: ClassVar[ParametricObject]
|
||||
WALL: ClassVar[ParametricObject]
|
||||
|
||||
_geom_generation: int = 0
|
||||
@@ -178,16 +179,24 @@ class Parametric(bonsai.core.tool.Parametric):
|
||||
return cls._geom_generation
|
||||
|
||||
@classmethod
|
||||
def refresh_post_commit(cls) -> None:
|
||||
"""Post-commit hook for ``tool.Ifc.Operator``: re-syncs scene-level
|
||||
workspace-tool header fields from current IFC state and bumps the
|
||||
geometry generation counter so caches keyed off it drop stale
|
||||
entries on the next draw."""
|
||||
import bonsai.bim.handler # late import: bim.handler imports tool.*
|
||||
def refresh_post_commit(cls, operator: bpy.types.Operator) -> None:
|
||||
"""Post-commit hook for ``tool.Ifc.Operator``: bumps the geometry
|
||||
generation counter so caches keyed off it drop stale entries on
|
||||
the next draw, and tags viewports for redraw.
|
||||
|
||||
Additionally refreshes the BIM Tool header floats for the
|
||||
validate-gizmo path — operators whose ``bl_idname`` is the
|
||||
``finish_op`` of an entry in ``EDIT_TYPES``. That is the only
|
||||
commit class where selection didn't change but the header
|
||||
values displayed did. Other operators skip the refresh: they
|
||||
don't target an active-object header edit, and their commit
|
||||
context may lack the view-layer attributes the refresh reads."""
|
||||
cls._geom_generation += 1
|
||||
bonsai.bim.handler.update_bim_tool_props()
|
||||
tool.Blender.update_all_viewports()
|
||||
if operator.bl_idname in {feature.finish_op for feature in cls.EDIT_TYPES}:
|
||||
import bonsai.bim.handler # late import: bim.handler imports tool.*
|
||||
|
||||
bonsai.bim.handler.refresh_bim_tool_headers()
|
||||
|
||||
@classmethod
|
||||
def find_by_name(cls, name: str) -> Optional[ParametricObject]:
|
||||
@@ -262,6 +271,18 @@ class Parametric(bonsai.core.tool.Parametric):
|
||||
for obj in bpy.data.objects:
|
||||
cls._validated_editing_feature(obj)
|
||||
|
||||
@classmethod
|
||||
def on_load_post(cls, scene: bpy.types.Scene) -> None:
|
||||
"""Drain load-transient parametric state on a freshly opened scene
|
||||
so no draft edit flag, preview flag, or cache entry persists from
|
||||
the saved file."""
|
||||
from bonsai.bim.module.model import wall_offset_gizmos
|
||||
from bonsai.bim.module.model.preview_base import discard_pending_previews
|
||||
|
||||
cls.heal_stale_edit_flags()
|
||||
discard_pending_previews(scene)
|
||||
wall_offset_gizmos.clear_caches()
|
||||
|
||||
@classmethod
|
||||
def get_pending_edits(cls) -> list[tuple[bpy.types.Object, str]]:
|
||||
"""``(object, finish_operator_bl_idname)`` pairs for every object
|
||||
@@ -382,29 +403,6 @@ class Parametric(bonsai.core.tool.Parametric):
|
||||
if hasattr(bpy.types.Object, feature.props_attr):
|
||||
delattr(bpy.types.Object, feature.props_attr)
|
||||
|
||||
@classmethod
|
||||
def iter_gizmo_preference_classes(cls, ui_module) -> list[type]:
|
||||
"""``GizmoPreferences<Name>`` classes that exist on ``ui_module`` for
|
||||
every registry entry, plus the shared ``GizmoPreferencesFeature`` if
|
||||
present. Order matches ``EDIT_TYPES``. Used by ``bim/__init__.py`` to
|
||||
inject the per-type ``GizmoPreferences<X>`` classes at the correct
|
||||
point — before ``ui.GizmoPreferences``, which references them via
|
||||
``PointerProperty``."""
|
||||
# FIXME(PR5): drop the per-feature loop once PR4 consolidates
|
||||
# bim/ui.py to use a single shared GizmoPreferencesFeature class
|
||||
# and rewrites GizmoPreferences accordingly. The shared-class
|
||||
# branch is the forward-compat path; the per-feature loop keeps
|
||||
# v0.8.0's bim/ui.py working until then.
|
||||
out: list[type] = []
|
||||
for feature in cls.EDIT_TYPES:
|
||||
gpref = getattr(ui_module, f"GizmoPreferences{feature.name.capitalize()}", None)
|
||||
if gpref is not None:
|
||||
out.append(gpref)
|
||||
shared = getattr(ui_module, "GizmoPreferencesFeature", None)
|
||||
if shared is not None:
|
||||
out.append(shared)
|
||||
return out
|
||||
|
||||
# --- Feature-kind predicates ------------------------------------------------
|
||||
# One predicate per registered parametric type. Each is total: accepts any
|
||||
# IFC entity (or None), returns a bool, never raises. Predicates live with
|
||||
|
||||
@@ -373,7 +373,6 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
except:
|
||||
loc = Vector((0, 0, 0))
|
||||
|
||||
|
||||
snap_obj._ensure_bvh()
|
||||
intersected = snap_obj.raycast_boxes(
|
||||
context, event, snap_obj.root, intersected=[], rays=(ray_origin, ray_direction)
|
||||
@@ -395,13 +394,10 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
# 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]
|
||||
)
|
||||
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
|
||||
@@ -885,9 +881,7 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
|
||||
# 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
|
||||
)
|
||||
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:
|
||||
@@ -926,9 +920,7 @@ class Raycast(bonsai.core.tool.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
|
||||
)
|
||||
hit_obj, hit = cls.process_wireframe_snap_obj(context, event, snap_obj, ray_origin, closest_snaps)
|
||||
face_index = None
|
||||
else:
|
||||
# Solid objects
|
||||
|
||||
@@ -71,6 +71,18 @@ class Root(bonsai.core.tool.Root):
|
||||
should_use_presentation_style_assignment=props.should_use_presentation_style_assignment,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def has_material_styles(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
"""``True`` if any constituent material on ``element`` carries a style
|
||||
representation. Body styles should NOT be applied directly when this
|
||||
is True — the material-inherited style is the authoritative source.
|
||||
Paired with ``assign_body_styles``: callers check this first and only
|
||||
call ``assign_body_styles`` when it returns False."""
|
||||
materials = ifcopenshell.util.element.get_materials(element)
|
||||
if not materials:
|
||||
return False
|
||||
return any(getattr(m, "HasRepresentation", None) for m in materials)
|
||||
|
||||
@classmethod
|
||||
def copy_representation(
|
||||
cls, source: ifcopenshell.entity_instance, dest: ifcopenshell.entity_instance
|
||||
@@ -381,7 +393,7 @@ class Root(bonsai.core.tool.Root):
|
||||
# Make sure that the array children also get reassigned to the correct aggregate
|
||||
pset = ifcopenshell.util.element.get_pset(new[0], "BBIM_Array")
|
||||
if pset:
|
||||
array_children = tool.Blender.Modifier.Array.get_all_children_objects(new[0])
|
||||
array_children = tool.Array.get_all_children_objects(new[0])
|
||||
for obj in array_children:
|
||||
bonsai.core.aggregate.assign_object(
|
||||
tool.Ifc,
|
||||
|
||||
@@ -687,8 +687,10 @@ Scenario: Saving with a door mid-edit auto-commits the draft value to the IFC ps
|
||||
Then "active_object.BIMDoorProperties.is_editing" is "True"
|
||||
When I press "bim.save_project(filepath='{temp_project_path}', should_save_as=True)"
|
||||
Then "active_object.BIMDoorProperties.is_editing" is "False"
|
||||
# BBIM_<Type> psets store project units, not raw Blender SI. The empty project
|
||||
# used in an_empty_blender_session is METRIC_MM, so 2.5 m → 2500 mm in the pset.
|
||||
And the variable "saved_height" is "__import__('json').loads(ifcopenshell.util.element.get_pset({ifc}.by_type('IfcDoor')[0], 'BBIM_Door', 'Data'))['overall_height']"
|
||||
And the variable "saved_height" equals "2.5"
|
||||
And the variable "saved_height" equals "2500.0"
|
||||
|
||||
Scenario: Saving with no parametric edits in progress leaves the door pset unchanged
|
||||
Given an empty IFC project
|
||||
@@ -705,14 +707,10 @@ Scenario: Saving with no parametric edits in progress leaves the door pset uncha
|
||||
|
||||
Scenario: Saving with a wall mid-edit auto-commits the draft to IFC
|
||||
Given an empty IFC project
|
||||
And I add a cube
|
||||
And the object "Cube" is selected
|
||||
And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType"
|
||||
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
|
||||
And I press "bim.assign_class"
|
||||
And I load the demo construction library
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
|
||||
And the variable "cube" is "{ifc}.by_type('IfcWallType')[0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{cube}"
|
||||
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
|
||||
And I press "bim.add_occurrence"
|
||||
And the object "IfcWall/Wall" is selected
|
||||
And I press "bim.enable_editing_wall()"
|
||||
@@ -722,21 +720,18 @@ Scenario: Saving with a wall mid-edit auto-commits the draft to IFC
|
||||
|
||||
Scenario: Enabling and finishing a wall edit with no drag is a no-op
|
||||
Given an empty IFC project
|
||||
And I add a cube
|
||||
And the object "Cube" is selected
|
||||
And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType"
|
||||
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
|
||||
And I press "bim.assign_class"
|
||||
And I load the demo construction library
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
|
||||
And the variable "cube" is "{ifc}.by_type('IfcWallType')[0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{cube}"
|
||||
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
|
||||
And I press "bim.add_occurrence"
|
||||
And the object "IfcWall/Wall" is selected
|
||||
And the variable "entity_count_before" is "len(list({ifc}))"
|
||||
When I press "bim.enable_editing_wall()"
|
||||
And I press "bim.finish_editing_wall()"
|
||||
Then "active_object.BIMWallProperties.is_editing" is "False"
|
||||
And "len(list({ifc}))" is "{entity_count_before}"
|
||||
And the variable "entity_count_after" is "len(list({ifc}))"
|
||||
And the variable "entity_count_after" equals "{entity_count_before}"
|
||||
|
||||
Scenario: Cancelling a wall edit clears is_editing
|
||||
Given an empty IFC project
|
||||
@@ -756,14 +751,10 @@ Scenario: Cancelling a wall edit clears is_editing
|
||||
|
||||
Scenario: Wall parametric edit works on IFC2X3 projects
|
||||
Given an empty IFC2X3 project
|
||||
And I add a cube
|
||||
And the object "Cube" is selected
|
||||
And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType"
|
||||
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
|
||||
And I press "bim.assign_class"
|
||||
And I load the demo construction library
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
|
||||
And the variable "cube" is "{ifc}.by_type('IfcWallType')[0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{cube}"
|
||||
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
|
||||
And I press "bim.add_occurrence"
|
||||
And the object "IfcWall/Wall" is selected
|
||||
When I press "bim.enable_editing_wall()"
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Shared fixtures and factories for ``test/bim/module/model/`` gizmo and
|
||||
decorator tests.
|
||||
|
||||
The boundary between Blender / IFC / Bonsai's ``tool.*`` layer is patched
|
||||
identically across many model-test files (viewport-state, selection, IFC
|
||||
entity lookup, modifier predicates, view-camera state). The ``patched_tool``
|
||||
fixture below centralises that patch stack so each test names only the
|
||||
boundary methods it cares about; everything else is left to production.
|
||||
|
||||
Factory helpers (``make_obj``, ``make_element``, ``make_context``,
|
||||
``make_ifc_file``) replace near-identical local helpers that previously
|
||||
lived in each file.
|
||||
|
||||
When to use these fixtures in a new test file:
|
||||
|
||||
- Adding a gizmo / decorator test that patches ``tool.Blender`` or
|
||||
``tool.Ifc`` boundary methods? Request the ``patched_tool`` fixture
|
||||
as a test parameter and call it as a context-manager factory.
|
||||
- Need a stub ``bpy.types.Object`` / ``ifcopenshell.entity_instance`` /
|
||||
``poll()`` context / ``ifcopenshell.file``? Import the matching factory
|
||||
from this module rather than re-rolling locally.
|
||||
- Need to reset module-level state (e.g. a decorator cache token) between
|
||||
tests? Define an ``@pytest.fixture(autouse=True)`` reset in the test
|
||||
file itself — these stay file-local because they target state specific
|
||||
to one decorator/module and globalising the reset would surprise
|
||||
unrelated tests.
|
||||
|
||||
Layout note: pure helpers (``make_*``) live alongside the fixture in this
|
||||
file rather than a sibling ``test_utils.py``. pytest's documented role for
|
||||
``conftest.py`` is fixtures, so this is a mild convention bend — kept here
|
||||
because the helper count is small and the dependencies (``tool``, ``Mock``)
|
||||
already need to be imported for the fixture itself. Split into a separate
|
||||
module if the helper count grows past ~6 or any helper picks up its own
|
||||
non-trivial dependencies."""
|
||||
|
||||
import contextlib
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import ifcopenshell
|
||||
import pytest
|
||||
|
||||
from bonsai import tool
|
||||
|
||||
|
||||
def make_obj(*, session_uid=None, selected=True, **attrs):
|
||||
"""Mock a ``bpy.types.Object`` with attributes commonly read by gizmos.
|
||||
|
||||
``session_uid`` is set only when provided so tests that don't care about
|
||||
object identity (most poll() tests use ``object()`` sentinels) can use
|
||||
``make_obj()`` without a spurious uid. ``selected`` wires ``select_get()``
|
||||
to return the given boolean. Extra attrs are set as plain attributes.
|
||||
|
||||
A bare ``Mock()`` is required because ``Mock(spec=bpy.types.Object)``
|
||||
rejects ``select_get`` — Blender's C-registered methods aren't exposed
|
||||
to Python introspection."""
|
||||
obj = Mock()
|
||||
if session_uid is not None:
|
||||
obj.session_uid = session_uid
|
||||
obj.select_get.return_value = selected
|
||||
for name, value in attrs.items():
|
||||
setattr(obj, name, value)
|
||||
return obj
|
||||
|
||||
|
||||
def make_element(step_id=None, *, ifc_class=None, **attrs):
|
||||
"""Mock an ``ifcopenshell.entity_instance`` with the surfaces gizmos read.
|
||||
|
||||
``step_id`` populates ``element.id()``. ``ifc_class`` wires ``is_a(name)``
|
||||
to return True only when ``name == ifc_class``. Extra kwargs become plain
|
||||
attributes (e.g. ``HasOpenings=()``)."""
|
||||
element = Mock()
|
||||
if step_id is not None:
|
||||
element.id.return_value = step_id
|
||||
if ifc_class is not None:
|
||||
element.is_a.side_effect = lambda type_name: type_name == ifc_class
|
||||
for name, value in attrs.items():
|
||||
setattr(element, name, value)
|
||||
return element
|
||||
|
||||
|
||||
def make_context(*, active=None, selected=(), scene=None):
|
||||
"""``SimpleNamespace`` stub with the ``poll()`` reads tests exercise:
|
||||
``active_object``, ``selected_objects``, and ``scene``. ``selected`` is
|
||||
materialised to a list so tests can iterate without re-walking a generator.
|
||||
``scene`` defaults to an empty namespace so guards that walk
|
||||
``context.scene.BIMPreviewProperties`` (via ``getattr(..., default=None)``)
|
||||
treat the preview as inactive — pass a custom namespace to activate."""
|
||||
return SimpleNamespace(
|
||||
active_object=active,
|
||||
selected_objects=list(selected),
|
||||
scene=scene if scene is not None else SimpleNamespace(),
|
||||
)
|
||||
|
||||
|
||||
def make_ifc_file(elements_by_guid: dict | None = None) -> MagicMock:
|
||||
"""Mock ``ifcopenshell.file`` with ``spec=`` so attribute typos surface as
|
||||
``AttributeError`` instead of silently auto-creating a child mock.
|
||||
|
||||
When ``elements_by_guid`` is given, ``by_guid`` is wired to look up the
|
||||
mapping and raise ``RuntimeError`` on a missing guid — same shape as the
|
||||
real ifcopenshell.file behaviour, so a test that depends on orphan handling
|
||||
sees an exception rather than a silent ``None``."""
|
||||
f = MagicMock(spec=ifcopenshell.file, name="ifc_file")
|
||||
if elements_by_guid is not None:
|
||||
|
||||
def _by_guid(guid):
|
||||
try:
|
||||
return elements_by_guid[guid]
|
||||
except KeyError:
|
||||
raise RuntimeError(f"no entity with guid {guid}")
|
||||
|
||||
f.by_guid.side_effect = _by_guid
|
||||
return f
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_tool():
|
||||
"""Context-manager factory for the ``tool.*`` boundary patches that nearly
|
||||
every gizmo / decorator test repeats. Use as::
|
||||
|
||||
with patched_tool(viewport_gizmos=True, selected=[obj_a, obj_b],
|
||||
modifier_predicates={"is_wall": True}):
|
||||
GizmoFoo.poll(context)
|
||||
|
||||
Only the kwargs you pass are patched — anything left as ``None`` (or
|
||||
omitted) keeps production behaviour. Values can be:
|
||||
|
||||
- ``viewport_gizmos`` / ``view_top_down`` / ``addon_prefs``: passed to
|
||||
``return_value=`` of the corresponding patch.
|
||||
- ``selected``: wrapped in ``set(...)`` for ``get_selected_objects``
|
||||
(matches the production return type for ``poll()``-side reads).
|
||||
- ``selected_list``: as-is for ``get_selected_objects`` when order
|
||||
matters (some operators iterate it). Mutually exclusive with
|
||||
``selected`` — if both are passed, ``selected`` wins and
|
||||
``selected_list`` is ignored. Pass only one.
|
||||
- ``entity``: either a callable (used as ``side_effect``) or a single
|
||||
value (used as ``return_value``).
|
||||
- ``modifier_predicates``: dict ``{predicate_name: bool_or_callable}``.
|
||||
Callables are wired as ``side_effect``, bools as ``return_value``.
|
||||
- ``screen_up``: ``return_value`` for ``get_screen_up_world``.
|
||||
|
||||
Patches close on context-manager exit via an ``ExitStack`` — no
|
||||
``try/finally`` bookkeeping in the test body."""
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _factory(
|
||||
*,
|
||||
viewport_gizmos=None,
|
||||
addon_prefs=None,
|
||||
selected=None,
|
||||
selected_list=None,
|
||||
entity=None,
|
||||
modifier_predicates=None,
|
||||
view_top_down=None,
|
||||
screen_up=None,
|
||||
):
|
||||
with contextlib.ExitStack() as stack:
|
||||
if viewport_gizmos is not None:
|
||||
stack.enter_context(
|
||||
patch.object(tool.Blender, "are_viewport_gizmos_enabled", return_value=viewport_gizmos)
|
||||
)
|
||||
if addon_prefs is not None:
|
||||
stack.enter_context(patch.object(tool.Blender, "get_addon_preferences", return_value=addon_prefs))
|
||||
if selected is not None:
|
||||
stack.enter_context(patch.object(tool.Blender, "get_selected_objects", return_value=set(selected)))
|
||||
elif selected_list is not None:
|
||||
stack.enter_context(
|
||||
patch.object(tool.Blender, "get_selected_objects", return_value=list(selected_list))
|
||||
)
|
||||
if entity is not None:
|
||||
if callable(entity):
|
||||
stack.enter_context(patch.object(tool.Ifc, "get_entity", side_effect=entity))
|
||||
else:
|
||||
stack.enter_context(patch.object(tool.Ifc, "get_entity", return_value=entity))
|
||||
if modifier_predicates:
|
||||
for name, value in modifier_predicates.items():
|
||||
# Parametric feature-kind predicates live on tool.Parametric; the
|
||||
# remaining cardinality / non-parametric predicates (is_array_child,
|
||||
# is_slab, is_eligible_for_*) stay on tool.Blender.Modifier.
|
||||
target = tool.Parametric if hasattr(tool.Parametric, name) else tool.Blender.Modifier
|
||||
if callable(value):
|
||||
stack.enter_context(patch.object(target, name, side_effect=value))
|
||||
else:
|
||||
stack.enter_context(patch.object(target, name, return_value=value))
|
||||
if view_top_down is not None:
|
||||
stack.enter_context(patch.object(tool.Blender, "is_view_top_down", return_value=view_top_down))
|
||||
if screen_up is not None:
|
||||
stack.enter_context(patch.object(tool.Blender, "get_screen_up_world", return_value=screen_up))
|
||||
yield
|
||||
|
||||
return _factory
|
||||
@@ -0,0 +1,219 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Contract tests for the door swing-arc gizmo positioning.
|
||||
|
||||
Each test calls ``GizmoDoorEdition.update_swing_gizmos`` as an unbound method
|
||||
against a SimpleNamespace stand-in that records ``matrix_basis`` assignments and
|
||||
``hide`` flags. The expected matrices are recomputed from first principles so
|
||||
the tests describe the geometric contract directly rather than echoing the
|
||||
implementation."""
|
||||
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _require_real_bpy():
|
||||
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
|
||||
pytest.skip("requires real Blender (bpy is mocked or absent)")
|
||||
|
||||
|
||||
def _make_props(door_type, overall_width=0.9, lining_offset=0.0, is_editing=True):
|
||||
return SimpleNamespace(
|
||||
door_type=door_type,
|
||||
overall_width=overall_width,
|
||||
lining_offset=lining_offset,
|
||||
is_editing=is_editing,
|
||||
)
|
||||
|
||||
|
||||
def _make_fake_group():
|
||||
"""Stand-in for ``GizmoDoorEdition``: one MagicMock per declared arc gizmo
|
||||
plus a stub ``update_gizmo_visibility`` that records the visibility flag on
|
||||
each mock's ``hide`` attribute."""
|
||||
from bonsai.bim.module.model.door import GizmoDoorEdition
|
||||
|
||||
fake = SimpleNamespace()
|
||||
fake.swing_arc_props = GizmoDoorEdition.swing_arc_props
|
||||
|
||||
def update_gizmo_visibility(gizmo, is_visible):
|
||||
gizmo.hide = not is_visible
|
||||
return is_visible
|
||||
|
||||
fake.update_gizmo_visibility = update_gizmo_visibility
|
||||
|
||||
for cfg in fake.swing_arc_props:
|
||||
setattr(fake, f"gizmo_swing_arc_{cfg.name}", MagicMock(spec=["matrix_basis", "hide"]))
|
||||
setattr(fake, f"gizmo_swing_arc_{cfg.name}_flip", MagicMock(spec=["matrix_basis", "hide"]))
|
||||
|
||||
return fake
|
||||
|
||||
|
||||
def _call_update(fake, props, mw=None):
|
||||
from bonsai.bim.module.model.door import GizmoDoorEdition
|
||||
|
||||
GizmoDoorEdition.update_swing_gizmos(fake, mw or Matrix.Identity(4), props)
|
||||
|
||||
|
||||
def _matrix_approx(actual, expected, abs_tol=1e-6):
|
||||
assert isinstance(actual, Matrix), f"matrix_basis was never assigned (got {type(actual).__name__})"
|
||||
for i in range(4):
|
||||
for j in range(4):
|
||||
assert actual[i][j] == pytest.approx(expected[i][j], abs=abs_tol), (
|
||||
f"Mismatch at [{i}][{j}]: got {actual[i][j]}, expected {expected[i][j]}\n"
|
||||
f"actual=\n{actual}\nexpected=\n{expected}"
|
||||
)
|
||||
|
||||
|
||||
_MIRROR_X = Matrix.Scale(-1, 4, (1, 0, 0))
|
||||
_MIRROR_Y = Matrix.Scale(-1, 4, (0, 1, 0))
|
||||
|
||||
|
||||
def test_single_swing_left_primary_arc_hinges_at_left_edge():
|
||||
"""Left-hinged single-swing: primary arc at (0, lining_offset), scaled to
|
||||
overall_width, no X-mirror. Flip arc same transform composed with Y-mirror.
|
||||
Secondary panel hidden."""
|
||||
fake = _make_fake_group()
|
||||
props = _make_props(door_type="SINGLE_SWING_LEFT", overall_width=0.9, lining_offset=0.05)
|
||||
_call_update(fake, props)
|
||||
|
||||
expected = Matrix.Translation(Vector((0.0, 0.05, 0.0))) @ Matrix.Scale(0.9, 4)
|
||||
_matrix_approx(fake.gizmo_swing_arc_primary.matrix_basis, expected)
|
||||
_matrix_approx(fake.gizmo_swing_arc_primary_flip.matrix_basis, expected @ _MIRROR_Y)
|
||||
assert fake.gizmo_swing_arc_secondary.hide is True
|
||||
assert fake.gizmo_swing_arc_secondary_flip.hide is True
|
||||
|
||||
|
||||
def test_single_swing_right_primary_arc_hinges_at_right_edge_with_x_mirror():
|
||||
"""Right-hinged single-swing: primary arc anchored at (overall_width, lining_offset)
|
||||
with an X-mirror applied so the arc sweeps back over the door panel rather
|
||||
than extending past the right edge."""
|
||||
fake = _make_fake_group()
|
||||
props = _make_props(door_type="SINGLE_SWING_RIGHT", overall_width=0.9, lining_offset=0.05)
|
||||
_call_update(fake, props)
|
||||
|
||||
expected = Matrix.Translation(Vector((0.9, 0.05, 0.0))) @ Matrix.Scale(0.9, 4) @ _MIRROR_X
|
||||
_matrix_approx(fake.gizmo_swing_arc_primary.matrix_basis, expected)
|
||||
_matrix_approx(fake.gizmo_swing_arc_primary_flip.matrix_basis, expected @ _MIRROR_Y)
|
||||
assert fake.gizmo_swing_arc_secondary.hide is True
|
||||
assert fake.gizmo_swing_arc_secondary_flip.hide is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("double_type", "single_type"),
|
||||
[
|
||||
("DOUBLE_SWING_LEFT", "SINGLE_SWING_LEFT"),
|
||||
("DOUBLE_SWING_RIGHT", "SINGLE_SWING_RIGHT"),
|
||||
],
|
||||
)
|
||||
def test_double_swing_uses_same_recipe_as_single_swing(double_type, single_type):
|
||||
"""DOUBLE_SWING_* (one panel that can open both ways) shares the
|
||||
single-panel positioning recipe with its SINGLE_SWING_* counterpart."""
|
||||
fake_a = _make_fake_group()
|
||||
fake_b = _make_fake_group()
|
||||
props_a = _make_props(door_type=double_type, overall_width=0.9, lining_offset=0.05)
|
||||
props_b = _make_props(door_type=single_type, overall_width=0.9, lining_offset=0.05)
|
||||
_call_update(fake_a, props_a)
|
||||
_call_update(fake_b, props_b)
|
||||
|
||||
_matrix_approx(
|
||||
fake_a.gizmo_swing_arc_primary.matrix_basis,
|
||||
fake_b.gizmo_swing_arc_primary.matrix_basis,
|
||||
)
|
||||
_matrix_approx(
|
||||
fake_a.gizmo_swing_arc_primary_flip.matrix_basis,
|
||||
fake_b.gizmo_swing_arc_primary_flip.matrix_basis,
|
||||
)
|
||||
|
||||
|
||||
def test_double_door_shows_four_arcs_each_scaled_to_half_door_width():
|
||||
"""DOUBLE_DOOR_SINGLE_SWING: left panel hinged at x=0, right panel hinged
|
||||
at x=overall_width with X-mirror, both scaled to overall_width/2. Each
|
||||
panel also gets a Y-mirrored flip arc — 4 arcs total."""
|
||||
fake = _make_fake_group()
|
||||
props = _make_props(door_type="DOUBLE_DOOR_SINGLE_SWING", overall_width=1.6, lining_offset=0.0)
|
||||
_call_update(fake, props)
|
||||
|
||||
half = 1.6 / 2
|
||||
expected_primary = Matrix.Translation(Vector((0.0, 0.0, 0.0))) @ Matrix.Scale(half, 4)
|
||||
expected_secondary = Matrix.Translation(Vector((1.6, 0.0, 0.0))) @ Matrix.Scale(half, 4) @ _MIRROR_X
|
||||
|
||||
_matrix_approx(fake.gizmo_swing_arc_primary.matrix_basis, expected_primary)
|
||||
_matrix_approx(fake.gizmo_swing_arc_primary_flip.matrix_basis, expected_primary @ _MIRROR_Y)
|
||||
_matrix_approx(fake.gizmo_swing_arc_secondary.matrix_basis, expected_secondary)
|
||||
_matrix_approx(fake.gizmo_swing_arc_secondary_flip.matrix_basis, expected_secondary @ _MIRROR_Y)
|
||||
|
||||
for cfg in fake.swing_arc_props:
|
||||
assert getattr(fake, f"gizmo_swing_arc_{cfg.name}").hide is False
|
||||
assert getattr(fake, f"gizmo_swing_arc_{cfg.name}_flip").hide is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("door_type", ["SLIDING_TO_LEFT", "SLIDING_TO_RIGHT", "DOUBLE_DOOR_SLIDING"])
|
||||
def test_sliding_door_types_hide_all_arcs(door_type):
|
||||
"""Sliding doors don't swing — every arc in ``swing_arc_props`` is hidden."""
|
||||
fake = _make_fake_group()
|
||||
props = _make_props(door_type=door_type, overall_width=0.9, lining_offset=0.0)
|
||||
_call_update(fake, props)
|
||||
|
||||
for cfg in fake.swing_arc_props:
|
||||
assert getattr(fake, f"gizmo_swing_arc_{cfg.name}").hide is True
|
||||
assert getattr(fake, f"gizmo_swing_arc_{cfg.name}_flip").hide is True
|
||||
|
||||
|
||||
def test_not_editing_hides_all_arcs():
|
||||
"""``is_editing=False`` collapses every arc's visibility, regardless of door type."""
|
||||
fake = _make_fake_group()
|
||||
props = _make_props(door_type="SINGLE_SWING_LEFT", overall_width=0.9, is_editing=False)
|
||||
_call_update(fake, props)
|
||||
|
||||
for cfg in fake.swing_arc_props:
|
||||
assert getattr(fake, f"gizmo_swing_arc_{cfg.name}").hide is True
|
||||
assert getattr(fake, f"gizmo_swing_arc_{cfg.name}_flip").hide is True
|
||||
|
||||
|
||||
def test_flip_arc_matrix_is_reassigned_each_refresh():
|
||||
"""The flip arc's ``matrix_basis`` must be (re-)assigned on every refresh
|
||||
so a stale identity matrix can never appear at the world origin."""
|
||||
fake = _make_fake_group()
|
||||
props = _make_props(door_type="SINGLE_SWING_LEFT", overall_width=0.9, lining_offset=0.1)
|
||||
_call_update(fake, props)
|
||||
|
||||
assert isinstance(fake.gizmo_swing_arc_primary_flip.matrix_basis, Matrix)
|
||||
assert fake.gizmo_swing_arc_primary_flip.matrix_basis != Matrix.Identity(4)
|
||||
|
||||
|
||||
def test_world_matrix_pre_multiplies_into_arc_transform():
|
||||
"""The caller's world matrix ``mw`` left-multiplies the per-panel transform:
|
||||
a translated ``mw`` shifts every arc by the same offset."""
|
||||
fake = _make_fake_group()
|
||||
props = _make_props(door_type="SINGLE_SWING_LEFT", overall_width=0.9, lining_offset=0.0)
|
||||
mw = Matrix.Translation(Vector((10.0, 20.0, 30.0)))
|
||||
_call_update(fake, props, mw=mw)
|
||||
|
||||
expected = mw @ Matrix.Translation(Vector((0.0, 0.0, 0.0))) @ Matrix.Scale(0.9, 4)
|
||||
_matrix_approx(fake.gizmo_swing_arc_primary.matrix_basis, expected)
|
||||
@@ -0,0 +1,463 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Poll + positioning tests for ``GizmoHostAddOpening``.
|
||||
|
||||
The gizmo dispatches on element type: walls keep the existing axis-projection
|
||||
math, while LAYER3 hosts (slabs, roofs) use a world-Z face bias derived from
|
||||
the void object's elevation. Each branch is exercised independently with
|
||||
mocks so the per-type contract is pinned without launching a full Blender
|
||||
modelling session."""
|
||||
|
||||
import contextlib
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
from test.bim.bootstrap import NewFile
|
||||
from test.bim.module.model.conftest import make_context
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# poll() — entry gate per host type and per co-selection shape
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_IFC_CLASS_BY_KIND = {
|
||||
"wall": "IfcWall",
|
||||
"slab": "IfcSlab",
|
||||
"roof": "IfcRoof",
|
||||
"plain": "IfcDiscreteAccessory",
|
||||
}
|
||||
|
||||
|
||||
class _FakeIfcEntity:
|
||||
"""Minimal stand-in for an ``ifcopenshell.entity_instance`` in poll tests.
|
||||
|
||||
Provides the two surfaces the gizmo's poll consults: ``is_a(type_name)``
|
||||
(used directly by ``is_supported_host`` for slab/roof) and an optional
|
||||
``HasOpenings`` attribute (probed by the poll's ``hasattr`` guard)."""
|
||||
|
||||
def __init__(self, ifc_class: str, has_openings: bool = True):
|
||||
self._ifc_class = ifc_class
|
||||
if has_openings:
|
||||
self.HasOpenings = ()
|
||||
|
||||
def is_a(self, type_name: str) -> bool:
|
||||
return self._ifc_class == type_name
|
||||
|
||||
|
||||
def _build_poll_callbacks(selected, active_kind, other_kind):
|
||||
"""Build the ``(get_entity, is_path_connectable_wall)`` side-effect
|
||||
callables that simulate one poll() invocation. ``active_kind`` /
|
||||
``other_kind`` accept ``"wall"``, ``"slab"``, ``"roof"``, ``"plain"``
|
||||
(non-host IFC element), ``"mesh"`` (no IFC entity), or ``None``
|
||||
(object outside the selection set).
|
||||
|
||||
Wall recognition goes through ``tool.Parametric.is_path_connectable_wall``
|
||||
so fillet-corner walls (which have no LAYER2 usage) also surface the
|
||||
add-opening icon; slab/roof use ``is_a`` on the fake entity so the
|
||||
broadened class-based predicate is exercised."""
|
||||
sentinels = {kind: _FakeIfcEntity(_IFC_CLASS_BY_KIND[kind]) for kind in _IFC_CLASS_BY_KIND}
|
||||
# The "plain" sentinel lacks HasOpenings so the hasattr guard branch
|
||||
# is reachable from the corresponding poll test.
|
||||
sentinels["plain"] = _FakeIfcEntity(_IFC_CLASS_BY_KIND["plain"], has_openings=False)
|
||||
|
||||
def entity_for(kind):
|
||||
if kind in (None, "mesh"):
|
||||
return None
|
||||
return sentinels[kind]
|
||||
|
||||
entity_map = {}
|
||||
if len(selected) >= 1:
|
||||
entity_map[id(selected[0])] = entity_for(active_kind)
|
||||
if len(selected) >= 2:
|
||||
entity_map[id(selected[1])] = entity_for(other_kind)
|
||||
|
||||
def get_entity(obj):
|
||||
return entity_map.get(id(obj))
|
||||
|
||||
def is_path_connectable_wall(element):
|
||||
return element is sentinels["wall"]
|
||||
|
||||
return get_entity, is_path_connectable_wall
|
||||
|
||||
|
||||
def _run_poll(
|
||||
patched_tool, prefs_on=True, n_selected=2, active_in_selected=True, active_kind="wall", other_kind="mesh"
|
||||
):
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import GizmoHostAddOpening
|
||||
|
||||
selected = [object() for _ in range(n_selected)]
|
||||
active = selected[0] if (active_in_selected and selected) else object()
|
||||
get_entity, is_path_connectable_wall = _build_poll_callbacks(selected, active_kind, other_kind)
|
||||
|
||||
with patched_tool(
|
||||
viewport_gizmos=prefs_on,
|
||||
selected=selected,
|
||||
entity=get_entity,
|
||||
modifier_predicates={"is_path_connectable_wall": is_path_connectable_wall},
|
||||
):
|
||||
return GizmoHostAddOpening.poll(make_context(active=active, selected=selected))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host_kind", ["wall", "slab", "roof"])
|
||||
def test_poll_accepts_each_host_with_a_plain_mesh_void(host_kind, patched_tool):
|
||||
assert _run_poll(patched_tool, active_kind=host_kind, other_kind="mesh") is True
|
||||
|
||||
|
||||
def test_poll_rejects_when_gizmo_toggle_off(patched_tool):
|
||||
assert _run_poll(patched_tool, prefs_on=False) is False
|
||||
|
||||
|
||||
def test_poll_rejects_when_selection_count_is_not_two(patched_tool):
|
||||
assert _run_poll(patched_tool, n_selected=1) is False
|
||||
assert _run_poll(patched_tool, n_selected=3) is False
|
||||
|
||||
|
||||
def test_poll_rejects_when_active_is_not_in_selection(patched_tool):
|
||||
assert _run_poll(patched_tool, active_in_selected=False) is False
|
||||
|
||||
|
||||
def test_poll_rejects_when_active_has_no_ifc_entity(patched_tool):
|
||||
assert _run_poll(patched_tool, active_kind="mesh") is False
|
||||
|
||||
|
||||
def test_poll_rejects_when_active_is_not_a_host(patched_tool):
|
||||
# "plain" sentinel is recognised as an IFC entity but is none of wall/slab/roof.
|
||||
assert _run_poll(patched_tool, active_kind="plain") is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"active_kind,other_kind",
|
||||
[
|
||||
("wall", "wall"), # wall-join gizmo owns this
|
||||
("slab", "slab"), # future slab-edit gizmo
|
||||
("roof", "roof"),
|
||||
("wall", "slab"), # extend-vertically gizmo overlaps with this
|
||||
("slab", "wall"),
|
||||
("roof", "wall"),
|
||||
],
|
||||
)
|
||||
def test_poll_rejects_host_host_pairs(active_kind, other_kind, patched_tool):
|
||||
"""Host + host pairings must be suppressed so the icon never stacks with
|
||||
the wall-join / extend-vertical / future slab-edit gizmos."""
|
||||
assert _run_poll(patched_tool, active_kind=active_kind, other_kind=other_kind) is False
|
||||
|
||||
|
||||
def test_poll_rejects_active_host_without_has_openings(patched_tool):
|
||||
# Real-world equivalent: an IFC class that the active schema strips
|
||||
# ``HasOpenings`` from (e.g., a non-element subtype). The active sentinel
|
||||
# is set up as a connectable wall but with no HasOpenings attribute.
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import GizmoHostAddOpening
|
||||
|
||||
selected = [object(), object()]
|
||||
active = selected[0]
|
||||
host_sentinel = object() # No HasOpenings attribute
|
||||
other_sentinel = None
|
||||
|
||||
with patched_tool(
|
||||
viewport_gizmos=True,
|
||||
selected=selected,
|
||||
entity=lambda o: host_sentinel if o is selected[0] else other_sentinel,
|
||||
modifier_predicates={"is_path_connectable_wall": lambda e: e is host_sentinel},
|
||||
):
|
||||
assert GizmoHostAddOpening.poll(make_context(active=active, selected=selected)) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# position_gizmos() — branch dispatch and per-branch anchor math
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _run_position_wall_branch(patched_tool, *, other_translation=(0.5, 0.0, 0.0), top_down=True):
|
||||
"""Drive the wall branch with stub IFC reads, returning the icon's
|
||||
matrix_basis translation."""
|
||||
from bonsai.bim.module.drawing import gizmos as gizmo_module
|
||||
from bonsai.bim.module.model import host_add_opening_gizmo as host_mod
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import GizmoHostAddOpening
|
||||
|
||||
geom = {"anchor_x": 0.0, "length": 2.0, "height": 3.0, "offset": 0.0, "thickness": 0.2}
|
||||
wall_element = object()
|
||||
active = SimpleNamespace(matrix_world=Matrix.Identity(4))
|
||||
other = SimpleNamespace(matrix_world=Matrix.Translation(Vector(other_translation)))
|
||||
selected = [active, other]
|
||||
context = SimpleNamespace(active_object=active)
|
||||
icon = SimpleNamespace(matrix_basis=None, hide=True)
|
||||
self_stub = SimpleNamespace(add_opening_icon=icon)
|
||||
|
||||
with contextlib.ExitStack() as stack:
|
||||
stack.enter_context(
|
||||
patched_tool(
|
||||
selected_list=selected,
|
||||
entity=wall_element,
|
||||
modifier_predicates={"is_path_connectable_wall": True},
|
||||
view_top_down=top_down,
|
||||
screen_up=Vector((0.0, 1.0, 0.0)),
|
||||
)
|
||||
)
|
||||
stack.enter_context(patch.object(host_mod, "_get_wall_geom_cached", return_value=geom))
|
||||
stack.enter_context(patch.object(host_mod, "_wall_camera_facing_icon_y", return_value=0.0))
|
||||
stack.enter_context(patch.object(gizmo_module, "get_billboard_rotation", return_value=Matrix.Identity(4)))
|
||||
stack.enter_context(
|
||||
patch.object(
|
||||
gizmo_module, "billboarded_at", side_effect=lambda pos, rot, scale=0.5: Matrix.Translation(pos)
|
||||
)
|
||||
)
|
||||
GizmoHostAddOpening.position_gizmos(self_stub, context)
|
||||
return icon.matrix_basis.translation
|
||||
|
||||
|
||||
def test_wall_branch_drops_height_lift_in_top_down_view(patched_tool):
|
||||
"""In plan view the wall-top Z lift must collapse to zero and the icon
|
||||
must instead offset along screen-up — otherwise the icon stacks on top
|
||||
of the wall outline and the user can't see it."""
|
||||
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
|
||||
|
||||
pos = _run_position_wall_branch(patched_tool, top_down=True)
|
||||
assert pos.z == pytest.approx(0.0)
|
||||
assert pos.y == pytest.approx(BaseParametricGizmoGroup.SCREEN_STACK_OFFSET)
|
||||
|
||||
|
||||
def _run_position_layer3_branch(
|
||||
patched_tool, *, host_world_z_range=(0.0, 0.2), other_z=1.0, other_xy=(0.7, 0.4), is_path_connectable_wall=False
|
||||
):
|
||||
"""Drive the LAYER3 (slab/roof) branch and return the icon translation.
|
||||
|
||||
``host_world_z_range`` sets the world-Z extents of the host's bounding box
|
||||
(the gizmo picks top vs bottom by comparing the void's Z to the box
|
||||
midpoint). ``is_path_connectable_wall`` keeps a single helper for both
|
||||
branches by flipping the dispatch predicate."""
|
||||
from bonsai.bim.module.drawing import gizmos as gizmo_module
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import GizmoHostAddOpening
|
||||
|
||||
z_min, z_max = host_world_z_range
|
||||
# bound_box returns 8 corners in local space; we only need their world-Z
|
||||
# range to drive the branch, so fix XY at zero and vary Z.
|
||||
local_corners = [(0.0, 0.0, z_min), (0.0, 0.0, z_max)] * 4
|
||||
host_obj = SimpleNamespace(matrix_world=Matrix.Identity(4), bound_box=local_corners)
|
||||
other = SimpleNamespace(matrix_world=Matrix.Translation(Vector((other_xy[0], other_xy[1], other_z))))
|
||||
selected = [host_obj, other]
|
||||
context = SimpleNamespace(active_object=host_obj)
|
||||
icon = SimpleNamespace(matrix_basis=None, hide=True)
|
||||
self_stub = SimpleNamespace(add_opening_icon=icon)
|
||||
|
||||
host_element = object()
|
||||
with contextlib.ExitStack() as stack:
|
||||
stack.enter_context(
|
||||
patched_tool(
|
||||
selected_list=selected,
|
||||
entity=host_element,
|
||||
modifier_predicates={"is_path_connectable_wall": is_path_connectable_wall},
|
||||
)
|
||||
)
|
||||
stack.enter_context(patch.object(gizmo_module, "get_billboard_rotation", return_value=Matrix.Identity(4)))
|
||||
stack.enter_context(
|
||||
patch.object(
|
||||
gizmo_module, "billboarded_at", side_effect=lambda pos, rot, scale=0.5: Matrix.Translation(pos)
|
||||
)
|
||||
)
|
||||
GizmoHostAddOpening.position_gizmos(self_stub, context)
|
||||
return icon.matrix_basis.translation
|
||||
|
||||
|
||||
@pytest.mark.parametrize("other_z", [1.0, 0.1, -1.0])
|
||||
def test_layer3_branch_always_parks_above_top_face(patched_tool, other_z):
|
||||
"""Icon parks above the host's top face regardless of the void's Z —
|
||||
predictable height every time. Void's XY is preserved so clicking the
|
||||
icon dispatches the operator at the intended XY position."""
|
||||
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
|
||||
|
||||
pos = _run_position_layer3_branch(patched_tool, host_world_z_range=(0.0, 0.2), other_z=other_z, other_xy=(0.7, 0.4))
|
||||
assert pos.x == pytest.approx(0.7)
|
||||
assert pos.y == pytest.approx(0.4)
|
||||
assert pos.z == pytest.approx(0.2 + BaseParametricGizmoGroup.ICON_Z_OFFSET)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_supported_host() — predicate totality
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_is_supported_host_returns_false_for_none():
|
||||
"""Total predicate: ``None`` short-circuits to False without raising."""
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import is_supported_host
|
||||
|
||||
assert is_supported_host(None) is False
|
||||
|
||||
|
||||
def test_is_supported_host_accepts_bare_ifc_slab():
|
||||
"""The slab branch is class-based — any ``IfcSlab`` qualifies, even
|
||||
without LAYER3 parametric usage. The positioner reads ``obj.bound_box``,
|
||||
which works for both parametric and imported geometry."""
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import is_supported_host
|
||||
|
||||
assert is_supported_host(_FakeIfcEntity("IfcSlab")) is True
|
||||
|
||||
|
||||
def test_is_supported_host_accepts_bare_ifc_roof():
|
||||
"""The roof branch is class-based, not pset-based — a bare ``IfcRoof``
|
||||
imported from another IFC tool qualifies even without the Bonsai
|
||||
BBIM_Roof parametric marker that ``tool.Parametric.is_roof``
|
||||
would require."""
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import is_supported_host
|
||||
|
||||
assert is_supported_host(_FakeIfcEntity("IfcRoof")) is True
|
||||
|
||||
|
||||
def test_is_supported_host_rejects_non_host_ifc_class():
|
||||
"""Non-host IFC classes are filtered — covers ``IfcCovering`` (which has
|
||||
HasOpenings but is not a wall/slab/roof) and prevents the gizmo from
|
||||
surfacing on arbitrary building elements."""
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import is_supported_host
|
||||
|
||||
assert is_supported_host(_FakeIfcEntity("IfcCovering")) is False
|
||||
assert is_supported_host(_FakeIfcEntity("IfcDiscreteAccessory")) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End-to-end smoke: gizmo's target operator handles host + mesh-void selection
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# The gizmo binds ``bim.add_opening`` via ``setup_icon_gizmo`` — clicking the
|
||||
# icon dispatches that operator with the current selection set. The operator
|
||||
# has its own target/opening detection that swaps based on which selected
|
||||
# object carries an IFC entity. This smoke test pins that handoff: with a
|
||||
# host as the active object and a non-IFC mesh as the "void", the operator
|
||||
# creates an ``IfcOpeningElement`` linked to the host via the standard
|
||||
# ``HasOpenings`` inverse.
|
||||
|
||||
|
||||
class TestAddOpeningIntegrationOnSlab(NewFile):
|
||||
def test_creates_opening_when_slab_is_active_with_mesh_void(self):
|
||||
tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc"
|
||||
bpy.ops.bim.create_project()
|
||||
ifc_file = tool.Ifc.get()
|
||||
slab_type = ifc_file.by_type("IfcSlabType")[0]
|
||||
bpy.ops.bim.add_occurrence(relating_type_id=slab_type.id())
|
||||
slab = ifc_file.by_type("IfcSlab")[0]
|
||||
slab_obj = tool.Ifc.get_object(slab)
|
||||
assert isinstance(slab_obj, bpy.types.Object)
|
||||
assert len(slab.HasOpenings) == 0
|
||||
|
||||
void_obj = bpy.data.objects.new("VoidMesh", bpy.data.meshes.new("VoidMesh"))
|
||||
bpy.context.scene.collection.objects.link(void_obj)
|
||||
void_obj.matrix_world = void_obj.matrix_world.copy()
|
||||
void_obj.matrix_world.translation = (
|
||||
slab_obj.matrix_world.translation.x,
|
||||
slab_obj.matrix_world.translation.y,
|
||||
slab_obj.matrix_world.translation.z + 1.0,
|
||||
)
|
||||
|
||||
tool.Blender.set_objects_selection(bpy.context, slab_obj, (slab_obj, void_obj))
|
||||
bpy.ops.bim.add_opening()
|
||||
|
||||
assert len(slab.HasOpenings) == 1
|
||||
opening = slab.HasOpenings[0].RelatedOpeningElement
|
||||
assert opening.is_a("IfcOpeningElement")
|
||||
|
||||
|
||||
class TestAddOpeningPollOnForeignAuthoredSlab(NewFile):
|
||||
def test_poll_resolves_true_for_slab_without_layer3_usage(self):
|
||||
"""An ``IfcSlab`` loaded from a non-Bonsai IFC carries no
|
||||
``IfcMaterialLayerSetUsage``, so ``tool.Blender.Modifier.is_slab``
|
||||
rejects it — yet the gizmo's widened predicate accepts any
|
||||
``IfcSlab`` because the positioner only reads the bound box.
|
||||
This pins the bare-class branch through the full ``poll`` path
|
||||
with real bpy + ifcopenshell state."""
|
||||
import ifcopenshell.api.material
|
||||
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import (
|
||||
GizmoHostAddOpening,
|
||||
is_supported_host,
|
||||
)
|
||||
|
||||
tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc"
|
||||
bpy.ops.bim.create_project()
|
||||
ifc_file = tool.Ifc.get()
|
||||
slab_type = ifc_file.by_type("IfcSlabType")[0]
|
||||
bpy.ops.bim.add_occurrence(relating_type_id=slab_type.id())
|
||||
slab = ifc_file.by_type("IfcSlab")[0]
|
||||
slab_obj = tool.Ifc.get_object(slab)
|
||||
assert isinstance(slab_obj, bpy.types.Object)
|
||||
|
||||
# Strip every material association so the slab has no direct
|
||||
# LayerSetUsage and nothing to inherit from the type. The slab is
|
||||
# now a foreign-authored IFC class in everything but provenance.
|
||||
ifcopenshell.api.material.unassign_material(ifc_file, products=[slab, slab_type])
|
||||
assert tool.Blender.Modifier.is_slab(slab) is False
|
||||
assert is_supported_host(slab) is True
|
||||
|
||||
void_obj = bpy.data.objects.new("VoidMesh", bpy.data.meshes.new("VoidMesh"))
|
||||
bpy.context.scene.collection.objects.link(void_obj)
|
||||
void_obj.matrix_world = void_obj.matrix_world.copy()
|
||||
void_obj.matrix_world.translation = (
|
||||
slab_obj.matrix_world.translation.x,
|
||||
slab_obj.matrix_world.translation.y,
|
||||
slab_obj.matrix_world.translation.z + 1.0,
|
||||
)
|
||||
|
||||
tool.Blender.set_objects_selection(bpy.context, slab_obj, (slab_obj, void_obj))
|
||||
assert GizmoHostAddOpening.poll(bpy.context) is True
|
||||
|
||||
|
||||
class TestAddOpeningPollOnForeignAuthoredRoof(NewFile):
|
||||
def test_poll_resolves_true_for_roof_without_bbim_pset(self):
|
||||
"""A mesh-bodied ``IfcRoof`` promoted from a raw Blender mesh
|
||||
carries no ``BBIM_Roof`` pset, so ``tool.Parametric.is_roof``
|
||||
rejects it — yet the gizmo's widened predicate accepts any
|
||||
``IfcRoof`` because the positioner only reads the bound box. This
|
||||
fixture mirrors how a foreign IFC roof loads (geometry + IFC
|
||||
identity, no parametric markers)."""
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import (
|
||||
GizmoHostAddOpening,
|
||||
is_supported_host,
|
||||
)
|
||||
|
||||
tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc"
|
||||
bpy.ops.bim.create_project()
|
||||
|
||||
bpy.ops.mesh.primitive_cube_add(size=2, location=(0, 0, 0))
|
||||
roof_obj = bpy.context.active_object
|
||||
assert roof_obj is not None
|
||||
tool.Root.get_root_props().ifc_product = "IfcElement"
|
||||
bpy.ops.bim.assign_class(ifc_class="IfcRoof")
|
||||
roof = tool.Ifc.get_entity(roof_obj)
|
||||
assert roof is not None and roof.is_a("IfcRoof")
|
||||
assert tool.Parametric.is_roof(roof) is False
|
||||
assert is_supported_host(roof) is True
|
||||
|
||||
void_obj = bpy.data.objects.new("VoidMesh", bpy.data.meshes.new("VoidMesh"))
|
||||
bpy.context.scene.collection.objects.link(void_obj)
|
||||
void_obj.matrix_world = void_obj.matrix_world.copy()
|
||||
void_obj.matrix_world.translation = (
|
||||
roof_obj.matrix_world.translation.x,
|
||||
roof_obj.matrix_world.translation.y,
|
||||
roof_obj.matrix_world.translation.z + 1.0,
|
||||
)
|
||||
|
||||
tool.Blender.set_objects_selection(bpy.context, roof_obj, (roof_obj, void_obj))
|
||||
assert GizmoHostAddOpening.poll(bpy.context) is True
|
||||
@@ -0,0 +1,306 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Unit tests for the roof parametric gizmo group.
|
||||
|
||||
Covers the parts of ``GizmoRoofEdition`` that don't need a live Blender
|
||||
viewport: the mode-conditional ``visibility_condition`` lambdas, the
|
||||
slope ``compute_value`` / ``apply_value`` roundtrip, the
|
||||
``CycleRoofGenerationMethod`` operator metadata + cycle behaviour, and
|
||||
the ``_update_dimension_gizmo_positions`` override that anchors all three
|
||||
dimension gizmos at the object's local origin."""
|
||||
|
||||
import math
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
def _get_config(attr_name):
|
||||
"""Return the ``DimensionGizmoConfig`` for ``attr_name`` from the roof gizmo."""
|
||||
from bonsai.bim.module.model.roof import GizmoRoofEdition
|
||||
|
||||
for cfg in GizmoRoofEdition.dimension_gizmo_props:
|
||||
if cfg.attr_name == attr_name:
|
||||
return cfg
|
||||
raise AssertionError(f"no DimensionGizmoConfig with attr_name={attr_name!r}")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Mode-conditional visibility
|
||||
# ----------------------------------------------------------------------------
|
||||
#
|
||||
# ``height`` and ``angle`` are mutually exclusive — exactly one is shown
|
||||
# depending on ``generation_method``. ``roof_thickness`` applies regardless
|
||||
# of the generation mode.
|
||||
|
||||
|
||||
def test_height_gizmo_visible_only_in_height_mode():
|
||||
cfg = _get_config("height")
|
||||
assert cfg.visibility_condition(SimpleNamespace(generation_method="HEIGHT")) is True
|
||||
assert cfg.visibility_condition(SimpleNamespace(generation_method="ANGLE")) is False
|
||||
|
||||
|
||||
def test_angle_gizmo_visible_only_in_angle_mode():
|
||||
cfg = _get_config("angle")
|
||||
assert cfg.visibility_condition(SimpleNamespace(generation_method="ANGLE")) is True
|
||||
assert cfg.visibility_condition(SimpleNamespace(generation_method="HEIGHT")) is False
|
||||
|
||||
|
||||
def test_thickness_has_no_mode_gate():
|
||||
"""Slab thickness applies to both generation modes — pinning
|
||||
``visibility_condition is None`` guards against an accidental mode-gate
|
||||
being added later that would silently hide it when toggling modes."""
|
||||
assert _get_config("roof_thickness").visibility_condition is None
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Slope (angle) ↔ rise roundtrip
|
||||
# ----------------------------------------------------------------------------
|
||||
#
|
||||
# The slope handle displays vertical rise at a fixed 1m run; dragging it
|
||||
# updates ``props.angle`` via ``atan2(rise, run)``. Roundtrip preservation
|
||||
# is the contract — feeding ``compute_value`` into ``apply_value`` must
|
||||
# leave the angle unchanged (within float tolerance).
|
||||
|
||||
|
||||
def test_slope_compute_value_returns_rise_at_reference_run():
|
||||
from bonsai.bim.module.model.roof import _ROOF_SLOPE_REFERENCE_RUN
|
||||
|
||||
cfg = _get_config("angle")
|
||||
# 30° slope → rise = tan(30°) * 1m ≈ 0.5774 m
|
||||
props = SimpleNamespace(angle=math.radians(30))
|
||||
assert cfg.compute_value(props) == pytest.approx(math.tan(math.radians(30)) * _ROOF_SLOPE_REFERENCE_RUN)
|
||||
|
||||
|
||||
def test_slope_apply_value_sets_angle_from_rise():
|
||||
from bonsai.bim.module.model.roof import _ROOF_SLOPE_REFERENCE_RUN
|
||||
|
||||
cfg = _get_config("angle")
|
||||
props = SimpleNamespace(angle=0.0)
|
||||
cfg.apply_value(props, 0.5)
|
||||
assert props.angle == pytest.approx(math.atan2(0.5, _ROOF_SLOPE_REFERENCE_RUN))
|
||||
|
||||
|
||||
def test_slope_roundtrip_preserves_angle():
|
||||
cfg = _get_config("angle")
|
||||
for deg in (5, 15, 30, 45, 60, 80):
|
||||
props = SimpleNamespace(angle=math.radians(deg))
|
||||
rise = cfg.compute_value(props)
|
||||
cfg.apply_value(props, rise)
|
||||
assert math.degrees(props.angle) == pytest.approx(deg, abs=1e-6)
|
||||
|
||||
|
||||
def test_slope_apply_value_clamps_negative_to_zero():
|
||||
"""A negative drag (rise < 0) must not produce a negative angle —
|
||||
``atan2(-x, run)`` would yield a negative result, but ``apply_value``
|
||||
clamps to ``[0, pi/2 - 1e-3]`` so the roof never inverts."""
|
||||
cfg = _get_config("angle")
|
||||
props = SimpleNamespace(angle=math.radians(30))
|
||||
cfg.apply_value(props, -1.0)
|
||||
assert props.angle == 0.0
|
||||
|
||||
|
||||
def test_slope_apply_value_clamps_at_near_vertical():
|
||||
"""Slopes approaching 90° are clamped just below to avoid a vertical
|
||||
extrusion that would degenerate the bisect step in
|
||||
``generate_hipped_roof_bmesh``."""
|
||||
from bonsai.bim.module.model.roof import _ROOF_MAX_SLOPE_ANGLE
|
||||
|
||||
cfg = _get_config("angle")
|
||||
props = SimpleNamespace(angle=0.0)
|
||||
cfg.apply_value(props, 1e9) # absurdly steep
|
||||
assert props.angle == pytest.approx(_ROOF_MAX_SLOPE_ANGLE)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Cycle operator metadata
|
||||
# ----------------------------------------------------------------------------
|
||||
#
|
||||
# ``CycleRoofGenerationMethod`` plugs into ``CycleTypeMixin`` so the
|
||||
# HEIGHT ↔ ANGLE icon cycles through the two values. The mixin reads four
|
||||
# class attributes to do its work; if any drift, the cycle no-ops or
|
||||
# CANCELLED-loops in subtle ways. Pin them here.
|
||||
|
||||
|
||||
def test_cycle_operator_class_metadata():
|
||||
from typing import get_args
|
||||
|
||||
from bonsai import tool
|
||||
from bonsai.bim.module.model.roof import CycleRoofGenerationMethod
|
||||
|
||||
assert CycleRoofGenerationMethod.bl_idname == "bim.cycle_roof_generation_method"
|
||||
assert CycleRoofGenerationMethod.element_checker == tool.Parametric.is_roof
|
||||
assert CycleRoofGenerationMethod.props_getter == tool.Model.get_roof_props
|
||||
assert CycleRoofGenerationMethod.type_attr == "generation_method"
|
||||
# The Literal resolves to ("HEIGHT", "ANGLE") — the mixin calls
|
||||
# ``get_args(type_literal)`` to enumerate the cycle.
|
||||
assert get_args(CycleRoofGenerationMethod.type_literal) == ("HEIGHT", "ANGLE")
|
||||
assert CycleRoofGenerationMethod.type_literal is tool.Model.RoofGenerationMethod
|
||||
|
||||
|
||||
def test_cycle_operator_wired_on_gizmo_group():
|
||||
"""The gizmo group's ``cycle_type_operator`` must match the bl_idname or
|
||||
the base class skips the cycle icon entirely (see gizmos.py:4987)."""
|
||||
from bonsai.bim.module.model.roof import CycleRoofGenerationMethod, GizmoRoofEdition
|
||||
|
||||
assert GizmoRoofEdition.cycle_type_operator == CycleRoofGenerationMethod.bl_idname
|
||||
|
||||
|
||||
def _cycle_stub_self(*, reverse: bool, props, element_is_target: bool = True):
|
||||
"""Build a stub ``self`` for ``CycleTypeMixin._cycle_type``.
|
||||
|
||||
``bpy.types.Operator`` subclasses can't be ``__init__``-ed outside of
|
||||
Blender's registration path (``bpy_struct.__new__`` rejects a bare
|
||||
call). Calling the unbound mixin method with a stub ``self`` that
|
||||
mirrors the class attributes the method reads is the cleanest way to
|
||||
exercise the cycle logic without launching a registered operator
|
||||
instance.
|
||||
|
||||
``element_checker`` and ``props_getter`` are captured by the cycle
|
||||
operator at class-definition time, so global ``tool.*`` patches at
|
||||
test time can't intercept them — the stub injects callables directly
|
||||
instead. ``_resolve_target`` is bound from ``TypeAccessorBase`` so
|
||||
the cycle method's call into it dispatches against the stub
|
||||
attributes."""
|
||||
from types import MethodType
|
||||
|
||||
from bonsai.bim.module.model.roof import CycleRoofGenerationMethod
|
||||
from bonsai.bim.parametric_lifecycle import TypeAccessorBase
|
||||
|
||||
stub = SimpleNamespace(
|
||||
reverse=reverse,
|
||||
skip_element_check=False,
|
||||
element_checker=lambda _elem: element_is_target,
|
||||
props_getter=lambda _obj: props,
|
||||
type_literal=CycleRoofGenerationMethod.type_literal,
|
||||
type_attr=CycleRoofGenerationMethod.type_attr,
|
||||
)
|
||||
stub._resolve_target = MethodType(TypeAccessorBase._resolve_target, stub)
|
||||
return stub
|
||||
|
||||
|
||||
def test_cycle_type_advances_forward():
|
||||
"""``_cycle_type`` advances the prop value to the next item in the
|
||||
Literal. The stub injects ``element_checker`` / ``props_getter``
|
||||
directly so the method runs without a live IFC fixture."""
|
||||
from bonsai import tool
|
||||
from bonsai.bim import parametric_lifecycle as gizmo_module
|
||||
|
||||
props = SimpleNamespace(generation_method="HEIGHT")
|
||||
context = SimpleNamespace(active_object=object())
|
||||
|
||||
with patch.object(tool.Ifc, "get_entity", return_value=object()):
|
||||
result = gizmo_module.CycleTypeMixin._cycle_type(_cycle_stub_self(reverse=False, props=props), context)
|
||||
assert result == {"FINISHED"}
|
||||
assert props.generation_method == "ANGLE"
|
||||
|
||||
|
||||
def test_cycle_type_reverse_walks_backward():
|
||||
"""Shift+click sets ``reverse=True`` and walks the cycle in the other
|
||||
direction — from HEIGHT that means wrapping to ANGLE (the last item)."""
|
||||
from bonsai import tool
|
||||
from bonsai.bim import parametric_lifecycle as gizmo_module
|
||||
|
||||
props = SimpleNamespace(generation_method="HEIGHT")
|
||||
context = SimpleNamespace(active_object=object())
|
||||
|
||||
with patch.object(tool.Ifc, "get_entity", return_value=object()):
|
||||
gizmo_module.CycleTypeMixin._cycle_type(_cycle_stub_self(reverse=True, props=props), context)
|
||||
assert props.generation_method == "ANGLE" # wrapped from HEIGHT backward
|
||||
|
||||
|
||||
def test_cycle_type_cancels_when_active_is_not_a_roof():
|
||||
"""Non-roof active object → CANCELLED, props untouched. Guards against
|
||||
a stray cycle click on a wall mutating ``wall.generation_method`` (a
|
||||
non-existent attr) and silently no-oping or AttributeError-ing later."""
|
||||
from bonsai import tool
|
||||
from bonsai.bim import parametric_lifecycle as gizmo_module
|
||||
|
||||
props = SimpleNamespace(generation_method="HEIGHT")
|
||||
context = SimpleNamespace(active_object=object())
|
||||
|
||||
with patch.object(tool.Ifc, "get_entity", return_value=object()):
|
||||
result = gizmo_module.CycleTypeMixin._cycle_type(
|
||||
_cycle_stub_self(reverse=False, props=props, element_is_target=False),
|
||||
context,
|
||||
)
|
||||
assert result == {"CANCELLED"}
|
||||
assert props.generation_method == "HEIGHT"
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# _update_dimension_gizmo_positions — origin anchoring
|
||||
# ----------------------------------------------------------------------------
|
||||
#
|
||||
# All three dimension gizmos anchor at the object's local origin. Their
|
||||
# declared axes (height/slope +Z, thickness -Z) separate them in 3D so
|
||||
# they don't visually collide despite sharing a position; height + slope
|
||||
# are themselves mutually exclusive via visibility_condition on
|
||||
# generation_method.
|
||||
|
||||
|
||||
def test_override_positions_all_dimensions_at_object_origin():
|
||||
"""The override calls ``set_dimension_gizmo_position`` with the
|
||||
object-local origin (0, 0, 0) for every dimension gizmo. Anchoring at
|
||||
the object origin keeps the gizmos tied to the object's matrix_world
|
||||
rather than to footprint geometry that may not be cached yet — fixes
|
||||
the first-click default-identity-matrix bug structurally."""
|
||||
from bonsai.bim.module.model.roof import GizmoRoofEdition
|
||||
|
||||
calls: dict[str, tuple] = {}
|
||||
|
||||
def record(attr_name, _mw, position, axis, _value=None):
|
||||
calls[attr_name] = (position, axis)
|
||||
|
||||
stub = SimpleNamespace(set_dimension_gizmo_position=record)
|
||||
GizmoRoofEdition._update_dimension_gizmo_positions(stub, context=None, mw=None, props=None)
|
||||
|
||||
assert set(calls) == {"height", "angle", "roof_thickness"}
|
||||
for name in ("height", "angle", "roof_thickness"):
|
||||
position, _axis = calls[name]
|
||||
assert position.xyz[:] == pytest.approx(
|
||||
(0.0, 0.0, 0.0)
|
||||
), f"{name} anchored at {position.xyz[:]} instead of object origin"
|
||||
# Axes split the three handles along Z+ (height/slope) vs Z- (thickness)
|
||||
# so they don't visually collide despite sharing the anchor point.
|
||||
assert calls["height"][1] == (0, 0, 1)
|
||||
assert calls["angle"][1] == (0, 0, 1)
|
||||
assert calls["roof_thickness"][1] == (0, 0, -1)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Registration smoke test
|
||||
# ----------------------------------------------------------------------------
|
||||
#
|
||||
# Pattern 4 from _shared/bonsai-test-patterns.md: assert the operator is
|
||||
# actually registered as ``bim.cycle_roof_generation_method``. Catches
|
||||
# ``bl_idname`` typos and missing-from-``classes``-tuple regressions at
|
||||
# test time rather than at user-click time (the failure mode otherwise is
|
||||
# a silent no-op on the cycle icon, because the gizmo base class skips the
|
||||
# icon entirely if its ``cycle_type_operator`` resolves to nothing).
|
||||
|
||||
|
||||
def test_cycle_operator_is_registered_under_bim_namespace():
|
||||
assert hasattr(bpy.ops.bim, "cycle_roof_generation_method")
|
||||
@@ -133,3 +133,75 @@ def test_set_icon_gizmo_position_does_not_apply_object_rotation():
|
||||
for row_a, row_b in zip(stub.matrix_basis, expected):
|
||||
for va, vb in zip(row_a, row_b):
|
||||
assert abs(va - vb) < 1e-6
|
||||
|
||||
|
||||
def test_icon_slot_placeholder_skips_validation_and_returns_no_attrs():
|
||||
"""Placeholder slots reserve an X position without an auto-created gizmo:
|
||||
construction must not require ``gizmo_idname`` / ``operator``, and
|
||||
``gizmo_attrs()`` must return an empty tuple so the base class's
|
||||
setup/positioning loops naturally skip the slot."""
|
||||
from bonsai.bim.module.drawing.gizmos import IconSlot
|
||||
|
||||
slot = IconSlot(name="my_label", placeholder=True)
|
||||
assert slot.placeholder is True
|
||||
assert slot.gizmo_attrs() == ()
|
||||
|
||||
with pytest.raises(TypeError, match="gizmo_idname"):
|
||||
IconSlot(name="broken")
|
||||
|
||||
|
||||
def test_count_label_gizmo_is_registered():
|
||||
"""The shared text-only ``xN`` gizmo must register so the stair group's
|
||||
``gizmos.new("BIM_GT_count_label")`` resolves."""
|
||||
from bonsai.bim.module.drawing.gizmos import GizmoCountLabel
|
||||
|
||||
assert GizmoCountLabel.bl_idname == "BIM_GT_count_label"
|
||||
assert bpy.types.Gizmo.bl_rna_get_subclass_py("BIM_GT_count_label") is GizmoCountLabel
|
||||
|
||||
|
||||
def test_stair_edit_row_reserves_label_slot_between_tread_lock_and_plus():
|
||||
"""The ``tread_count_label`` placeholder slot must sit one
|
||||
``ICON_ARRAY_GAP`` past the tread-lock and one gap before the plus
|
||||
icon, so the layout naturally allocates the count label's X without
|
||||
any subclass-side gap math."""
|
||||
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
|
||||
from bonsai.bim.module.model.stair import GizmoStairEdition
|
||||
|
||||
slot_x = GizmoStairEdition._slot_x_positions()
|
||||
gap = BaseParametricGizmoGroup.ICON_ARRAY_GAP
|
||||
|
||||
assert "tread_count_label" in slot_x
|
||||
assert slot_x["tread_count_label"] - slot_x["tread_lock"] == pytest.approx(gap)
|
||||
assert slot_x["plus"] - slot_x["tread_count_label"] == pytest.approx(gap)
|
||||
assert slot_x["minus"] - slot_x["plus"] == pytest.approx(gap)
|
||||
|
||||
|
||||
def test_update_tread_count_gizmos_toggles_label_with_editing():
|
||||
"""``update_tread_count_gizmos`` must propagate ``props.is_editing``
|
||||
to the label's hide state so the badge appears only inside edit mode."""
|
||||
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
|
||||
from bonsai.bim.module.model.stair import GizmoStairEdition
|
||||
|
||||
class _GizmoStub:
|
||||
def __init__(self):
|
||||
self.hide = False
|
||||
|
||||
plus_gz = _GizmoStub()
|
||||
minus_gz = _GizmoStub()
|
||||
label_gz = _GizmoStub()
|
||||
|
||||
fake_self = types.SimpleNamespace(
|
||||
plus_gizmo=plus_gz,
|
||||
minus_gizmo=minus_gz,
|
||||
tread_count_label_gizmo=label_gz,
|
||||
update_gizmo_visibility=lambda g, v: BaseParametricGizmoGroup.update_gizmo_visibility(fake_self, g, v),
|
||||
is_gizmo_hidden_by_modal=lambda g: False,
|
||||
)
|
||||
|
||||
props_editing = types.SimpleNamespace(is_editing=True, number_of_treads=5)
|
||||
GizmoStairEdition.update_tread_count_gizmos(fake_self, props_editing)
|
||||
assert label_gz.hide is False
|
||||
|
||||
props_idle = types.SimpleNamespace(is_editing=False, number_of_treads=5)
|
||||
GizmoStairEdition.update_tread_count_gizmos(fake_self, props_idle)
|
||||
assert label_gz.hide is True
|
||||
|
||||
@@ -202,11 +202,11 @@ def _make_path_rel(relating, related, relating_ct, related_ct, kind="IfcRelConne
|
||||
)
|
||||
|
||||
|
||||
def _run_iter_path_connections(elem, *, is_wall_predicate=lambda _e: True):
|
||||
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.Blender.Modifier, "is_wall", side_effect=is_wall_predicate):
|
||||
with patch.object(tool.Parametric, "is_path_connectable_wall", side_effect=partner_predicate):
|
||||
return _iter_path_connections(elem)
|
||||
|
||||
|
||||
@@ -260,14 +260,28 @@ def test_iter_path_connections_skips_non_wall_partners():
|
||||
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, is_wall_predicate=lambda e: e is wall_partner)
|
||||
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, `Modifier.is_wall(None)` would raise on
|
||||
# `None.is_a(...)` mid-frame and silently break the gizmo group.
|
||||
# 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")
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Forward-compat AST contracts for wall gizmo internals.
|
||||
|
||||
Pins structural invariants that no per-call-site behavioural test can catch
|
||||
on its own: the kind of "someone tidied the imports" regression that leaves
|
||||
tests green but silently changes runtime semantics. Each contract names the
|
||||
invariant it pins so a future revert tells the contributor exactly what the
|
||||
rule is."""
|
||||
|
||||
import ast
|
||||
import inspect
|
||||
import textwrap
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.wall
|
||||
|
||||
|
||||
def test_iter_path_connections_uses_path_connectable_predicate():
|
||||
"""The partner filter must consult the looser ``is_path_connectable_wall``
|
||||
predicate, matching the host-side predicate used by the gizmo group's
|
||||
poll. Strict ``is_wall`` rejects fillet-corner walls (which have no
|
||||
LAYER2 usage by IFC spec), so a regression to ``is_wall`` would silently
|
||||
drop fillet partners from the connection list — visible to the user as
|
||||
"the corner looks unconnected from the adjacent wall's selection.\" """
|
||||
from bonsai.bim.module.model.wall import _iter_path_connections
|
||||
|
||||
source = inspect.getsource(_iter_path_connections)
|
||||
tree = ast.parse(source)
|
||||
attr_names = {node.attr for node in ast.walk(tree) if isinstance(node, ast.Attribute)}
|
||||
|
||||
assert "is_path_connectable_wall" in attr_names, (
|
||||
"_iter_path_connections must filter partners with is_path_connectable_wall — "
|
||||
"the same predicate the gizmo group's poll uses on the host wall. "
|
||||
"Symmetry between host and partner predicates is required for fillet "
|
||||
"corners (no LAYER2 usage) to surface as connected from their LAYER2 "
|
||||
"neighbours' perspective."
|
||||
)
|
||||
assert "is_wall" not in attr_names, (
|
||||
"_iter_path_connections must NOT call .is_wall on partner elements — "
|
||||
"that strict predicate drops fillet-corner walls. Use "
|
||||
"is_path_connectable_wall instead."
|
||||
)
|
||||
|
||||
|
||||
def test_gizmo_wall_link_toggle_invokes_partner_bbox_helper():
|
||||
"""The wall subclass must call draw_wall_partner_bbox when its hover
|
||||
state is active. Without this contract the partner-wall highlight
|
||||
silently regresses if someone "tidies" the draw() override away."""
|
||||
from bonsai.bim.module.model import wall as wall_module
|
||||
|
||||
source = textwrap.dedent(inspect.getsource(wall_module.GizmoWallLinkToggle.draw))
|
||||
tree = ast.parse(source)
|
||||
attr_names = {node.attr for node in ast.walk(tree) if isinstance(node, ast.Attribute)}
|
||||
call_names: set[str] = set()
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
if isinstance(node.func, ast.Attribute):
|
||||
call_names.add(node.func.attr)
|
||||
elif isinstance(node.func, ast.Name):
|
||||
call_names.add(node.func.id)
|
||||
|
||||
assert "is_highlight" in attr_names, (
|
||||
"GizmoWallLinkToggle.draw must gate its highlight call on self.is_highlight — "
|
||||
"without it the partner outline would draw every frame, not just on hover."
|
||||
)
|
||||
assert "draw_wall_partner_bbox" in call_names, (
|
||||
"GizmoWallLinkToggle.draw must call draw_wall_partner_bbox to render the "
|
||||
"partner outline. The shared composite in decorator.py is the canonical "
|
||||
"trigger for this feature; replacing it with an ad-hoc draw call would "
|
||||
"drift from the array-children bbox styling."
|
||||
)
|
||||
|
||||
|
||||
def test_every_wall_gizmo_group_resolves_get_decoration_colors():
|
||||
"""Any wall ``GizmoGroup`` whose ``setup()`` reads decoration colours via
|
||||
``self.get_decoration_colors()`` must inherit from a mixin that supplies
|
||||
it (``gizmo.BaseParametricGizmoGroup`` or ``gizmo.BillboardingGizmoGroupMixin``).
|
||||
Without the mixin the call AttributeErrors inside ``setup()``, Blender
|
||||
logs the failure and skips the rest of ``setup()``, and every later
|
||||
``draw_prepare()`` blows up on whichever attribute the truncated setup
|
||||
failed to assign — a silent, runtime-only regression that no other test
|
||||
catches."""
|
||||
import bpy
|
||||
|
||||
from bonsai.bim.module.model import wall as wall_module
|
||||
|
||||
offenders: list[str] = []
|
||||
for name in dir(wall_module):
|
||||
cls = getattr(wall_module, name)
|
||||
if not inspect.isclass(cls):
|
||||
continue
|
||||
if inspect.getmodule(cls) is not wall_module:
|
||||
continue
|
||||
if not issubclass(cls, bpy.types.GizmoGroup):
|
||||
continue
|
||||
setup = cls.__dict__.get("setup")
|
||||
if setup is None:
|
||||
continue
|
||||
try:
|
||||
src = inspect.getsource(setup)
|
||||
except (OSError, TypeError):
|
||||
continue
|
||||
if "self.get_decoration_colors()" not in src:
|
||||
continue
|
||||
if not hasattr(cls, "get_decoration_colors"):
|
||||
offenders.append(cls.__name__)
|
||||
|
||||
assert not offenders, (
|
||||
f"GizmoGroup subclasses {offenders} call self.get_decoration_colors() in "
|
||||
"setup() but inherit from no class that provides it. Add "
|
||||
"gizmo.BillboardingGizmoGroupMixin (or gizmo.BaseParametricGizmoGroup) to "
|
||||
"the class bases — both define get_decoration_colors and are the canonical "
|
||||
"wall-gizmo mixins."
|
||||
)
|
||||
|
||||
|
||||
def test_host_add_opening_accepts_fillet_corner_active():
|
||||
"""``is_supported_host`` (the gate ``GizmoHostAddOpening.poll`` dispatches
|
||||
through) must classify walls via ``is_path_connectable_wall``, not the
|
||||
strict ``is_wall`` predicate. Fillet-corner walls carry no LAYER2 usage
|
||||
by IFC spec, so the strict predicate rejects them and the add-opening
|
||||
icon never surfaces over a curved corner — symmetry with the join /
|
||||
unjoin / extend wall gizmos (all of which already poll on the looser
|
||||
predicate) is required for the user to drop openings into fillet
|
||||
corners at all."""
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import is_supported_host
|
||||
|
||||
source = textwrap.dedent(inspect.getsource(is_supported_host))
|
||||
tree = ast.parse(source)
|
||||
attr_names = {node.attr for node in ast.walk(tree) if isinstance(node, ast.Attribute)}
|
||||
|
||||
assert "is_path_connectable_wall" in attr_names, (
|
||||
"is_supported_host must gate walls on tool.Parametric.is_path_connectable_wall. "
|
||||
"The strict is_wall predicate hides the add-opening gizmo over every "
|
||||
"fillet-corner wall."
|
||||
)
|
||||
assert "is_wall" not in attr_names, (
|
||||
"is_supported_host must NOT call .is_wall — that strict predicate drops "
|
||||
"fillet-corner walls. Use is_path_connectable_wall instead, matching the "
|
||||
"host gate every other wall-state gizmo group uses."
|
||||
)
|
||||
|
||||
|
||||
def test_join_intersection_uses_l_and_t_glyphs():
|
||||
"""``GizmoWallJoinIntersection.setup`` must bind the join icon to the L
|
||||
glyph (``VIEW3D_GT_wall_corner``) and the extend-to icon to the T glyph
|
||||
(``VIEW3D_GT_wall_tee``). The L / T pair makes the corner-join vs
|
||||
extend-into-side distinction read at a glance — a regression to the
|
||||
arrow-merge glyph for both icons makes them visually indistinguishable
|
||||
once they're stacked at the same XY."""
|
||||
from bonsai.bim.module.model.wall import GizmoWallJoinIntersection
|
||||
|
||||
source = textwrap.dedent(inspect.getsource(GizmoWallJoinIntersection.setup))
|
||||
assert '"VIEW3D_GT_wall_corner"' in source, (
|
||||
"GizmoWallJoinIntersection.setup must bind join_icon to VIEW3D_GT_wall_corner "
|
||||
"(the L glyph). The arrow-merge glyph (VIEW3D_GT_merge) is the collinear-merge "
|
||||
"case and was visually ambiguous with the extend-to icon when both were stacked."
|
||||
)
|
||||
assert '"VIEW3D_GT_wall_tee"' in source, (
|
||||
"GizmoWallJoinIntersection.setup must bind extend_to_wall_icon to "
|
||||
"VIEW3D_GT_wall_tee (the T glyph). The arrow-extend glyph was visually "
|
||||
"ambiguous with the join icon when both were stacked."
|
||||
)
|
||||
|
||||
|
||||
def test_join_intersection_stacks_along_screen_up_in_both_states():
|
||||
"""``GizmoWallJoinIntersection.position_gizmos`` must route both the
|
||||
joined (unjoin + fillet) and the intersecting (join + extend + fillet)
|
||||
states through ``_stack_at`` so the icons stay individually clickable
|
||||
in any view, including top / plan view where world-Z separation
|
||||
collapses to zero on screen. A regression that re-introduces a
|
||||
per-state ``billboarded_at(corner, ...)`` write outside ``_stack_at``
|
||||
silently flattens the stack back onto one screen pixel."""
|
||||
from bonsai.bim.module.model.wall import GizmoWallJoinIntersection
|
||||
|
||||
source = textwrap.dedent(inspect.getsource(GizmoWallJoinIntersection.position_gizmos))
|
||||
tree = ast.parse(source)
|
||||
call_names: set[str] = set()
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
if isinstance(node.func, ast.Attribute):
|
||||
call_names.add(node.func.attr)
|
||||
elif isinstance(node.func, ast.Name):
|
||||
call_names.add(node.func.id)
|
||||
|
||||
assert "_stack_at" in call_names, (
|
||||
"GizmoWallJoinIntersection.position_gizmos must call self._stack_at to "
|
||||
"lay icons along screen-up at the wall-top anchor. Direct "
|
||||
"billboarded_at writes for the join/unjoin/extend/fillet icons bypass "
|
||||
"the stacking contract and re-introduce the top-view collapse bug."
|
||||
)
|
||||
|
||||
|
||||
def _get_wall_axis_callers_in(method) -> set[str]:
|
||||
"""Return the set of attribute chains in ``method``'s source that resolve
|
||||
to ``tool.Model.get_wall_axis``. Empty set means the method does not read
|
||||
from the mesh-bound-box axis source."""
|
||||
source = textwrap.dedent(inspect.getsource(method))
|
||||
tree = ast.parse(source)
|
||||
offenders: set[str] = set()
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
func = node.func
|
||||
if not isinstance(func, ast.Attribute) or func.attr != "get_wall_axis":
|
||||
continue
|
||||
# Reconstruct the receiver chain to surface it in the assertion message.
|
||||
chain: list[str] = [func.attr]
|
||||
receiver = func.value
|
||||
while isinstance(receiver, ast.Attribute):
|
||||
chain.append(receiver.attr)
|
||||
receiver = receiver.value
|
||||
if isinstance(receiver, ast.Name):
|
||||
chain.append(receiver.id)
|
||||
offenders.add(".".join(reversed(chain)))
|
||||
return offenders
|
||||
|
||||
|
||||
def _method_writes_ifc_axis(method) -> bool:
|
||||
"""True iff ``method``'s body calls ``self.set_axis(...)`` — the only
|
||||
path that writes a wall's IFC reference line via
|
||||
``ifcopenshell.api.geometry.assign_representation``. Methods that only
|
||||
read ``axis["base"]`` / ``axis["side"]`` for layer-polygon work (slab
|
||||
clipping, opening snap) never call ``set_axis`` and are not under this
|
||||
rule."""
|
||||
source = textwrap.dedent(inspect.getsource(method))
|
||||
tree = ast.parse(source)
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
func = node.func
|
||||
if isinstance(func, ast.Attribute) and func.attr == "set_axis":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def test_dumb_wall_joiner_axis_writers_read_ifc_reference_line():
|
||||
"""Any ``DumbWallJoiner`` method that writes the IFC reference line
|
||||
(via ``self.set_axis`` → ``ifcopenshell.api.geometry.assign_representation``)
|
||||
must read its input axis from the IFC reference line too — not from
|
||||
``tool.Model.get_wall_axis``, whose X-extent comes from ``obj.bound_box``
|
||||
(the Body mesh AABB). The bound-box axis drifts past or short of the
|
||||
IFC reference line at mitred / butt-jointed walls and at walls with end
|
||||
openings; mixing it on input with the IFC axis on output produces
|
||||
non-colinear sub-axes that compound through chained extend/split/join
|
||||
edits.
|
||||
|
||||
The IFC-anchored helper is ``tool.Wall.get_world_reference_line`` for
|
||||
world-space endpoints, or ``ifcopenshell.util.representation.get_reference_line``
|
||||
for local-SI endpoints.
|
||||
|
||||
Joiner methods that only read layer-polygon base/side (e.g. ``clip``
|
||||
for slab intersection) are exempt — they need the body footprint, not
|
||||
the axis, and never call ``set_axis``."""
|
||||
from bonsai.bim.module.model.wall import DumbWallJoiner
|
||||
|
||||
offenders: dict[str, set[str]] = {}
|
||||
for name, method in inspect.getmembers(DumbWallJoiner, predicate=inspect.isfunction):
|
||||
if not _method_writes_ifc_axis(method):
|
||||
continue
|
||||
bad_calls = _get_wall_axis_callers_in(method)
|
||||
if bad_calls:
|
||||
offenders[name] = bad_calls
|
||||
|
||||
assert not offenders, (
|
||||
f"DumbWallJoiner methods that call self.set_axis must not read the "
|
||||
f"bound-box-derived axis: {offenders}. Use "
|
||||
"tool.Wall.get_world_reference_line for world-space endpoints, or "
|
||||
"ifcopenshell.util.representation.get_reference_line for local-SI "
|
||||
"endpoints. Mixing bound_box on input with IFC axis on output "
|
||||
"produces non-colinear sub-axes that compound through chained "
|
||||
"extend/split/join edits."
|
||||
)
|
||||
|
||||
|
||||
def test_extend_walls_to_polyline_set_origin_uses_ifc_reference_line():
|
||||
"""``ExtendWallsToPolylinePoint.set_origin`` seeds the polyline preview
|
||||
anchor at one of the wall's axis endpoints. The downstream operator
|
||||
(``DumbWallJoiner.extend``) projects the user's chosen target onto the
|
||||
IFC reference line; if the preview anchor comes from
|
||||
``tool.Model.get_wall_axis`` (bound_box) the user sees the preview at
|
||||
one endpoint and the wall lands at a different one — the visible
|
||||
"extend falls short by a few cm/m" symptom."""
|
||||
from bonsai.bim.module.model.wall import ExtendWallsToPolylinePoint
|
||||
|
||||
offenders = _get_wall_axis_callers_in(ExtendWallsToPolylinePoint.set_origin)
|
||||
|
||||
assert not offenders, (
|
||||
f"ExtendWallsToPolylinePoint.set_origin must not read the bound-box-derived "
|
||||
f"axis: {offenders}. Use tool.Wall.get_world_reference_line so the preview "
|
||||
"anchor lands on the same IFC reference line the downstream extend operator "
|
||||
"projects onto."
|
||||
)
|
||||
|
||||
|
||||
def test_wall_toggle_openings_uses_idle_slots():
|
||||
"""The wall's toggle_openings icon must be declared in
|
||||
``GizmoWallEdition.idle_slots`` so the base class lays it out at the
|
||||
standard pen-row position. Routing it through ad-hoc setup helpers
|
||||
instead would re-introduce the X-collision with the array's first
|
||||
per-layer icon — the bug this contract was added to prevent."""
|
||||
from bonsai.bim.module.model.wall import GizmoWallEdition
|
||||
|
||||
slot_names = {s.name for s in GizmoWallEdition.idle_slots}
|
||||
assert "toggle_openings" in slot_names, (
|
||||
"GizmoWallEdition.idle_slots must contain a slot named 'toggle_openings'. "
|
||||
"The base class derives its X position from the slot's tuple index so peer "
|
||||
"groups (GizmoArrayEdition's per-layer icons) can query a real layout edge "
|
||||
"via _idle_row_right_edge() instead of a hardcoded per-feature table."
|
||||
)
|
||||
|
||||
|
||||
def test_no_pen_row_toggle_openings_helpers_remain():
|
||||
"""The legacy ``setup_pen_row_toggle_openings_icon`` and
|
||||
``update_pen_row_toggle_openings_icon`` helpers were removed once
|
||||
toggle_openings migrated into the ``idle_slots`` system. A re-introduced
|
||||
helper would shadow the slot-driven layout — features calling it would
|
||||
set up a second gizmo at a different X and the collision-prevention
|
||||
contract would silently regress.
|
||||
|
||||
Walks the wall and roof modules (the historical callers) plus
|
||||
drawing/gizmos.py (the historical home) for any reference to either
|
||||
name."""
|
||||
import bonsai.bim.module.drawing.gizmos as gizmos_mod
|
||||
import bonsai.bim.module.model.roof as roof_mod
|
||||
import bonsai.bim.module.model.wall as wall_mod
|
||||
|
||||
forbidden = ("setup_pen_row_toggle_openings_icon", "update_pen_row_toggle_openings_icon")
|
||||
for mod in (gizmos_mod, roof_mod, wall_mod):
|
||||
source = inspect.getsource(mod)
|
||||
for name in forbidden:
|
||||
assert name not in source, (
|
||||
f"{mod.__name__} still references {name!r}. The toggle_openings icon "
|
||||
f"is now declared via idle_slots; the ad-hoc helpers were removed to "
|
||||
f"prevent layout drift between feature groups."
|
||||
)
|
||||
|
||||
|
||||
def test_array_idle_max_x_walks_registry_not_hardcoded_dict():
|
||||
"""``GizmoArrayEdition._resolve_feature_idle_max_x`` must query peer
|
||||
parametric gizmo groups' ``_idle_row_right_edge`` rather than indexing
|
||||
a hardcoded per-feature ``_FEATURE_IDLE_MAX_X`` dict. The dict approach
|
||||
was the source of the toggle_openings ↔ array-layer-icon collision bug
|
||||
on arrayed walls (find_for_element returns 'array' first, shadowing the
|
||||
wall reservation)."""
|
||||
from bonsai.bim.module.model.array import GizmoArrayEdition
|
||||
|
||||
assert not hasattr(GizmoArrayEdition, "_FEATURE_IDLE_MAX_X"), (
|
||||
"GizmoArrayEdition._FEATURE_IDLE_MAX_X was a hardcoded per-feature dict "
|
||||
"that shadowed peer groups' real idle rows for compound elements (arrayed "
|
||||
"walls). It was replaced by a registry walk via REGISTRY + "
|
||||
"_idle_row_right_edge() — re-introducing the dict would re-create the bug."
|
||||
)
|
||||
|
||||
source = inspect.getsource(GizmoArrayEdition._resolve_feature_idle_max_x)
|
||||
assert "_idle_row_right_edge" in source, (
|
||||
"_resolve_feature_idle_max_x must call peer_cls._idle_row_right_edge() so "
|
||||
"the X position derives from each peer's actual declared idle_slots."
|
||||
)
|
||||
assert "REGISTRY" in source, (
|
||||
"_resolve_feature_idle_max_x must iterate BaseParametricGizmoGroup.REGISTRY "
|
||||
"to discover peer groups; find_for_element returns ONE entry and shadows "
|
||||
"compound-element memberships."
|
||||
)
|
||||
@@ -18,21 +18,20 @@
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Regression tests for the post-IFC-commit refresh path that re-syncs the
|
||||
workspace tool header (``BIMModelProperties``) and invalidates the per-wall
|
||||
gizmo geometry cache.
|
||||
"""Regression tests for the post-IFC-commit refresh path.
|
||||
|
||||
Bug repro before the fix: hotkey operators that edited the active wall in
|
||||
place (``bpy.ops.bim.hotkey(hotkey="S_E")`` / ``"C_E"``) mutated IFC but never
|
||||
fired ``active_object_callback`` (no selection change), so the header H/L/A
|
||||
fields and the gizmo cache both kept showing stale values. ``refresh_ui_data``
|
||||
ran, but it never resynced ``BIMModelProperties`` and never invalidated the
|
||||
per-gizmo-group geometry cache. The fix wires both refreshes through
|
||||
``tool.Parametric.refresh_post_commit`` and calls it from every
|
||||
``tool.Ifc.Operator`` epilogue."""
|
||||
Two invariants:
|
||||
|
||||
* Every commit bumps ``_geom_generation`` so caches keyed off it drop
|
||||
stale entries on the next read.
|
||||
* The BIM Tool header float refresh (``refresh_bim_tool_headers``) fires
|
||||
only for commits whose operator is a parametric ``finish_op`` from
|
||||
``tool.Parametric.EDIT_TYPES`` — the validate-gizmo path. Other
|
||||
operators skip it; their commit context may lack the view-layer
|
||||
attributes the refresh reads."""
|
||||
|
||||
import types
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
@@ -46,17 +45,42 @@ def _require_real_bpy():
|
||||
pytest.skip("requires real Blender (bpy is mocked or absent)")
|
||||
|
||||
|
||||
def test_refresh_post_commit_bumps_generation_and_resyncs_header():
|
||||
"""``refresh_post_commit`` must bump the generation counter and call
|
||||
``update_bim_tool_props`` so the workspace tool header re-syncs from IFC."""
|
||||
import bonsai.bim.handler as handler
|
||||
def test_refresh_post_commit_bumps_generation_for_every_operator():
|
||||
"""The generation counter advances on every commit, regardless of
|
||||
operator class — it's the cache-invalidation signal for any code
|
||||
keyed off ``tool.Parametric.get_geom_generation()``."""
|
||||
from bonsai import tool
|
||||
|
||||
before = tool.Parametric.get_geom_generation()
|
||||
with patch.object(handler, "update_bim_tool_props") as mock_resync:
|
||||
tool.Parametric.refresh_post_commit()
|
||||
tool.Parametric.refresh_post_commit(MagicMock(bl_idname="bim.append_library_element"))
|
||||
assert tool.Parametric.get_geom_generation() == before + 1
|
||||
mock_resync.assert_called_once()
|
||||
|
||||
|
||||
def test_refresh_post_commit_refreshes_headers_for_validate_gizmo_operators():
|
||||
"""Operators whose ``bl_idname`` matches a ``ParametricObject.finish_op``
|
||||
in ``EDIT_TYPES`` are the validate-gizmo path: selection didn't
|
||||
change, but the IFC values backing the BIM Tool header did. The
|
||||
commit hook must push the new IFC state into the header floats."""
|
||||
import bonsai.bim.handler as handler
|
||||
from bonsai import tool
|
||||
|
||||
finish_op_idname = tool.Parametric.EDIT_TYPES[0].finish_op
|
||||
with patch.object(handler, "refresh_bim_tool_headers") as mock_refresh:
|
||||
tool.Parametric.refresh_post_commit(MagicMock(bl_idname=finish_op_idname))
|
||||
mock_refresh.assert_called_once()
|
||||
|
||||
|
||||
def test_refresh_post_commit_skips_header_refresh_for_non_finish_operators():
|
||||
"""Other operators must not trigger the header refresh. The refresh
|
||||
reads ``bpy.context``; for commits invoked from a stripped operator
|
||||
context (e.g. nested ``bpy.ops`` calls during project setup) this
|
||||
would raise ``AttributeError`` and break the outer operator chain."""
|
||||
import bonsai.bim.handler as handler
|
||||
from bonsai import tool
|
||||
|
||||
with patch.object(handler, "refresh_bim_tool_headers") as mock_refresh:
|
||||
tool.Parametric.refresh_post_commit(MagicMock(bl_idname="bim.append_library_element"))
|
||||
mock_refresh.assert_not_called()
|
||||
|
||||
|
||||
def test_geom_generation_invalidates_wall_geom_cache():
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Offset arithmetic for the filling (Door / Window) wall-offset dimension gizmos.
|
||||
|
||||
The apply path (``_set_offset``) must translate ``obj.matrix_world`` so the
|
||||
corresponding read path (``_get_offset``) reads back the new value — i.e.
|
||||
drag a left-offset to 2.0 m, then reading the left offset must return ~2.0 m.
|
||||
The tests below pin that round-trip, the rotated/flipped filling case (the
|
||||
add-opening flow may 180° a filling onto the wall's opposite face), and the
|
||||
visibility predicate."""
|
||||
|
||||
from math import pi
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from mathutils import Matrix
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
# Module under test, plus its cache dict so each test starts from a clean slate.
|
||||
import bonsai.bim.module.model.wall_offset_gizmos as subject
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_geom_cache():
|
||||
"""Per-test isolation — the module-level cache would otherwise carry mocks
|
||||
across tests and surface as flaky-looking failures."""
|
||||
subject._GEOM_CACHE.clear()
|
||||
yield
|
||||
subject._GEOM_CACHE.clear()
|
||||
|
||||
|
||||
def _make_props(filling_matrix, overall_width=1.0, overall_height=2.0, name="FillingObj"):
|
||||
"""Filling PropertyGroup stand-in (``BIMDoorProperties`` /
|
||||
``BIMWindowProperties`` share the relevant shape). The wall-offset helpers
|
||||
only touch ``id_data`` (the filling obj), ``overall_width``, and
|
||||
``overall_height``; nothing else from the real PropertyGroup matters here."""
|
||||
filling_obj = mock.Mock(name=name)
|
||||
filling_obj.name = name
|
||||
filling_obj.matrix_world = filling_matrix
|
||||
props = mock.Mock(spec=["id_data", "overall_width", "overall_height"])
|
||||
props.id_data = filling_obj
|
||||
props.overall_width = overall_width
|
||||
props.overall_height = overall_height
|
||||
return props, filling_obj
|
||||
|
||||
|
||||
def _patch_host_wall(wall_matrix, length, height, geom_gen=1, host_present=True, x_angle=0.0):
|
||||
"""Mock the chain ``Ifc.get_entity → Spatial.get_host_wall → Ifc.get_object``
|
||||
and the ``tool.Wall.*`` IFC reads so the helpers see a host wall
|
||||
positioned at ``wall_matrix`` with the supplied length, height, and
|
||||
extrusion angle. The IFC axis is taken to start at wall-local X=0 and
|
||||
extend to X=``length``. ``host_present=False`` makes the chain return
|
||||
None partway through."""
|
||||
wall_obj = mock.Mock(name="WallObj")
|
||||
wall_obj.matrix_world = wall_matrix
|
||||
host_wall = mock.Mock(name="IfcWall") if host_present else None
|
||||
return mock.patch.multiple(
|
||||
subject.tool,
|
||||
Ifc=mock.MagicMock(
|
||||
spec=subject.tool.Ifc,
|
||||
get_entity=mock.Mock(return_value=mock.Mock(name="IfcDoor")),
|
||||
get_object=mock.Mock(return_value=wall_obj if host_present else None),
|
||||
),
|
||||
Spatial=mock.MagicMock(
|
||||
spec=subject.tool.Spatial,
|
||||
get_host_wall=mock.Mock(return_value=host_wall),
|
||||
),
|
||||
Wall=mock.MagicMock(
|
||||
spec=subject.tool.Wall,
|
||||
get_length_and_height=mock.Mock(return_value=(length, height) if host_present else None),
|
||||
get_axis_local_extent=mock.Mock(return_value=(0.0, length) if host_present else None),
|
||||
get_x_angle=mock.Mock(return_value=x_angle if host_present else None),
|
||||
),
|
||||
Parametric=mock.MagicMock(
|
||||
spec=subject.tool.Parametric,
|
||||
get_geom_generation=mock.Mock(return_value=geom_gen),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Visibility predicate
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_has_host_wall_returns_true_when_chain_resolves():
|
||||
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
assert subject.has_host_wall(props) is True
|
||||
|
||||
|
||||
def test_has_host_wall_returns_false_when_no_host():
|
||||
props, _ = _make_props(Matrix.Identity(4))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0, host_present=False):
|
||||
assert subject.has_host_wall(props) is False
|
||||
|
||||
|
||||
def test_has_host_wall_returns_true_for_slanted_wall():
|
||||
"""Slanted LAYER2 walls keep ``matrix_world`` upright — the slope lives in
|
||||
the IFC extrusion direction and in the wall mesh vertices, not in the
|
||||
object transform. So wall-local Z still equals world Z, the offset math
|
||||
round-trips, and the gizmos must remain visible."""
|
||||
import math
|
||||
|
||||
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0, x_angle=math.radians(15)):
|
||||
assert subject.has_host_wall(props) is True
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Compute helpers (un-flipped filling, wall at world origin)
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_wall_offset_left_for_filling_at_known_x():
|
||||
"""Wall span 0→5 on X; filling origin at wall-local X=1.5 with +X aligned.
|
||||
Filling's left edge is at wall-X 1.5 → offset_left = 1.5."""
|
||||
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
assert subject._get_offset(props, subject._LEFT) == pytest.approx(1.5)
|
||||
|
||||
|
||||
def test_get_wall_offset_right_complements_left_plus_width():
|
||||
"""offset_left + overall_width + offset_right == wall length."""
|
||||
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
left = subject._get_offset(props, subject._LEFT)
|
||||
right = subject._get_offset(props, subject._RIGHT)
|
||||
assert left + props.overall_width + right == pytest.approx(5.0)
|
||||
|
||||
|
||||
def test_get_wall_offset_bottom_for_filling_at_sill_height():
|
||||
"""Wall base at world Z=0; filling origin at wall-local Z=0.5 → sill at 0.5 m."""
|
||||
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
assert subject._get_offset(props, subject._BOTTOM) == pytest.approx(0.5)
|
||||
|
||||
|
||||
def test_get_wall_offset_top_complements_bottom_plus_height():
|
||||
"""offset_bottom + overall_height + offset_top == wall height."""
|
||||
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
bottom = subject._get_offset(props, subject._BOTTOM)
|
||||
top = subject._get_offset(props, subject._TOP)
|
||||
assert bottom + props.overall_height + top == pytest.approx(3.0)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Slanted wall (LAYER2): wall ``matrix_world`` stays upright, so the
|
||||
# offset math is the same as for a vertical wall. Pinning this guards
|
||||
# against the visibility gate being re-added or the math diverging.
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_wall_offset_bottom_for_slanted_wall():
|
||||
"""For a LAYER2 slanted wall the wall matrix is identity rotation — sill
|
||||
height read in the wall's local Z is still the world Z above the wall
|
||||
base."""
|
||||
import math
|
||||
|
||||
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.9)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0, x_angle=math.radians(15)):
|
||||
assert subject._get_offset(props, subject._BOTTOM) == pytest.approx(0.9)
|
||||
|
||||
|
||||
def test_set_wall_offset_bottom_round_trips_for_slanted_wall():
|
||||
"""Round-trip on a slanted wall: setting then reading the bottom offset
|
||||
yields the input. The apply path translates along the wall's local Z
|
||||
direction in world space (``matrix_world.to_3x3().col[2]``), which equals
|
||||
world Z for an upright wall matrix regardless of IFC slope."""
|
||||
import math
|
||||
|
||||
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0, x_angle=math.radians(15)):
|
||||
subject._set_offset(props, subject._BOTTOM, 1.2)
|
||||
assert subject._get_offset(props, subject._BOTTOM) == pytest.approx(1.2)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Flipped filling (180° around Z) — add-opening flow flips a filling that
|
||||
# lands on the wall's opposite face. Offset arithmetic must still report
|
||||
# the leftmost/rightmost edges in wall coordinates, not in filling coordinates.
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_wall_offset_left_handles_flipped_filling():
|
||||
"""Flipped filling at wall-local X=1.5: filling extends from X=1.5 in filling's +X
|
||||
direction, which is wall's -X. So filling's leftmost edge in wall coords is
|
||||
at wall-X 0.5, not wall-X 1.5."""
|
||||
filling_matrix = Matrix.Translation((1.5, 0.0, 0.5)) @ Matrix.Rotation(pi, 4, "Z")
|
||||
props, _ = _make_props(filling_matrix, overall_width=1.0)
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
assert subject._get_offset(props, subject._LEFT) == pytest.approx(0.5)
|
||||
|
||||
|
||||
def test_get_wall_offset_right_handles_flipped_filling():
|
||||
"""Same flipped filling — filling's rightmost edge in wall coords is at the
|
||||
filling origin (wall-X 1.5), so offset_right = wall_length - 1.5 = 3.5."""
|
||||
filling_matrix = Matrix.Translation((1.5, 0.0, 0.5)) @ Matrix.Rotation(pi, 4, "Z")
|
||||
props, _ = _make_props(filling_matrix, overall_width=1.0)
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
assert subject._get_offset(props, subject._RIGHT) == pytest.approx(3.5)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Apply helpers — must round-trip with the compute helpers.
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_set_wall_offset_left_translates_filling_along_wall_x():
|
||||
"""Setting left-offset to 2.0 (from 1.5) shifts the filling origin by +0.5
|
||||
along the wall's local X axis."""
|
||||
props, filling_obj = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
subject._set_offset(props, subject._LEFT, 2.0)
|
||||
assert filling_obj.matrix_world.translation.x == pytest.approx(2.0)
|
||||
assert filling_obj.matrix_world.translation.z == pytest.approx(0.5)
|
||||
|
||||
|
||||
def test_set_wall_offset_bottom_translates_filling_along_wall_z():
|
||||
"""Setting bottom-offset to 1.0 (from 0.5) shifts the filling origin by +0.5
|
||||
along the wall's local Z axis."""
|
||||
props, filling_obj = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
subject._set_offset(props, subject._BOTTOM, 1.0)
|
||||
assert filling_obj.matrix_world.translation.z == pytest.approx(1.0)
|
||||
assert filling_obj.matrix_world.translation.x == pytest.approx(1.5)
|
||||
|
||||
|
||||
def test_set_wall_offset_right_round_trips_with_get():
|
||||
"""The right-edge setter is the symmetric pair of the left-edge setter —
|
||||
they must produce mutually consistent geometry, otherwise pulling the
|
||||
right edge would silently desync the left."""
|
||||
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
subject._set_offset(props, subject._RIGHT, 1.0)
|
||||
result = subject._get_offset(props, subject._RIGHT)
|
||||
assert result == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_set_wall_offset_top_round_trips_with_get():
|
||||
"""Same round-trip invariant for the top edge."""
|
||||
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
subject._set_offset(props, subject._TOP, 0.25)
|
||||
result = subject._get_offset(props, subject._TOP)
|
||||
assert result == pytest.approx(0.25)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Cache invalidation
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_geom_cache_invalidates_when_generation_bumps():
|
||||
"""The cache must reset when ``tool.Parametric.get_geom_generation()``
|
||||
advances so IFC mutations don't leave stale host-wall reads in memory."""
|
||||
props, _ = _make_props(Matrix.Translation((1.0, 0.0, 0.0)))
|
||||
# Patch get_geom_generation on the real class — the cache binds to the class
|
||||
# at definition time, so a mock.patch.multiple on subject.tool.Parametric
|
||||
# would be invisible to it.
|
||||
from bonsai.tool.parametric import Parametric
|
||||
|
||||
with mock.patch.object(Parametric, "get_geom_generation", return_value=1):
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
first = subject._get_offset(props, subject._LEFT)
|
||||
with mock.patch.object(Parametric, "get_geom_generation", return_value=2):
|
||||
with _patch_host_wall(Matrix.Identity(4), length=8.0, height=3.0):
|
||||
right_after_bump = subject._get_offset(props, subject._RIGHT)
|
||||
assert first == pytest.approx(1.0)
|
||||
# 8 m wall, filling at x=1, width 1 → right offset = 6. Reads 6 only if the
|
||||
# cache dropped on the generation bump.
|
||||
assert right_after_bump == pytest.approx(6.0)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Signed value the gizmo reports for left/right (sign-flip on filling's 180° Z rotation)
|
||||
#
|
||||
# Without the sign flip the dim arrow renders in the wrong world direction
|
||||
# for a filling whose local +X is opposite the wall's local +X. The gizmo
|
||||
# system flips its rendered dim arrow 180° around Z whenever the reported
|
||||
# value is negative, so the unflipped/flipped cases produce mirrored signs
|
||||
# and the arrow ends up pointing the right way visually in both orientations.
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_left_signed_value_positive_for_unflipped_filling():
|
||||
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
assert subject._compute_value(props, subject._LEFT) == pytest.approx(1.5)
|
||||
|
||||
|
||||
def test_left_signed_value_negative_for_flipped_filling():
|
||||
"""Filling rotated 180° around Z: its leftmost edge (in wall coords) sits
|
||||
at wall-X 0.5, so the user-facing left offset is 0.5 — but the signed
|
||||
value must be -0.5 so the gizmo flips its rendered arrow 180° around Z."""
|
||||
from math import pi
|
||||
|
||||
filling = Matrix.Translation((1.5, 0.0, 0.5)) @ Matrix.Rotation(pi, 4, "Z")
|
||||
props, _ = _make_props(filling, overall_width=1.0)
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
assert subject._compute_value(props, subject._LEFT) == pytest.approx(-0.5)
|
||||
|
||||
|
||||
def test_right_signed_value_positive_for_unflipped_filling():
|
||||
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
assert subject._compute_value(props, subject._RIGHT) == pytest.approx(2.5)
|
||||
|
||||
|
||||
def test_right_signed_value_negative_for_flipped_filling():
|
||||
from math import pi
|
||||
|
||||
filling = Matrix.Translation((1.5, 0.0, 0.5)) @ Matrix.Rotation(pi, 4, "Z")
|
||||
props, _ = _make_props(filling, overall_width=1.0)
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
assert subject._compute_value(props, subject._RIGHT) == pytest.approx(-3.5)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Matrix-position anchors at the wall edge (visual: arrow tail at wall,
|
||||
# head at filling). The position is in filling-local frame so that mw @ pos
|
||||
# lands at the wall edge in world.
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_left_offset_position_lands_at_wall_start_in_world():
|
||||
"""For an unflipped filling at wall-local (1.5, 0, 0.5) with the wall at the
|
||||
world origin (bound_box.min_x=0), the matrix_position transformed by the
|
||||
filling's world matrix must land at world (0, 0, mid_height)."""
|
||||
props, filling_obj = _make_props(Matrix.Translation((1.5, 0.0, 0.5)), overall_height=2.0)
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
pos_filling_local = subject._edge_position(props, subject._LEFT)
|
||||
pos_world = filling_obj.matrix_world @ pos_filling_local
|
||||
# Wall's start at world X=0 (bound_box.min_x=0 in the patched wall).
|
||||
assert pos_world.x == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_top_offset_position_lands_at_wall_top_in_world():
|
||||
"""The top arrow anchors at wall-top — filling-local Z must equal
|
||||
``overall_height + top_offset`` so the mw-multiplied point lands on the
|
||||
wall's top edge at world height."""
|
||||
props, filling_obj = _make_props(Matrix.Translation((1.5, 0.0, 0.5)), overall_height=2.0)
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
pos_filling_local = subject._edge_position(props, subject._TOP)
|
||||
pos_world = filling_obj.matrix_world @ pos_filling_local
|
||||
# Wall height 3.0, wall base at world Z=0 → wall top at world Z=3.
|
||||
assert pos_world.z == pytest.approx(3.0)
|
||||
|
||||
|
||||
def test_bottom_offset_position_lands_at_wall_base_in_world():
|
||||
"""Same idea for the bottom anchor — filling-local Z = ``-bottom_offset``
|
||||
so the point lands at world Z=0 (the wall's base)."""
|
||||
props, filling_obj = _make_props(Matrix.Translation((1.5, 0.0, 0.5)), overall_height=2.0)
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
pos_filling_local = subject._edge_position(props, subject._BOTTOM)
|
||||
pos_world = filling_obj.matrix_world @ pos_filling_local
|
||||
assert pos_world.z == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_apply_value_takes_absolute_value():
|
||||
"""Apply lambdas use ``abs(v)`` so the apply path stays correct even when
|
||||
the compute side returned a negative signed value (flipped filling)."""
|
||||
props, filling_obj = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
# Simulate the gizmo handing back a negative value (flipped-filling scenario).
|
||||
# The user-facing offset is +2.0, the filling must end up at wall-X=2.0.
|
||||
left_cfg = next(c for c in subject.WALL_OFFSET_GIZMO_CONFIGS if c.attr_name == "host_wall_offset_left")
|
||||
left_cfg.apply_value(props, -2.0)
|
||||
assert filling_obj.matrix_world.translation.x == pytest.approx(2.0)
|
||||
|
||||
|
||||
def test_clear_caches_drops_all_entries():
|
||||
"""The ``load_post`` handler calls ``clear_caches`` so a fresh file
|
||||
doesn't inherit stale entries from the previous one. Pin the contract."""
|
||||
props, _ = _make_props(Matrix.Translation((1.0, 0.0, 0.0)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
subject._get_offset(props, subject._LEFT)
|
||||
assert subject._GEOM_CACHE._data
|
||||
subject.clear_caches()
|
||||
assert not subject._GEOM_CACHE._data
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Stale-cache edge cases — cache keys are Blender object names, invalidated
|
||||
# only by parametric generation bumps and ``load_post``. Anything that
|
||||
# changes scene state without bumping the generation (Blender rename,
|
||||
# external Python script deleting a wall) leaves the cache holding stale
|
||||
# entries until the next IFC mutation. These tests pin that behavior.
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_filling_rename_within_session_reads_correctly_under_new_name():
|
||||
"""Reading offsets after a Blender rename hits a cache miss under the
|
||||
new name and recomputes — the old-name entry is leaked but harmless,
|
||||
and the new-name read returns correct geometry."""
|
||||
props, filling_obj = _make_props(Matrix.Translation((1.5, 0.0, 0.5)), name="Door1")
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
first = subject._get_offset(props, subject._LEFT)
|
||||
assert "Door1" in subject._GEOM_CACHE._data
|
||||
filling_obj.name = "Door1_renamed"
|
||||
second = subject._get_offset(props, subject._LEFT)
|
||||
assert first == pytest.approx(1.5)
|
||||
assert second == pytest.approx(1.5)
|
||||
assert "Door1_renamed" in subject._GEOM_CACHE._data
|
||||
|
||||
|
||||
def test_host_wall_deletion_serves_stale_cache_until_invalidation():
|
||||
"""If the host wall is removed without bumping the generation counter
|
||||
(e.g. external script), the cache keeps returning the pre-deletion
|
||||
geometry — only an IFC mutation or ``clear_caches()`` drops the stale
|
||||
entry."""
|
||||
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
assert subject.has_host_wall(props) is True
|
||||
# Even after the patch exits and the chain would now return None, the
|
||||
# cached entry under the filling's name is still served.
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0, host_present=False):
|
||||
assert subject.has_host_wall(props) is True # stale
|
||||
subject.clear_caches()
|
||||
assert subject.has_host_wall(props) is False # recomputed
|
||||
|
||||
|
||||
def test_filling_rotated_90_degrees_in_wall_plane_falls_back_to_positive_sign():
|
||||
"""A filling rotated exactly 90° around Z has ``col[0].x == 0.0``, which
|
||||
is the ambiguous boundary for the X-sign. The implementation falls back
|
||||
to +1 (the ``>= 0.0`` branch), so the renderer-side value reads as
|
||||
positive — same sign as an unflipped filling."""
|
||||
filling_matrix = Matrix.Translation((1.5, 0.0, 0.5)) @ Matrix.Rotation(pi / 2, 4, "Z")
|
||||
props, _ = _make_props(filling_matrix, overall_width=1.0)
|
||||
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
|
||||
signed = subject._compute_value(props, subject._LEFT)
|
||||
# +1 fallback × unflipped-equivalent left offset = +1.5 (filling origin in wall coords).
|
||||
assert signed == pytest.approx(1.5)
|
||||
@@ -0,0 +1,79 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Pins the outward-normals invariant of the parametric-wall draft preview mesh.
|
||||
|
||||
``regenerate_wall_mesh_from_props`` rebuilds ``obj.data`` as a fresh bmesh
|
||||
box from ``BIMWallProperties`` every time a gizmo handle moves. The hand
|
||||
authored face windings carry no guarantee of outward orientation, so the
|
||||
function must normalise face windings before writing the mesh back —
|
||||
otherwise the viewport renders the draft with inverted shading and
|
||||
back-face culling hides faces the user expects to see."""
|
||||
|
||||
import types
|
||||
from unittest.mock import patch
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
from mathutils import Vector
|
||||
|
||||
pytestmark = pytest.mark.wall
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _require_real_bpy():
|
||||
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
|
||||
pytest.skip("requires real Blender (bpy is mocked or absent)")
|
||||
|
||||
|
||||
def test_regenerate_wall_mesh_from_props_outward_normals():
|
||||
"""Every face of the preview box must have its normal pointing away
|
||||
from the box centroid — the contract every other preview-mesh builder
|
||||
in ``bim/module/model`` (door / window / roof / railing) holds."""
|
||||
from bonsai.bim.module.model.wall import regenerate_wall_mesh_from_props
|
||||
|
||||
mesh = bpy.data.meshes.new("preview_mesh")
|
||||
obj = bpy.data.objects.new("preview_wall", mesh)
|
||||
fake_props = types.SimpleNamespace(
|
||||
length=2.0,
|
||||
height=3.0,
|
||||
thickness=0.2,
|
||||
offset=0.0,
|
||||
x_angle=0.0,
|
||||
anchor_x=0.0,
|
||||
mesh_dirty=False,
|
||||
)
|
||||
|
||||
try:
|
||||
with patch("bonsai.tool.Model.get_wall_props", return_value=fake_props):
|
||||
regenerate_wall_mesh_from_props(obj)
|
||||
|
||||
assert len(mesh.polygons) == 6, f"expected 6 faces, got {len(mesh.polygons)}"
|
||||
centroid = sum((v.co for v in mesh.vertices), Vector()) / len(mesh.vertices)
|
||||
for face in mesh.polygons:
|
||||
outward = (face.center - centroid).normalized()
|
||||
dot = face.normal.dot(outward)
|
||||
assert dot > 0.5, (
|
||||
f"face {face.index} normal {tuple(face.normal)} points inward "
|
||||
f"(outward direction {tuple(outward)}, dot={dot:.3f})"
|
||||
)
|
||||
finally:
|
||||
bpy.data.objects.remove(obj)
|
||||
bpy.data.meshes.remove(mesh)
|
||||
@@ -1764,7 +1764,17 @@ def i_load_the_ifc_test_file(filepath):
|
||||
@given("I load the demo construction library")
|
||||
@when("I load the demo construction library")
|
||||
def i_add_a_construction_library():
|
||||
lib_path = "./bonsai/bim/data/libraries/IFC4 Demo Library.ifc"
|
||||
# Pick the library file whose schema matches the current project so the
|
||||
# appended types are valid (IFC2X3-vs-IFC4 entity attributes differ).
|
||||
schema_to_library = {
|
||||
"IFC2X3": "IFC2X3 Demo Library.ifc",
|
||||
"IFC4": "IFC4 Demo Library.ifc",
|
||||
"IFC4X3": "IFC4X3 Demo Library.ifc",
|
||||
"IFC4X3_ADD2": "IFC4X3 Demo Library.ifc",
|
||||
}
|
||||
schema = tool.Ifc.get().schema
|
||||
lib_name = schema_to_library.get(schema, "IFC4 Demo Library.ifc")
|
||||
lib_path = f"./bonsai/bim/data/libraries/{lib_name}"
|
||||
bpy.ops.bim.select_library_file(filepath=lib_path, append_all=True)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Forward-compat AST contracts for the BIM Tool refresh path.
|
||||
|
||||
Pins structural invariants that no behavioural test can catch on its own:
|
||||
the commit-driven header refresh fires only for the parametric validate-
|
||||
gizmo operators (``bim.finish_editing_<name>``), never universally — and
|
||||
the header writer never drifts into user-intent enum writes."""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
HANDLER_PATH = Path(__file__).parent.parent.parent / "bonsai" / "bim" / "handler.py"
|
||||
PARAMETRIC_PATH = HANDLER_PATH.parent.parent / "tool" / "parametric.py"
|
||||
MODEL_MODULE_DIR = HANDLER_PATH.parent / "module" / "model"
|
||||
|
||||
# User-intent enums encode the user's "what to build next" choice on the
|
||||
# BIM Tool panel. The header-only writer must never drift into enum writes;
|
||||
# user-intent enums are owned by the selection-change path.
|
||||
USER_INTENT_ENUM_ATTRS = frozenset({"ifc_class", "relating_type_id"})
|
||||
|
||||
|
||||
def _function_node(tree: ast.Module, name: str) -> ast.FunctionDef:
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.FunctionDef) and node.name == name:
|
||||
return node
|
||||
raise AssertionError(f"{name!r} not found in {HANDLER_PATH.name}")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def handler_tree() -> ast.Module:
|
||||
return ast.parse(HANDLER_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_read_headers_into_props_writes_only_header_floats(handler_tree: ast.Module) -> None:
|
||||
"""``_read_headers_into_props`` is the header-only writer called from
|
||||
the selection-driven refresh. It must not assign to user-intent enum
|
||||
slots (``ifc_class``, ``relating_type_id``); those are the
|
||||
'what to build next' choice and have their own targeted writes
|
||||
earlier in ``update_bim_tool_props``."""
|
||||
fn = _function_node(handler_tree, "_read_headers_into_props")
|
||||
offenders = []
|
||||
for node in ast.walk(fn):
|
||||
if not isinstance(node, ast.Assign):
|
||||
continue
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Attribute) and target.attr in USER_INTENT_ENUM_ATTRS:
|
||||
offenders.append((target.attr, node.lineno))
|
||||
if offenders:
|
||||
msgs = ", ".join(f"{attr} at line {line}" for attr, line in offenders)
|
||||
pytest.fail(
|
||||
f"_read_headers_into_props assigns to user-intent enum slot(s): {msgs}. "
|
||||
f"Header refresh must not re-target the user's BIM Tool panel selection."
|
||||
)
|
||||
|
||||
|
||||
def test_refresh_post_commit_gates_header_refresh_on_edit_types_registry() -> None:
|
||||
"""``tool.Parametric.refresh_post_commit`` fires for every IFC
|
||||
operator commit. Only operators whose ``bl_idname`` matches a
|
||||
``ParametricObject.finish_op`` in ``EDIT_TYPES`` (the validate-
|
||||
gizmo path) must trigger a BIM Tool header refresh — selection
|
||||
didn't change but the header values did. Other operators must
|
||||
skip the refresh: they don't target an active-object header edit,
|
||||
and their commit context may lack the view-layer attributes the
|
||||
refresh reads.
|
||||
|
||||
The gate must consult the registry, not match a string prefix —
|
||||
``EDIT_TYPES`` is the canonical list of parametric features, and
|
||||
querying it stays correct even if ``ParametricObject.finish_op``
|
||||
changes its derivation rule."""
|
||||
parametric_tree = ast.parse(PARAMETRIC_PATH.read_text(encoding="utf-8"))
|
||||
fn = _function_node(parametric_tree, "refresh_post_commit")
|
||||
found_gated_call = False
|
||||
for node in ast.walk(fn):
|
||||
if not isinstance(node, ast.If):
|
||||
continue
|
||||
references_registry = any(
|
||||
isinstance(sub, ast.Attribute) and sub.attr == "EDIT_TYPES" for sub in ast.walk(node.test)
|
||||
)
|
||||
if not references_registry:
|
||||
continue
|
||||
for body_node in ast.walk(node):
|
||||
if (
|
||||
isinstance(body_node, ast.Call)
|
||||
and isinstance(body_node.func, ast.Attribute)
|
||||
and body_node.func.attr == "refresh_bim_tool_headers"
|
||||
):
|
||||
found_gated_call = True
|
||||
break
|
||||
if found_gated_call:
|
||||
break
|
||||
assert found_gated_call, (
|
||||
"tool.Parametric.refresh_post_commit must gate refresh_bim_tool_headers on an "
|
||||
"If whose test references EDIT_TYPES (the parametric registry). An ungated call "
|
||||
"fires the refresh for commits in contexts that strip view-layer attributes; "
|
||||
"a missing call silently drops the validate-gizmo header refresh."
|
||||
)
|
||||
|
||||
|
||||
def _modules_with_module_scope_cache_and_clear():
|
||||
"""Yield ``module_name`` for every ``bim/module/model/*.py`` source that
|
||||
declares a module-scope ``GenerationKeyedCache()`` assignment AND a
|
||||
top-level ``def clear_caches``. These are the modules whose cache state
|
||||
survives file loads and must be drained from ``_apply_save_file_invariants``."""
|
||||
for path in MODEL_MODULE_DIR.glob("*.py"):
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
has_cache = False
|
||||
has_clear = False
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.FunctionDef) and node.name == "clear_caches":
|
||||
has_clear = True
|
||||
continue
|
||||
if isinstance(node, ast.Assign):
|
||||
for sub in ast.walk(node.value):
|
||||
if (
|
||||
isinstance(sub, ast.Call)
|
||||
and isinstance(sub.func, ast.Attribute)
|
||||
and sub.func.attr == "GenerationKeyedCache"
|
||||
):
|
||||
has_cache = True
|
||||
break
|
||||
if has_cache and has_clear:
|
||||
yield path.stem
|
||||
|
||||
|
||||
def test_on_load_post_drains_every_module_scope_geom_cache() -> None:
|
||||
"""Module-scope ``GenerationKeyedCache`` instances persist across file
|
||||
loads — the counter they invalidate against is class-level and survives
|
||||
a ``.blend`` reload. Without a ``load_post`` drain the cache may serve
|
||||
entries whose ``bpy_struct`` references point into the previous file's
|
||||
freed ``bpy.data``, raising ``ReferenceError`` on the next attribute read.
|
||||
|
||||
Pin: every model module that exposes both a module-scope cache and a
|
||||
top-level ``clear_caches`` is called from ``tool.Parametric.on_load_post``,
|
||||
the central post-load drain."""
|
||||
parametric_tree = ast.parse(PARAMETRIC_PATH.read_text(encoding="utf-8"))
|
||||
fn = _function_node(parametric_tree, "on_load_post")
|
||||
drained: set[str] = set()
|
||||
for node in ast.walk(fn):
|
||||
if (
|
||||
isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Attribute)
|
||||
and node.func.attr == "clear_caches"
|
||||
and isinstance(node.func.value, ast.Name)
|
||||
):
|
||||
drained.add(node.func.value.id)
|
||||
missing = [name for name in _modules_with_module_scope_cache_and_clear() if name not in drained]
|
||||
if missing:
|
||||
pytest.fail(
|
||||
"Module(s) expose a module-scope GenerationKeyedCache + clear_caches() but "
|
||||
f"tool.Parametric.on_load_post does not drain them on load_post: {sorted(missing)}. "
|
||||
"Add a `<module>.clear_caches()` call so freshly-loaded files cannot serve "
|
||||
"entries holding freed bpy.data references from the previous file."
|
||||
)
|
||||
@@ -0,0 +1,74 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Restricted-context regression test for ``tool.Blender.get_active_object``.
|
||||
|
||||
Some Blender contexts (e.g. the C-side operator context handed to
|
||||
programmatically-invoked nested ``bpy.ops`` calls) lack the view-layer
|
||||
attributes a normal UI context exposes. The canonical accessor must
|
||||
return ``None`` in that case rather than ``AttributeError`` — otherwise
|
||||
every caller routed through it inherits the same crash class that
|
||||
originally broke ``bpy.ops.bim.new_project(preset='demo')``."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
class _RestrictedContext:
|
||||
"""Stand-in for a ``bpy.context`` stripped of view-layer attributes."""
|
||||
|
||||
def __getattr__(self, name):
|
||||
raise AttributeError(name)
|
||||
|
||||
|
||||
def test_get_active_object_returns_none_in_restricted_context():
|
||||
"""``tool.Blender.get_active_object`` is the canonical defensive
|
||||
accessor. Both the primary read (``bpy.context.active_object``) and
|
||||
the fallback (``bpy.context.view_layer.objects.active``) must
|
||||
tolerate a stripped context — otherwise the 150+ callers in the
|
||||
codebase that route through this helper inherit the crash."""
|
||||
import bonsai.tool.blender as blender_tool
|
||||
|
||||
with patch.object(blender_tool, "bpy") as bpy_patch:
|
||||
bpy_patch.context = _RestrictedContext()
|
||||
assert blender_tool.Blender.get_active_object() is None
|
||||
|
||||
|
||||
def test_property_header_tools_whitelists_bim_tool_family_only():
|
||||
"""``tool.Blender.get_property_header_tools`` gates the validate-
|
||||
gizmo header refresh. Parametric ``BimTool`` subclasses and the
|
||||
base ``BimTool`` itself must be included; ``AnnotationTool`` and
|
||||
workspace tools outside the ``BimTool`` family (spatial, structural,
|
||||
etc.) must not — they don't surface these header floats."""
|
||||
import bonsai.tool as tool_
|
||||
|
||||
# Ensure the lru_cache picks up subclasses registered by the
|
||||
# current Blender session (idempotent if already populated).
|
||||
tool_.Blender.get_property_header_tools.cache_clear()
|
||||
headers = tool_.Blender.get_property_header_tools()
|
||||
|
||||
assert "bim.bim_tool" in headers, "base BimTool must surface property headers"
|
||||
assert "bim.wall_tool" in headers, "parametric BimTool subclass must surface property headers"
|
||||
assert "bim.annotation_tool" not in headers, "AnnotationTool is not a BimTool subclass — no header surface"
|
||||
assert "bim.spatial_tool" not in headers, "SpatialTool is not BimTool-derived"
|
||||
assert "bim.structural_tool" not in headers, "StructuralTool is not BimTool-derived"
|
||||
@@ -22,14 +22,15 @@
|
||||
|
||||
The registry is the single source of truth for which parametric element types
|
||||
exist. Every consumer (auto-commit on save, finish/cancel chains, the
|
||||
``PointerProperty`` attachment, the ``GizmoPreferences<X>`` registration) derives
|
||||
identifiers from each entry's short ``name`` token. Forget any downstream
|
||||
registration and the silent-desync the framework exists to prevent will ship.
|
||||
``PointerProperty`` attachment, the ``GizmoPreferences`` per-feature toggle)
|
||||
derives identifiers from each entry's short ``name`` token. Forget any
|
||||
downstream registration and the silent-desync the framework exists to prevent
|
||||
will ship.
|
||||
|
||||
These tests pin the registry-to-runtime contract: for every entry the operator
|
||||
``bl_idname``s resolve to registered ``bpy.ops.bim.*`` callables, the
|
||||
``PropertyGroup`` class is attached to ``bpy.types.Object``, and the per-type
|
||||
predicate exists on `tool.Blender.Modifier`."""
|
||||
predicate exists on `tool.Parametric`."""
|
||||
|
||||
import types
|
||||
|
||||
@@ -81,11 +82,11 @@ def test_every_entry_has_property_group_attached(registry):
|
||||
)
|
||||
|
||||
|
||||
def test_every_entry_has_modifier_predicate(registry):
|
||||
def test_every_entry_has_parametric_predicate(registry):
|
||||
from bonsai import tool
|
||||
|
||||
missing = [e.name for e in registry if getattr(tool.Blender.Modifier, f"is_{e.name}", None) is None]
|
||||
assert not missing, f"tool.Blender.Modifier missing is_<name> predicates: {missing}"
|
||||
missing = [e.name for e in registry if getattr(tool.Parametric, f"is_{e.name}", None) is None]
|
||||
assert not missing, f"tool.Parametric missing is_<name> predicates: {missing}"
|
||||
|
||||
|
||||
def test_every_predicate_does_not_raise_on_non_matching_element(registry):
|
||||
@@ -108,7 +109,7 @@ def test_every_predicate_does_not_raise_on_non_matching_element(registry):
|
||||
|
||||
raised = []
|
||||
for feature in registry:
|
||||
predicate = getattr(tool.Blender.Modifier, f"is_{feature.name}", None)
|
||||
predicate = getattr(tool.Parametric, f"is_{feature.name}", None)
|
||||
if predicate is None:
|
||||
continue
|
||||
try:
|
||||
@@ -122,19 +123,14 @@ def test_every_predicate_does_not_raise_on_non_matching_element(registry):
|
||||
)
|
||||
|
||||
|
||||
def test_gizmo_preferences_attached_when_class_exists(registry):
|
||||
"""For every registry entry whose ``GizmoPreferences<Name>`` class exists in
|
||||
``bonsai.bim.ui``, the matching sub-PointerProperty must be declared on
|
||||
``ui.GizmoPreferences`` under the registry entry's ``name`` token.
|
||||
|
||||
Catches the silent-skip behaviour of the registry-driven gizmo-prefs
|
||||
discovery: a typo in the class name or a dropped registration would
|
||||
otherwise produce a missing sub-panel at runtime with no error.
|
||||
Entries without a ``GizmoPreferences<Name>`` class are allowed — not
|
||||
every parametric type ships gizmo prefs.
|
||||
def test_gizmo_preferences_field_per_registry_entry(registry):
|
||||
"""Every registry entry must have a matching ``<name>: BoolProperty`` field
|
||||
on ``ui.GizmoPreferences`` so the addon-preferences UI auto-renders a
|
||||
toggle for it and ``BaseParametricGizmoGroup.poll`` can gate the whole
|
||||
gizmo group on ``prefs.gizmos.<name>``.
|
||||
|
||||
Checks ``__annotations__`` rather than ``hasattr`` because Blender's
|
||||
PropertyGroup syntax (``field: bpy.props.PointerProperty(...)``) is an
|
||||
PropertyGroup syntax (``field: bpy.props.BoolProperty(...)``) is an
|
||||
annotation-only assignment — the attribute only materialises on the
|
||||
class after Blender's metaclass installs the bpy_struct descriptor,
|
||||
which depends on registration timing. Reading ``__annotations__``
|
||||
@@ -142,16 +138,9 @@ def test_gizmo_preferences_attached_when_class_exists(registry):
|
||||
from bonsai.bim import ui
|
||||
|
||||
annotations = getattr(ui.GizmoPreferences, "__annotations__", {})
|
||||
missing = []
|
||||
for feature in registry:
|
||||
prefs_class_name = f"GizmoPreferences{feature.name.capitalize()}"
|
||||
if not hasattr(ui, prefs_class_name):
|
||||
continue
|
||||
if feature.name not in annotations:
|
||||
missing.append((feature.name, prefs_class_name))
|
||||
missing = [feature.name for feature in registry if feature.name not in annotations]
|
||||
assert not missing, (
|
||||
f"ui.GizmoPreferences missing sub-PointerProperty field(s) for: {missing} — "
|
||||
f"each registered ``GizmoPreferences<Name>`` class must have a matching "
|
||||
f"``<name>: PointerProperty(type=GizmoPreferences<Name>)`` field on "
|
||||
f"``ui.GizmoPreferences``"
|
||||
f"ui.GizmoPreferences missing BoolProperty field(s) for: {missing} — "
|
||||
f"each registry entry must have a matching ``<name>: BoolProperty(...)`` "
|
||||
f"field on ``ui.GizmoPreferences`` so the preferences UI surfaces a toggle"
|
||||
)
|
||||
|
||||
@@ -56,6 +56,7 @@ class TestCopyClass:
|
||||
ifc.get_entity("data").should_be_called().will_return("new_representation")
|
||||
geometry.get_representation_name("new_representation").should_be_called().will_return("name")
|
||||
geometry.rename_object("data", "name").should_be_called()
|
||||
root.has_material_styles("element").should_be_called().will_return(False)
|
||||
root.assign_body_styles("element", "obj").should_be_called()
|
||||
collector.assign("obj").should_be_called()
|
||||
subject.copy_class(ifc, collector, geometry, root, obj="obj")
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
ISO-10303-21;
|
||||
HEADER;
|
||||
FILE_DESCRIPTION(('ViewDefinition[DesignTransferView]'),'2;1');
|
||||
FILE_NAME('snap-target.ifc','2026-06-05T17:45:10-03:00',(''),(''),'IfcOpenShell 0.0.0','Bonsai 0.8.6-alpha260605-24a241a','Nobody');
|
||||
FILE_SCHEMA(('IFC4'));
|
||||
ENDSEC;
|
||||
DATA;
|
||||
#1=IFCPROJECT('2pZygwkcb1Au$5kgwmW6ZC',$,'My Project',$,$,$,$,(#10,#22),#5);
|
||||
#2=IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.);
|
||||
#3=IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.);
|
||||
#4=IFCSIUNIT(*,.VOLUMEUNIT.,$,.CUBIC_METRE.);
|
||||
#5=IFCUNITASSIGNMENT((#4,#2,#3));
|
||||
#6=IFCCARTESIANPOINT((0.,0.,0.));
|
||||
#7=IFCDIRECTION((0.,0.,1.));
|
||||
#8=IFCDIRECTION((1.,0.,0.));
|
||||
#9=IFCAXIS2PLACEMENT3D(#6,#7,#8);
|
||||
#10=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#9,$);
|
||||
#11=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Model',*,*,*,*,#10,$,.MODEL_VIEW.,$);
|
||||
#12=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Axis','Model',*,*,*,*,#10,$,.GRAPH_VIEW.,$);
|
||||
#13=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Box','Model',*,*,*,*,#10,$,.MODEL_VIEW.,$);
|
||||
#14=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Annotation','Model',*,*,*,*,#10,$,.SECTION_VIEW.,$);
|
||||
#15=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Annotation','Model',*,*,*,*,#10,$,.ELEVATION_VIEW.,$);
|
||||
#16=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Annotation','Model',*,*,*,*,#10,$,.MODEL_VIEW.,$);
|
||||
#17=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Annotation','Model',*,*,*,*,#10,$,.PLAN_VIEW.,$);
|
||||
#18=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Profile','Model',*,*,*,*,#10,$,.ELEVATION_VIEW.,$);
|
||||
#19=IFCCARTESIANPOINT((0.,0.));
|
||||
#20=IFCDIRECTION((1.,0.));
|
||||
#21=IFCAXIS2PLACEMENT2D(#19,#20);
|
||||
#22=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Plan',2,1.E-05,#21,$);
|
||||
#23=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Axis','Plan',*,*,*,*,#22,$,.GRAPH_VIEW.,$);
|
||||
#24=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Plan',*,*,*,*,#22,$,.PLAN_VIEW.,$);
|
||||
#25=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Annotation','Plan',*,*,*,*,#22,$,.PLAN_VIEW.,$);
|
||||
#26=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Annotation','Plan',*,*,*,*,#22,$,.REFLECTED_PLAN_VIEW.,$);
|
||||
#27=IFCSITE('1Lvnr1aSn2OP70jevISY_C',$,'My Site',$,$,#50,$,$,$,$,$,$,$,$);
|
||||
#33=IFCBUILDING('1NaNtC8Wf6YPU7CZRG8Z8Q',$,'My Building',$,$,#56,$,$,$,$,$,$);
|
||||
#39=IFCBUILDINGSTOREY('1k9kfaMOr2cvyNvxvoll27',$,'My Storey',$,$,#62,$,$,$,$);
|
||||
#45=IFCRELAGGREGATES('1plJGwDIHDzwuc7i7gcQQD',$,$,$,#1,(#27));
|
||||
#46=IFCCARTESIANPOINT((0.,0.,0.));
|
||||
#47=IFCDIRECTION((0.,0.,1.));
|
||||
#48=IFCDIRECTION((1.,0.,0.));
|
||||
#49=IFCAXIS2PLACEMENT3D(#46,#47,#48);
|
||||
#50=IFCLOCALPLACEMENT($,#49);
|
||||
#51=IFCRELAGGREGATES('13KnzD8ZT8bAvAyOQAWiUj',$,$,$,#27,(#33));
|
||||
#52=IFCCARTESIANPOINT((0.,0.,0.));
|
||||
#53=IFCDIRECTION((0.,0.,1.));
|
||||
#54=IFCDIRECTION((1.,0.,0.));
|
||||
#55=IFCAXIS2PLACEMENT3D(#52,#53,#54);
|
||||
#56=IFCLOCALPLACEMENT(#50,#55);
|
||||
#57=IFCRELAGGREGATES('2Hf4WQLJvE4O43wq$0AZeu',$,$,$,#33,(#39));
|
||||
#58=IFCCARTESIANPOINT((0.,0.,0.));
|
||||
#59=IFCDIRECTION((0.,0.,1.));
|
||||
#60=IFCDIRECTION((1.,0.,0.));
|
||||
#61=IFCAXIS2PLACEMENT3D(#58,#59,#60);
|
||||
#62=IFCLOCALPLACEMENT(#56,#61);
|
||||
#63=IFCPROPERTYSINGLEVALUE('Symbol',$,IFCLABEL('setout-point'),$);
|
||||
#64=IFCPROPERTYSET('27lmSbeAXC08EWkEq8XdUG',$,'EPset_Annotation',$,(#63));
|
||||
#65=IFCTYPEPRODUCT('0UrP0fLdD5OwzwD41aRKao',$,'SETOUT-POINT',$,'IfcAnnotation/SYMBOL',(#64),$,$);
|
||||
#66=IFCCARTESIANPOINTLIST3D(((-8999.9990234375,-8999.9990234375,0.),(8999.9990234375,-8999.9990234375,0.),(-8999.9990234375,8999.9990234375,0.),(8999.9990234375,8999.9990234375,0.)));
|
||||
#67=IFCINDEXEDPOLYGONALFACE((1,2,4,3));
|
||||
#68=IFCPOLYGONALFACESET(#66,$,(#67),$);
|
||||
#69=IFCSHAPEREPRESENTATION(#11,'Body','Tessellation',(#68));
|
||||
#70=IFCBUILDINGELEMENTPROXY('3Avrn7zrPBiA7RUh91ENg9',$,'Plane',$,$,#142,#72,$,.COMPLEX.);
|
||||
#71=IFCRELCONTAINEDINSPATIALSTRUCTURE('0ftVF1Vf1Dmg$p62mtpWVF',$,$,$,(#123,#70,#108),#39);
|
||||
#72=IFCPRODUCTDEFINITIONSHAPE($,$,(#69));
|
||||
#108=IFCBUILDINGELEMENTPROXY('1lwURx5YX5WAXD5ExqQt31',$,'Plane',$,$,#122,#117,$,.COMPLEX.);
|
||||
#114=IFCCARTESIANPOINTLIST3D(((-5999.99951171875,-2999.99975585938,0.),(5999.99951171875,-2999.99975585938,0.)));
|
||||
#115=IFCINDEXEDPOLYCURVE(#114,(IFCLINEINDEX((1,2))),$);
|
||||
#116=IFCSHAPEREPRESENTATION(#11,'Body','Curve3D',(#115));
|
||||
#117=IFCPRODUCTDEFINITIONSHAPE($,$,(#116));
|
||||
#118=IFCCARTESIANPOINT((0.,0.,0.));
|
||||
#119=IFCDIRECTION((0.,0.,1.));
|
||||
#120=IFCDIRECTION((1.,0.,0.));
|
||||
#121=IFCAXIS2PLACEMENT3D(#118,#119,#120);
|
||||
#122=IFCLOCALPLACEMENT(#62,#121);
|
||||
#123=IFCBUILDINGELEMENTPROXY('2X4YMFxHjANvnkl1Hzqjlq',$,'Plane',$,$,#137,#132,$,.COMPLEX.);
|
||||
#129=IFCCARTESIANPOINTLIST3D(((3000.,-5999.99951171875,0.),(2999.99951171875,5999.99951171875,0.)));
|
||||
#130=IFCINDEXEDPOLYCURVE(#129,(IFCLINEINDEX((1,2))),$);
|
||||
#131=IFCSHAPEREPRESENTATION(#11,'Body','Curve3D',(#130));
|
||||
#132=IFCPRODUCTDEFINITIONSHAPE($,$,(#131));
|
||||
#133=IFCCARTESIANPOINT((0.,0.,0.));
|
||||
#134=IFCDIRECTION((0.,0.,1.));
|
||||
#135=IFCDIRECTION((1.,0.,0.));
|
||||
#136=IFCAXIS2PLACEMENT3D(#133,#134,#135);
|
||||
#137=IFCLOCALPLACEMENT(#62,#136);
|
||||
#138=IFCCARTESIANPOINT((0.,0.,0.));
|
||||
#139=IFCDIRECTION((0.,0.,1.));
|
||||
#140=IFCDIRECTION((1.,0.,0.));
|
||||
#141=IFCAXIS2PLACEMENT3D(#138,#139,#140);
|
||||
#142=IFCLOCALPLACEMENT(#62,#141);
|
||||
ENDSEC;
|
||||
END-ISO-10303-21;
|
||||
@@ -114,11 +114,13 @@ def new_project():
|
||||
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)
|
||||
@@ -161,6 +163,7 @@ def test_snap_object_detection(window):
|
||||
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)
|
||||
@@ -215,10 +218,11 @@ def test_snap_partially_behind_camera(window):
|
||||
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)
|
||||
x = round(area.width * 0.68 + area.x)
|
||||
y = round(area.height * 0.54 + area.y)
|
||||
|
||||
area.spaces[0].shading.show_xray = True
|
||||
@@ -244,10 +248,11 @@ def test_snap_in_xray_mode(window):
|
||||
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")
|
||||
@@ -258,12 +263,11 @@ def test_snap_far_from_origin(window):
|
||||
|
||||
yield from preset_event_simulate(window, "ESC", "TAP", x, y)
|
||||
|
||||
bpy.data.objects['IfcBuildingElementProxy/Cube'].select_set(True)
|
||||
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():
|
||||
@@ -296,6 +300,65 @@ def test_snap_far_from_origin(window):
|
||||
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)
|
||||
@@ -358,6 +421,13 @@ def run_tests():
|
||||
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()
|
||||
|
||||
@@ -375,6 +445,7 @@ def run_tests():
|
||||
|
||||
_next()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
new_project()
|
||||
run_tests()
|
||||
|
||||
@@ -588,6 +588,10 @@ class TestGenerateStair2DProfile(NewFile):
|
||||
|
||||
|
||||
class TestUsingArrays(NewFile):
|
||||
@staticmethod
|
||||
def _array_objects() -> list[bpy.types.Object]:
|
||||
return [o for o in bpy.data.objects if (e := tool.Ifc.get_entity(o)) and e.is_a("IfcActuator")]
|
||||
|
||||
def setup_array(self, add_second_layer=False, sync_children=False):
|
||||
tool.Project.get_project_props().template_file = "0"
|
||||
bpy.ops.bim.create_project()
|
||||
@@ -605,7 +609,7 @@ class TestUsingArrays(NewFile):
|
||||
props.count = 4
|
||||
props.x = 4
|
||||
props.sync_children = sync_children
|
||||
bpy.ops.bim.edit_array(item=0)
|
||||
bpy.ops.bim.finish_editing_array()
|
||||
|
||||
if add_second_layer:
|
||||
bpy.ops.bim.add_array()
|
||||
@@ -614,14 +618,14 @@ class TestUsingArrays(NewFile):
|
||||
props.count = 3
|
||||
props.y = 4
|
||||
props.sync_children = sync_children
|
||||
bpy.ops.bim.edit_array(item=1)
|
||||
bpy.ops.bim.finish_editing_array()
|
||||
|
||||
def test_remove_array_last_to_first(self):
|
||||
self.setup_array(add_second_layer=True)
|
||||
bpy.ops.bim.remove_array(item=1)
|
||||
assert len(bpy.context.selected_objects) == 4
|
||||
assert len(self._array_objects()) == 4
|
||||
bpy.ops.bim.remove_array(item=0)
|
||||
assert len(bpy.context.selected_objects) == 1
|
||||
assert len(self._array_objects()) == 1
|
||||
|
||||
def test_remove_array_first_to_last(self):
|
||||
self.setup_array(add_second_layer=True)
|
||||
@@ -647,7 +651,7 @@ class TestUsingArrays(NewFile):
|
||||
bpy.ops.bim.apply_array() # apply second layer
|
||||
bpy.ops.bim.apply_array() # apply first layer
|
||||
|
||||
objs = bpy.context.selected_objects
|
||||
objs = self._array_objects()
|
||||
assert len(objs) == 12
|
||||
|
||||
# check BBIM_Array psets are removed
|
||||
|
||||
@@ -149,8 +149,12 @@ namespace {
|
||||
|
||||
template <>
|
||||
struct dispatch_conversion<ifcopenshell::geometry::taxonomy::type_by_kind::max> {
|
||||
static bool dispatch(ifcopenshell::geometry::kernels::AbstractKernel*, ifcopenshell::geometry::taxonomy::kinds, const ifcopenshell::geometry::taxonomy::ptr& item, IfcGeom::ConversionResults&) {
|
||||
Logger::Error("No conversion for " + std::to_string(item->kind()));
|
||||
static bool dispatch(ifcopenshell::geometry::kernels::AbstractKernel* kernel, ifcopenshell::geometry::taxonomy::kinds, const ifcopenshell::geometry::taxonomy::ptr& item, IfcGeom::ConversionResults&) {
|
||||
std::string created_from;
|
||||
if (item->instance) {
|
||||
created_from = " (created from " + item->instance->declaration().name() + ")";
|
||||
}
|
||||
Logger::Error("No support for " + ifcopenshell::geometry::taxonomy::kind_to_string(item->kind()) + created_from + " in kernel " + kernel->geometry_library());
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -169,8 +173,12 @@ namespace {
|
||||
|
||||
template <>
|
||||
struct dispatch_with_upgrade<ifcopenshell::geometry::taxonomy::upgrades::max> {
|
||||
static bool dispatch(ifcopenshell::geometry::kernels::AbstractKernel*, const ifcopenshell::geometry::taxonomy::ptr& item, IfcGeom::ConversionResults&) {
|
||||
Logger::Error("No conversion with upgrade for " + std::to_string(item->kind()));
|
||||
static bool dispatch(ifcopenshell::geometry::kernels::AbstractKernel* kernel, const ifcopenshell::geometry::taxonomy::ptr& item, IfcGeom::ConversionResults&) {
|
||||
std::string created_from;
|
||||
if (item->instance) {
|
||||
created_from = " (created from " + item->instance->declaration().name() + ")";
|
||||
}
|
||||
Logger::Error("No support (after considering item upgrade) for " + ifcopenshell::geometry::taxonomy::kind_to_string(item->kind()) + created_from + " in kernel " + kernel->geometry_library());
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -35,13 +35,25 @@ namespace IfcGeom {
|
||||
class Transformation {
|
||||
private:
|
||||
ifcopenshell::geometry::Settings settings_;
|
||||
ifcopenshell::geometry::taxonomy::matrix4::ptr matrix_;
|
||||
ifcopenshell::geometry::taxonomy::matrix4::ptr matrix_, matrix_orig_units_;
|
||||
public:
|
||||
Transformation(const ifcopenshell::geometry::Settings& settings, const ifcopenshell::geometry::taxonomy::matrix4::ptr& matrix)
|
||||
: settings_(settings)
|
||||
, matrix_(matrix)
|
||||
{}
|
||||
Transformation(const ifcopenshell::geometry::Settings& settings, const ifcopenshell::geometry::taxonomy::matrix4::ptr& matrix)
|
||||
: settings_(settings), matrix_(matrix)
|
||||
{
|
||||
const bool convert = settings.get<ifcopenshell::geometry::settings::ConvertBackUnits>().get();
|
||||
auto unit_magnitude = settings.get<ifcopenshell::geometry::settings::LengthUnit>().get();
|
||||
if (matrix_ && convert && unit_magnitude != 1.0) {
|
||||
matrix_orig_units_ = ifcopenshell::geometry::taxonomy::make<ifcopenshell::geometry::taxonomy::matrix4>(*matrix);
|
||||
// only multiple the translation components of the matrix with the unit magnitude, not the rotation/scaling components
|
||||
matrix_orig_units_->components().col(3).head<3>() /= unit_magnitude;
|
||||
} else {
|
||||
matrix_orig_units_ = nullptr;
|
||||
}
|
||||
}
|
||||
const ifcopenshell::geometry::taxonomy::matrix4::ptr& data() const {
|
||||
if (matrix_orig_units_) {
|
||||
return matrix_orig_units_;
|
||||
}
|
||||
if (matrix_) {
|
||||
return matrix_;
|
||||
}
|
||||
|
||||
@@ -116,6 +116,16 @@ template <typename Kernel>
|
||||
using plane_map = std::map<typename Kernel::Plane_3, typename Kernel::Plane_3, PlaneLess<Kernel>>;
|
||||
// using plane_map = std::unordered_map<typename Kernel::Plane_3, typename Kernel::Plane_3, PlaneHash<Kernel>>;
|
||||
|
||||
// Lexicographic comparator for CGAL Point_d (operator< is deleted in CGAL 6.x)
|
||||
struct Point_d_4d_Less {
|
||||
using Point_d = CGAL::Epick_d<CGAL::Dimension_tag<4>>::Point_d;
|
||||
bool operator()(const Point_d& a, const Point_d& b) const {
|
||||
return std::lexicographical_compare(
|
||||
a.cartesian_begin(), a.cartesian_end(),
|
||||
b.cartesian_begin(), b.cartesian_end());
|
||||
}
|
||||
};
|
||||
|
||||
// Snap halfspace planes
|
||||
// search_radius: max cartesian distance in plane equation parameters as 4d points in space
|
||||
template <typename Kernel>
|
||||
@@ -131,8 +141,8 @@ plane_map<Kernel> snap_halfspaces(const std::list<CGAL::Plane_3<Kernel>>& planes
|
||||
|
||||
plane_map<Kernel> result;
|
||||
|
||||
std::map<Point_d, std::set<Point_d>> neighbours;
|
||||
std::map<Point_d, std::list<CGAL::Plane_3<Kernel>>> originals;
|
||||
std::map<Point_d, std::set<Point_d, Point_d_4d_Less>, Point_d_4d_Less> neighbours;
|
||||
std::map<Point_d, std::list<CGAL::Plane_3<Kernel>>, Point_d_4d_Less> originals;
|
||||
std::vector<Point_d> planes_as_point;
|
||||
|
||||
for (auto& p : planes) {
|
||||
@@ -205,7 +215,7 @@ plane_map<Kernel> snap_halfspaces_2(const std::list<CGAL::Plane_3<Kernel>>& plan
|
||||
plane_map<Kernel> result;
|
||||
|
||||
std::vector<Point_d> planes_as_point;
|
||||
std::map<Point_d, CGAL::Plane_3<Kernel>> normalized_to_original;
|
||||
std::map<Point_d, CGAL::Plane_3<Kernel>, Point_d_4d_Less> normalized_to_original;
|
||||
|
||||
for (auto& p : planes_fixed) {
|
||||
// @todo can we skip normalization (simply divide by largest component perhaps)
|
||||
|
||||
@@ -136,17 +136,39 @@ bool IfcGeom::OpenCascadeKernel::convert_openings(const IfcUtil::IfcBaseEntity*
|
||||
for (auto entity_part : parts) {
|
||||
bool is_manifold = util::is_manifold(entity_part);
|
||||
|
||||
if (!is_manifold) {
|
||||
// force sewing, edge identity might have been mudied by FixAdvFace.FixOrientation.MSG5 to fix interior loop winding order
|
||||
TopTools_ListOfShape list;
|
||||
IfcGeom::util::shape_to_face_list(entity_part, list);
|
||||
IfcGeom::util::create_solid_from_faces(list, entity_part, settings_.get<settings::Precision>().get(), true);
|
||||
is_manifold = util::is_manifold(entity_part);
|
||||
if (is_manifold) {
|
||||
Logger::Warning("Successfully sewed non-manifold first operand");
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_manifold) {
|
||||
if (settings_.get<settings::MakeVolume>().get()) {
|
||||
BOPAlgo_MakerVolume mv;
|
||||
mv.AddArgument(entity_part);
|
||||
mv.SetAvoidInternalShapes(true);
|
||||
// mv.SetFuzzyValue(settings_.get<settings::Precision>().get());
|
||||
std::optional<std::string> failure;
|
||||
try {
|
||||
mv.Perform();
|
||||
entity_part = mv.Shape();
|
||||
Logger::Warning("Sucessfully detected exterior volume to non-manifold first operand");
|
||||
auto entity_part_2 = mv.Shape();
|
||||
if (IfcGeom::util::count(entity_part_2, TopAbs_FACE) == 0) {
|
||||
failure = "Empty result (no faces) for BOPAlgo_MakerVolume; original was " + std::to_string(IfcGeom::util::count(entity_part, TopAbs_FACE));
|
||||
} else {
|
||||
is_manifold = util::is_manifold(entity_part_2);
|
||||
Logger::Warning(std::string("Sucessfully detected exterior volume to non-manifold first operand; shape is now ") + (is_manifold ? std::string("manifold") : std::string("non-manifold")));
|
||||
entity_part = entity_part_2;
|
||||
}
|
||||
} catch (const Standard_Failure& e) {
|
||||
Logger::Warning("MakeVolume failed: " + std::string(e.GetMessageString()), entity);
|
||||
failure.emplace(e.GetMessageString());
|
||||
}
|
||||
if (failure) {
|
||||
Logger::Warning("MakeVolume failed: " + *failure, entity);
|
||||
}
|
||||
} else {
|
||||
Logger::Warning("Non-manifold first operand, use --make-volume to try and make manifold");
|
||||
|
||||
@@ -154,7 +154,13 @@ IfcGeom::OpenCascadeKernel::faceset_helper::faceset_helper(
|
||||
|
||||
typedef std::array<int, 2> edge_t;
|
||||
typedef std::set<edge_t> edge_set_t;
|
||||
std::set<edge_set_t> edge_sets;
|
||||
// When a single face fills an interior loop, their edge_sets (canonicalized edges) will be identical.
|
||||
// We can differentiate in this scenario in two ways:
|
||||
// - std::map<edge_t, bool> retain the edge order from the bool passed to the loop_() lambda
|
||||
// - std::pair<bool, edge_set_t> with pair::first populated from external (FaceBound / OuterBound)
|
||||
// The second has been found more reliable for typical models, because inner bound winding can be wrong.
|
||||
// The can be made more resilient by first checking correct population of external and falling back to approach 1.
|
||||
std::set<std::pair<bool, edge_set_t>> edge_sets;
|
||||
|
||||
for (auto& loop : loops) {
|
||||
std::vector<std::pair<int, int> > segments;
|
||||
@@ -165,12 +171,12 @@ IfcGeom::OpenCascadeKernel::faceset_helper::faceset_helper(
|
||||
segments.push_back(std::make_pair(C, D));
|
||||
});
|
||||
|
||||
if (edge_sets.find(segment_set) != edge_sets.end()) {
|
||||
if (edge_sets.find({loop->external.get_value_or(false), segment_set}) != edge_sets.end()) {
|
||||
duplicate_faces++;
|
||||
duplicates_.insert(loop->identity());
|
||||
continue;
|
||||
}
|
||||
edge_sets.insert(segment_set);
|
||||
edge_sets.insert({loop->external.get_value_or(false), segment_set});
|
||||
|
||||
if (segments.size() >= 3) {
|
||||
for (auto& p : segments) {
|
||||
|
||||
@@ -4,9 +4,12 @@
|
||||
#ifdef IFOPSH_WITH_ROCKSDB
|
||||
#include <rocksdb/table.h>
|
||||
#include <rocksdb/convenience.h>
|
||||
#include <rocksdb/version.h>
|
||||
#endif
|
||||
|
||||
#include <fstream>
|
||||
#include <memory>
|
||||
#include <utility>
|
||||
#include <sys/types.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
@@ -410,10 +413,8 @@ IfcUtil::IfcBaseClass* IfcParse::impl::rocks_db_file_storage::assert_existance(s
|
||||
}
|
||||
|
||||
namespace {
|
||||
rocksdb::DB* init_db(const std::string& filepath, bool readonly) {
|
||||
rocksdb::DB* db = nullptr;
|
||||
std::unique_ptr<rocksdb::DB> init_db(const std::string& filepath, bool readonly) {
|
||||
#ifdef IFOPSH_WITH_ROCKSDB
|
||||
|
||||
rocksdb::Options options;
|
||||
// options.disable_auto_compactions = true;
|
||||
options.create_if_missing = true;
|
||||
@@ -445,16 +446,31 @@ namespace {
|
||||
options.table_factory.reset(rocksdb::NewBlockBasedTableFactory(tbo));
|
||||
|
||||
rocksdb::Status status;
|
||||
std::unique_ptr<rocksdb::DB> db;
|
||||
if (readonly) {
|
||||
#if ROCKSDB_MAJOR > 9 || (ROCKSDB_MAJOR == 9 && ROCKSDB_MINOR >= 11)
|
||||
status = rocksdb::DB::OpenForReadOnly(options, filepath, &db);
|
||||
#else
|
||||
rocksdb::DB* raw = nullptr;
|
||||
status = rocksdb::DB::OpenForReadOnly(options, filepath, &raw);
|
||||
db.reset(raw);
|
||||
#endif
|
||||
} else {
|
||||
#if ROCKSDB_MAJOR > 9 || (ROCKSDB_MAJOR == 9 && ROCKSDB_MINOR >= 11)
|
||||
status = rocksdb::DB::Open(options, filepath, &db);
|
||||
#else
|
||||
rocksdb::DB* raw = nullptr;
|
||||
status = rocksdb::DB::Open(options, filepath, &raw);
|
||||
db.reset(raw);
|
||||
#endif
|
||||
}
|
||||
if (!status.ok()) {
|
||||
return nullptr;
|
||||
}
|
||||
#endif // IFOPSH_WITH_ROCKSDB#
|
||||
return db;
|
||||
#else
|
||||
return nullptr;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,12 +479,12 @@ IfcParse::impl::rocks_db_file_storage::rocks_db_file_storage(const std::string&
|
||||
: file(ffile)
|
||||
, db(init_db(filepath, readonly))
|
||||
// @todo streaming serializer does not populate the byguid map
|
||||
, byguid_internal_(db, "g|")
|
||||
, byguid_internal_(db.get(), "g|")
|
||||
, byguid_(&byguid_internal_, [this](size_t v) { return assert_existance(v, entityinstance_ref); }, [](IfcUtil::IfcBaseClass* v) { return v->identity(); })
|
||||
, instance_ids_(db, "i|")
|
||||
, instance_ids_(db.get(), "i|")
|
||||
, instance_by_name_(&instance_ids_, [this](size_t v) { return assert_existance(v, entityinstance_ref); })
|
||||
, bytype_(db, "t|")
|
||||
, byref_excl_(db, "v|")
|
||||
, bytype_(db.get(), "t|")
|
||||
, byref_excl_(db.get(), "v|")
|
||||
// @todo by_identity is probably not correct here, this mapping is Name -> Identity, so Fn should have access to full pair?
|
||||
// , byidentity_(&byid_, [this](size_t v) { return assert_existance(v, by_identity); }, [](IfcUtil::IfcBaseClass* v) { return v->identity(); })
|
||||
{
|
||||
@@ -491,7 +507,6 @@ IfcParse::impl::rocks_db_file_storage::~rocks_db_file_storage()
|
||||
assert(s.ok());
|
||||
|
||||
db->Close();
|
||||
delete db;
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ namespace rocksdb {
|
||||
#include <iostream>
|
||||
#include <vector>
|
||||
#include <list>
|
||||
#include <memory>
|
||||
|
||||
#ifndef SWIG
|
||||
|
||||
@@ -308,7 +309,7 @@ namespace IfcParse {
|
||||
|
||||
class IFC_PARSE_API rocks_db_file_storage {
|
||||
public:
|
||||
rocksdb::DB* db;
|
||||
std::unique_ptr<rocksdb::DB> db;
|
||||
rocksdb::WriteOptions wopts;
|
||||
rocksdb::ReadOptions ropts;
|
||||
IfcParse::IfcFile* file;
|
||||
|
||||
Reference in New Issue
Block a user