mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-07 08:21:42 +00:00
Compare commits
105 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 680f29f284 | |||
| f158ae7377 | |||
| 5dde402f8b | |||
| 1daee04d9c | |||
| 0d7c378db5 | |||
| 1c128a2d6a | |||
| 36372627db | |||
| a9b6f02f02 | |||
| 9e7d97e298 | |||
| 187b8e7167 | |||
| 08e33fe572 | |||
| e142b9d7b4 | |||
| 431cf435ef | |||
| a433f56337 | |||
| a3f92eb427 | |||
| 453e6dc1cc | |||
| 728026d3f0 | |||
| 3f270df11e | |||
| 792a0c7da1 | |||
| 5856d29fbe | |||
| cd482a7874 | |||
| f6e95c8e8e | |||
| ee63137c6c | |||
| c83b4eb69f | |||
| 5eef433abf | |||
| 7e5e7b8d6a | |||
| 788d4fe8e8 | |||
| a1c2aecf1b | |||
| d501970352 | |||
| fd96e6a4d2 | |||
| 3dd3a0d70c | |||
| 2e5995176a | |||
| 2114c1d5d0 | |||
| 3f680f5c21 | |||
| 97e8deb069 | |||
| 6ba5f5af3d | |||
| 335ee1a1bb | |||
| 49348908e6 | |||
| c250b2c1a7 | |||
| 6874d52100 | |||
| 6c21e2b6f4 | |||
| 70845e4dd4 | |||
| d7b5ac1453 | |||
| 1961cd905e | |||
| f1cf757ba2 | |||
| b039e12623 | |||
| 1325705d8e | |||
| cb2f20b2b6 | |||
| f41f5dfdd8 | |||
| 1855e4c019 | |||
| 2feade01cb | |||
| ff4c642db1 | |||
| e7e489e390 | |||
| 5e23030a0f | |||
| c9f12dd441 | |||
| 4b9ad66c95 | |||
| d43a1353e0 | |||
| b1fa2407a9 | |||
| 786d3c8a89 | |||
| 89b7eff03e | |||
| 1c8fad3c13 | |||
| 6ec8372378 | |||
| 5dc7513de0 | |||
| f37c77e80c | |||
| db9d903650 | |||
| 3483683cb4 | |||
| a0c6f6f9a6 | |||
| 49ddda6281 | |||
| 96b6985960 | |||
| b19b2ac7cd | |||
| fdf4b82371 | |||
| 2f40441f1c | |||
| 230cbe1fd8 | |||
| 4d4c5b4d51 | |||
| 3d81660dad | |||
| b4abd999b6 | |||
| 1e6db764d4 | |||
| 936526b41b | |||
| 45ea5eb07a | |||
| 42ed398169 | |||
| f70044d373 | |||
| 719309571e | |||
| d3f0ad03fb | |||
| 7e96692764 | |||
| 3e0978062f | |||
| b3f482e0fa | |||
| 4943c77c5e | |||
| 6caf94f1d3 | |||
| 1e36cc318e | |||
| 46381ec08b | |||
| 47af955dd1 | |||
| f582d0230c | |||
| 26eef20eb5 | |||
| 2143262883 | |||
| 233cc344fa | |||
| a64e737d9c | |||
| 1b2507e143 | |||
| 508b99cb73 | |||
| 4e406ab1ce | |||
| 227d85d81f | |||
| a24cdf4958 | |||
| 9345b9ce3f | |||
| 0b5dded3b3 | |||
| 97218b1fdb | |||
| 1b637c6499 |
@@ -30,7 +30,7 @@ jobs:
|
||||
uv tool install ruff
|
||||
uv tool install black
|
||||
uv tool install poethepoet
|
||||
uv tool install ty
|
||||
uv tool install ty==0.0.34
|
||||
|
||||
# black doesn't catch all syntax errors, so we check them explicitly.
|
||||
- name: Check syntax errors
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install xmlschema xsdata numpy lxml pytest isodate lark networkx tabulate python-dateutil shapely
|
||||
pip install xmlschema xsdata numpy lxml pytest isodate lark networkx tabulate python-dateutil shapely pyparsing
|
||||
pip install src/bcf --no-deps
|
||||
pip install pytest-xdist==3.8.0
|
||||
|
||||
|
||||
+1
-2
@@ -126,9 +126,8 @@ ssl._create_default_https_context = ssl._create_unverified_context
|
||||
import time
|
||||
from collections.abc import Generator, Sequence
|
||||
from pathlib import Path
|
||||
from urllib.request import urlretrieve
|
||||
|
||||
from typing import Literal, Union
|
||||
from urllib.request import urlretrieve
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
+9
-1
@@ -192,7 +192,11 @@ endif
|
||||
# Provides networkx graph analysis for project dependency calculations
|
||||
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download networkx --dest=./wheels
|
||||
# Required by IFCDiff
|
||||
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download deepdiff --dest=./wheels
|
||||
# Pinned <9.1: deepdiff 9.1.0 adds cachebox<6,>=5.2 which only ships macOS x86_64
|
||||
# wheels for macosx_10_12+ and is incompatible with our macos py311 --platform
|
||||
# macosx_10_10_x86_64 target. Revisit once the macos py311 platform tag is bumped
|
||||
# to 10_13 (matching py312/py313).
|
||||
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download "deepdiff<9.1" --dest=./wheels
|
||||
# Required by IFCCSV and ifcopenshell.util.selector
|
||||
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download lark --dest=./wheels
|
||||
# Required by IFC4D
|
||||
@@ -356,6 +360,10 @@ else
|
||||
pytest test/tool/test_$(MODULE).py --maxfail=1
|
||||
endif
|
||||
|
||||
.PHONY: test-modal
|
||||
test-modal:
|
||||
blender --enable-event-simulate --python test/modal/test_modal.py --window-maximized
|
||||
|
||||
# Reregistering test is not added to the standard test suite because during unregister
|
||||
# Blender removes all Bonsai dependencies breaking dev-environment symlinks.
|
||||
.PHONY: test-reregister
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was modified with the assistance of an AI coding tool.
|
||||
|
||||
import importlib
|
||||
import os
|
||||
@@ -25,7 +27,19 @@ import bpy
|
||||
import bpy.utils.previews
|
||||
from bpy_extras.io_utils import ExportHelper, ImportHelper
|
||||
|
||||
from . import handler, operator, prop, ui
|
||||
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
|
||||
@@ -157,9 +171,10 @@ classes = [
|
||||
ui.BIM_UL_tab_visibilities,
|
||||
ui.BIM_UL_panel_visibilities,
|
||||
ui.DocPreferences,
|
||||
ui.GizmoPreferencesDoor, # Register before GizmoPreferences
|
||||
ui.GizmoPreferencesWindow, # Register before GizmoPreferences
|
||||
ui.GizmoPreferencesStair, # Register before GizmoPreferences
|
||||
# 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
|
||||
@@ -268,6 +283,8 @@ def register():
|
||||
bpy.app.handlers.depsgraph_update_post.append(on_register)
|
||||
bpy.app.handlers.undo_post.append(handler.undo_post)
|
||||
bpy.app.handlers.redo_post.append(handler.redo_post)
|
||||
# Must follow the two appends above so regenerators see restored IFC state.
|
||||
parametric_lifecycle.install_parametric_lifecycle_handlers()
|
||||
bpy.app.handlers.load_post.append(handler.load_post)
|
||||
bpy.app.handlers.load_post.append(handler.loadIfcStore)
|
||||
bpy.types.Scene.BIMProperties = bpy.props.PointerProperty(type=prop.BIMProperties)
|
||||
@@ -325,6 +342,7 @@ def unregister():
|
||||
|
||||
unregister_classes(classes)
|
||||
|
||||
parametric_lifecycle.uninstall_parametric_lifecycle_handlers()
|
||||
bpy.app.handlers.load_post.remove(handler.load_post)
|
||||
bpy.app.handlers.load_post.remove(handler.loadIfcStore)
|
||||
del bpy.types.Scene.BIMProperties
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# 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 structural-change cache token for POST_VIEW decorators.
|
||||
|
||||
Decorators include the token in their cache key and rebuild on bump."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
import bpy
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
_DECORATOR_CACHE_TOKEN = 0
|
||||
|
||||
|
||||
def get_decorator_cache_token() -> int:
|
||||
return _DECORATOR_CACHE_TOKEN
|
||||
|
||||
|
||||
def reset_for_test() -> None:
|
||||
"""Test-only: reset the cache token to 0 so bump-count assertions are stable."""
|
||||
global _DECORATOR_CACHE_TOKEN
|
||||
_DECORATOR_CACHE_TOKEN = 0
|
||||
|
||||
|
||||
@bpy.app.handlers.persistent
|
||||
def _bump_decorator_cache_token(*args: Any) -> None:
|
||||
"""depsgraph_update_post fires every animation frame and every driver
|
||||
evaluation, even when no IFC-relevant ID block changed. Unconditional
|
||||
bumping defeats the cache: an animated scene rebuilds every decorator
|
||||
every viewport tick. Gate the depsgraph path on Object geometry or
|
||||
transform updates; undo / redo / load have no depsgraph and always
|
||||
invalidate.
|
||||
|
||||
Coverage assumption: ``TokenCache`` consumers key on Object identity
|
||||
(depsgraph updates whose ``id`` is a ``bpy.types.Object``). Mesh /
|
||||
Material / NodeTree updates that don't surface as an Object change
|
||||
do NOT invalidate the token — a decorator that caches material- or
|
||||
mesh-data-derived state must gate on a separate signal."""
|
||||
global _DECORATOR_CACHE_TOKEN
|
||||
if len(args) >= 2:
|
||||
depsgraph = args[1]
|
||||
if depsgraph is not None and hasattr(depsgraph, "updates"):
|
||||
if not any(
|
||||
(getattr(u, "is_updated_geometry", False) or getattr(u, "is_updated_transform", False))
|
||||
and hasattr(u, "id")
|
||||
and isinstance(u.id, bpy.types.Object)
|
||||
for u in depsgraph.updates
|
||||
):
|
||||
return
|
||||
_DECORATOR_CACHE_TOKEN += 1
|
||||
|
||||
|
||||
def _hooks() -> tuple[Any, ...]:
|
||||
return (
|
||||
bpy.app.handlers.depsgraph_update_post,
|
||||
bpy.app.handlers.undo_post,
|
||||
bpy.app.handlers.redo_post,
|
||||
bpy.app.handlers.load_post,
|
||||
)
|
||||
|
||||
|
||||
def install_decorator_cache_handlers() -> None:
|
||||
"""Append the bump handler to each hook; idempotent."""
|
||||
for hook in _hooks():
|
||||
if _bump_decorator_cache_token not in hook:
|
||||
hook.append(_bump_decorator_cache_token)
|
||||
|
||||
|
||||
def uninstall_decorator_cache_handlers() -> None:
|
||||
for hook in _hooks():
|
||||
try:
|
||||
hook.remove(_bump_decorator_cache_token)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
class TokenCache(Generic[T]):
|
||||
"""Memoise a single value keyed on ``(caller_key, get_decorator_cache_token())``.
|
||||
|
||||
The token component invalidates the cache on depsgraph / undo / redo / load,
|
||||
so cached ``bpy.types.Object`` references can't outlive the underlying ID
|
||||
blocks. Holds exactly one entry — last key wins."""
|
||||
|
||||
__slots__ = ("_key", "_value")
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._key: tuple[Any, int] | None = None
|
||||
self._value: T | None = None
|
||||
|
||||
def get_or_compute(self, key: Any, compute: Callable[[], T]) -> T:
|
||||
token_key = (key, _DECORATOR_CACHE_TOKEN)
|
||||
if token_key == self._key:
|
||||
return self._value # type: ignore[return-value]
|
||||
value = compute()
|
||||
self._key = token_key
|
||||
self._value = value
|
||||
return value
|
||||
@@ -15,11 +15,12 @@
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was modified with the assistance of an AI coding tool.
|
||||
|
||||
import os
|
||||
import weakref
|
||||
from collections.abc import Callable
|
||||
from math import cos
|
||||
from typing import Union
|
||||
|
||||
import bpy
|
||||
@@ -31,8 +32,13 @@ from bpy.app.handlers import persistent
|
||||
from mathutils import Vector
|
||||
|
||||
import bonsai.bim
|
||||
import bonsai.core.model as core_model
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
from bonsai.bim.decorator_cache import (
|
||||
install_decorator_cache_handlers,
|
||||
uninstall_decorator_cache_handlers,
|
||||
)
|
||||
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.data import AuthoringData
|
||||
@@ -40,7 +46,9 @@ from bonsai.bim.module.model.decorator import (
|
||||
BoundingBoxDecorator,
|
||||
SlabDirectionDecorator,
|
||||
WallAxisDecorator,
|
||||
WallFilletPreviewDecorator,
|
||||
)
|
||||
from bonsai.bim.module.model.preview_base import discard_pending_previews
|
||||
from bonsai.bim.module.nest.decorator import NestDecorator
|
||||
|
||||
cwd = os.path.dirname(os.path.realpath(__file__))
|
||||
@@ -133,14 +141,39 @@ def update_bim_tool_props():
|
||||
|
||||
if is_annotation_tool and (object_type := tool.Drawing.get_annotation_type_object_type(element_type)):
|
||||
aprops.object_type = object_type
|
||||
aprops.relating_type_id = str(element_type.id())
|
||||
try:
|
||||
aprops.relating_type_id = str(element_type.id())
|
||||
except TypeError:
|
||||
# EnumProperty items are rebuilt asynchronously when ifc_class changes;
|
||||
# this assignment can race a stale item list. Skipping is harmless —
|
||||
# the UI will resync on the next active_object_callback.
|
||||
pass
|
||||
return
|
||||
|
||||
if is_bim_tool:
|
||||
props.ifc_class = element_type.is_a()
|
||||
try:
|
||||
props.ifc_class = element_type.is_a()
|
||||
except TypeError:
|
||||
# ifc_class only lists element/space types present in the model, so an
|
||||
# unsupported type (e.g. a raw IfcTypeProduct) or a stale item list mid-
|
||||
# rebuild raises `enum "<class>" not found`. Skip rather than crash the
|
||||
# handler — it re-fires on the next selection and the panel resyncs.
|
||||
pass
|
||||
|
||||
if is_bim_tool or TOOLS_TO_CLASSES_MAP.get(current_tool.idname) == element_type.is_a():
|
||||
props.relating_type_id = str(element_type.id())
|
||||
# Only assign when the target enum is the one that lists this type — otherwise
|
||||
# we hit `enum "<id>" not found in (...)` if the user selects an element of a
|
||||
# different class than the workspace tool was built for (e.g. selecting a wall
|
||||
# while the door tool is active).
|
||||
tool_class_match = TOOLS_TO_CLASSES_MAP.get(current_tool.idname) == element_type.is_a()
|
||||
bim_tool_class_match = is_bim_tool and props.ifc_class == element_type.is_a()
|
||||
if bim_tool_class_match or tool_class_match:
|
||||
try:
|
||||
props.relating_type_id = str(element_type.id())
|
||||
except TypeError:
|
||||
# Defensive: the enum item list can lag behind ifc_class assignment
|
||||
# above. Skipping leaves the panel briefly out of sync rather than
|
||||
# crashing the handler (which Blender re-fires on every selection).
|
||||
pass
|
||||
|
||||
if is_annotation_tool:
|
||||
return
|
||||
@@ -165,7 +198,9 @@ def update_bim_tool_props():
|
||||
if AuthoringData.data["active_material_usage"] == "LAYER2":
|
||||
x_angle = get_x_angle(extrusion)
|
||||
axis = tool.Model.get_wall_axis(obj)["reference"]
|
||||
props.extrusion_depth = abs(extrusion.Depth * si_conversion * cos(x_angle))
|
||||
props.extrusion_depth = core_model.vertical_height_from_extrusion_depth(
|
||||
extrusion.Depth * si_conversion, x_angle
|
||||
)
|
||||
props.length = (axis[1] - axis[0]).length
|
||||
props.x_angle = x_angle
|
||||
|
||||
@@ -356,8 +391,10 @@ def subscribe_to_viewport_shading_changes():
|
||||
)
|
||||
|
||||
|
||||
@persistent
|
||||
def load_post(scene):
|
||||
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."""
|
||||
global global_subscription_owner
|
||||
active_object_key = bpy.types.LayerObjects, "active"
|
||||
bpy.msgbus.subscribe_rna(
|
||||
@@ -368,6 +405,24 @@ def load_post(scene):
|
||||
ifcopenshell.api.owner.settings.get_application = get_application
|
||||
AuthoringData.type_thumbnails = {}
|
||||
|
||||
tool.Parametric.heal_stale_edit_flags()
|
||||
discard_pending_previews(scene)
|
||||
|
||||
if tool.Ifc.get() and bpy.data.is_saved:
|
||||
props = tool.Blender.get_bim_props()
|
||||
props.has_blend_warning = True
|
||||
|
||||
# Probe the H5 cooked-geometry cache so the multi-instance warning surfaces
|
||||
# right after .blend load. Without this, the lock is only detected when a
|
||||
# mutation triggers ``clear_cache`` — by which time the user has already
|
||||
# made changes that may now conflict with the other Blender instance.
|
||||
if tool.Ifc.get():
|
||||
get_cache_or_detect_lock()
|
||||
|
||||
|
||||
def _apply_user_preferences() -> None:
|
||||
"""User-preference-driven UI setup: toolbar, BIM workspace, viewport shading
|
||||
subscription, scene-panel hijack, tab layout, snap defaults."""
|
||||
preferences = tool.Blender.get_addon_preferences()
|
||||
if not preferences.should_setup_toolbar:
|
||||
tool.Blender.unregister_toolbar()
|
||||
@@ -391,11 +446,21 @@ def load_post(scene):
|
||||
tool.Blender.override_scene_panel(panel)
|
||||
tool.Blender.setup_tabs()
|
||||
|
||||
if tool.Ifc.get() and bpy.data.is_saved:
|
||||
props = tool.Blender.get_bim_props()
|
||||
props.has_blend_warning = True
|
||||
if preferences.should_use_snap and (scene := bpy.context.scene):
|
||||
# Snapping is off by default in Blender, but in BIM, it's more useful to be on
|
||||
scene.tool_settings.use_snap = True
|
||||
# Match default Bonsai snaps
|
||||
scene.tool_settings.snap_elements_base = {"EDGE", "EDGE_PERPENDICULAR", "VERTEX", "EDGE_MIDPOINT", "FACE"}
|
||||
|
||||
# Bonsai overlays
|
||||
tool.Blender.sync_old_preferences()
|
||||
|
||||
|
||||
def _install_viewport_overlays() -> None:
|
||||
"""Sync every Bonsai viewport decorator to its enabled state.
|
||||
|
||||
Wrapped in uninstall/install of the decorator-cache bump handlers so a
|
||||
decorator's own install path doesn't double-bind to depsgraph_update_post
|
||||
via ``TokenCache`` instances created during their own ``install()``."""
|
||||
georeference_props = tool.Georeference.get_georeference_props()
|
||||
aggregate_props = tool.Aggregate.get_aggregate_props()
|
||||
nest_props = tool.Nest.get_nest_props()
|
||||
@@ -405,23 +470,31 @@ def load_post(scene):
|
||||
NestDecorator.uninstall()
|
||||
WallAxisDecorator.uninstall()
|
||||
SlabDirectionDecorator.uninstall()
|
||||
if georeference_props.should_visualise:
|
||||
GeoreferenceDecorator.install(bpy.context)
|
||||
if aggregate_props.aggregate_decorator:
|
||||
AggregateDecorator.install(bpy.context)
|
||||
if nest_props.nest_decorator:
|
||||
NestDecorator.install(bpy.context)
|
||||
if model_props.show_wall_axis:
|
||||
WallAxisDecorator.install(bpy.context)
|
||||
if model_props.show_slab_direction:
|
||||
SlabDirectionDecorator.install(bpy.context)
|
||||
if model_props.show_bounding_box:
|
||||
BoundingBoxDecorator.install(bpy.context)
|
||||
WallFilletPreviewDecorator.uninstall()
|
||||
uninstall_decorator_cache_handlers()
|
||||
try:
|
||||
if georeference_props.should_visualise:
|
||||
GeoreferenceDecorator.install(bpy.context)
|
||||
if aggregate_props.aggregate_decorator:
|
||||
AggregateDecorator.install(bpy.context)
|
||||
if nest_props.nest_decorator:
|
||||
NestDecorator.install(bpy.context)
|
||||
if model_props.show_wall_axis:
|
||||
WallAxisDecorator.install(bpy.context)
|
||||
if model_props.show_slab_direction:
|
||||
SlabDirectionDecorator.install(bpy.context)
|
||||
if model_props.show_bounding_box:
|
||||
BoundingBoxDecorator.install(bpy.context)
|
||||
# Always-installed: draw() self-polls on Scene.BIMPreviewProperties.
|
||||
# wall_fillet.is_active, so installation has no cost when no preview
|
||||
# is open. No corresponding addon-preference toggle.
|
||||
WallFilletPreviewDecorator.install(bpy.context)
|
||||
finally:
|
||||
install_decorator_cache_handlers()
|
||||
|
||||
if preferences.should_use_snap and (scene := bpy.context.scene):
|
||||
# Snapping is off by default in Blender, but in BIM, it's more useful to be on
|
||||
scene.tool_settings.use_snap = True
|
||||
# Match default Bonsai snaps
|
||||
scene.tool_settings.snap_elements_base = {"EDGE", "EDGE_PERPENDICULAR", "VERTEX", "EDGE_MIDPOINT", "FACE"}
|
||||
|
||||
tool.Blender.sync_old_preferences()
|
||||
@persistent
|
||||
def load_post(scene):
|
||||
_apply_save_file_invariants(scene)
|
||||
_apply_user_preferences()
|
||||
_install_viewport_overlays()
|
||||
|
||||
@@ -64,6 +64,44 @@ class TransactionStep(TypedDict):
|
||||
operations: list[Operation]
|
||||
|
||||
|
||||
# Set when ``IfcStore.get_cache`` observes an external lock on the HDF5 cache —
|
||||
# signal that another Blender process has the same IFC file open. Project panel
|
||||
# polls ``is_cache_locked_by_other_process`` to warn the user. The dismissed
|
||||
# flag is sticky per-session so the warning doesn't re-nag once the user has
|
||||
# acknowledged it.
|
||||
_cache_locked_by_other_process: bool = False
|
||||
_multi_instance_warning_dismissed: bool = False
|
||||
|
||||
|
||||
def is_cache_locked_by_other_process() -> bool:
|
||||
return _cache_locked_by_other_process and not _multi_instance_warning_dismissed
|
||||
|
||||
|
||||
def dismiss_multi_instance_warning() -> None:
|
||||
global _multi_instance_warning_dismissed
|
||||
_multi_instance_warning_dismissed = True
|
||||
|
||||
|
||||
def get_cache_or_detect_lock() -> ifcopenshell.geom.serializers.hdf5 | None:
|
||||
"""Like ``IfcStore.get_cache`` but tracks the multi-instance lock flag — sets
|
||||
it on ``PermissionError``, clears it (along with the dismiss flag) when a
|
||||
subsequent call succeeds. Returns ``None`` on lock; other exceptions
|
||||
propagate. Callers that don't need the warning side effect can use
|
||||
``IfcStore.get_cache`` directly."""
|
||||
global _cache_locked_by_other_process, _multi_instance_warning_dismissed
|
||||
try:
|
||||
cache = IfcStore.get_cache()
|
||||
except PermissionError:
|
||||
_cache_locked_by_other_process = True
|
||||
return None
|
||||
if _cache_locked_by_other_process:
|
||||
# Lock released — clear both flags so a future re-locking re-surfaces
|
||||
# the warning rather than staying suppressed by the previous dismiss.
|
||||
_cache_locked_by_other_process = False
|
||||
_multi_instance_warning_dismissed = False
|
||||
return cache
|
||||
|
||||
|
||||
class IfcStore:
|
||||
path: str = ""
|
||||
"""Should be set only using ``tool.Ifc.set_path``."""
|
||||
@@ -196,7 +234,7 @@ class IfcStore:
|
||||
shutil.copy2(IfcStore.cache_path, new_cache_path)
|
||||
except PermissionError:
|
||||
pass # Well we tried. No cache for you!
|
||||
IfcStore.get_cache()
|
||||
get_cache_or_detect_lock()
|
||||
|
||||
@staticmethod
|
||||
def load_file(path: str) -> None:
|
||||
@@ -514,6 +552,7 @@ class IfcStore:
|
||||
BrickStore.end_transaction()
|
||||
IfcStore.end_transaction(operator)
|
||||
bonsai.bim.handler.refresh_ui_data()
|
||||
tool.Parametric.refresh_post_commit()
|
||||
|
||||
if method == "MODAL":
|
||||
cls.modal_in_progress = False
|
||||
|
||||
@@ -139,6 +139,7 @@ class BIMAggregateProperties(PropertyGroup):
|
||||
previous_editing_aggregate: PointerProperty(name="Editing Aggregate", type=bpy.types.Object)
|
||||
editing_objects: CollectionProperty(type=Objects)
|
||||
not_editing_objects: CollectionProperty(type=Objects)
|
||||
previously_selected_objects: CollectionProperty(type=Objects)
|
||||
aggregate_decorator: BoolProperty(
|
||||
name="Display Aggregate",
|
||||
default=False,
|
||||
@@ -155,5 +156,6 @@ class BIMAggregateProperties(PropertyGroup):
|
||||
previous_editing_aggregate: Union[bpy.types.Object, None]
|
||||
editing_objects: bpy.types.bpy_prop_collection_idprop[Objects]
|
||||
not_editing_objects: bpy.types.bpy_prop_collection_idprop[Objects]
|
||||
previously_selected_objects: bpy.types.bpy_prop_collection_idprop[Objects]
|
||||
aggregate_decorator: bool
|
||||
previous_state: bool
|
||||
|
||||
@@ -48,12 +48,14 @@ def draw_ui(context: bpy.types.Context, layout: bpy.types.UILayout, attributes)
|
||||
row = layout.row()
|
||||
op = row.operator("bim.enable_editing_attributes", icon="GREASEPENCIL", text="Edit")
|
||||
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
key_prefix = "type." if (element and element.is_a("IfcTypeObject")) else ""
|
||||
for attribute in attributes:
|
||||
row = layout.row(align=True)
|
||||
row.label(text=attribute["name"])
|
||||
value = bonsai.bim.helper.get_display_value(attribute["value"])
|
||||
op = row.operator("bim.select_similar", text=value, icon="NONE", emboss=False)
|
||||
op.key = attribute["name"]
|
||||
op.key = key_prefix + attribute["name"]
|
||||
|
||||
# TODO: reimplement, see #1222
|
||||
# if "IfcSite/" in context.active_object.name or "IfcBuilding/" in context.active_object.name:
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was modified with the assistance of an AI coding tool.
|
||||
|
||||
import bpy
|
||||
|
||||
@@ -136,14 +138,32 @@ classes = (
|
||||
gizmos.GizmoArrow2D,
|
||||
gizmos.GizmoCone,
|
||||
gizmos.GizmoDimension,
|
||||
gizmos.GizmoLock,
|
||||
gizmos.GizmoLockOpen,
|
||||
gizmos.GizmoLockClosed,
|
||||
gizmos.GizmoArc,
|
||||
gizmos.GizmoFillet,
|
||||
gizmos.GizmoWallCornerIcon,
|
||||
gizmos.GizmoWallTeeIcon,
|
||||
gizmos.GizmoPen,
|
||||
gizmos.GizmoValidate,
|
||||
gizmos.GizmoCancel,
|
||||
gizmos.GizmoPlus,
|
||||
gizmos.GizmoMinus,
|
||||
gizmos.GizmoTrash,
|
||||
gizmos.GizmoArrayParent,
|
||||
gizmos.GizmoArrayAll,
|
||||
gizmos.GizmoArrayLayerIndicator,
|
||||
gizmos.GizmoMerge,
|
||||
gizmos.GizmoSplit,
|
||||
gizmos.GizmoUnjoin,
|
||||
gizmos.GizmoExtend,
|
||||
gizmos.GizmoExtendVertical,
|
||||
gizmos.GizmoOffsetExterior,
|
||||
gizmos.GizmoOffsetCenter,
|
||||
gizmos.GizmoOffsetInterior,
|
||||
gizmos.GizmoAddOpening,
|
||||
gizmos.GizmoCycle,
|
||||
gizmos.GizmoMenu,
|
||||
# Drawing-specific gizmos
|
||||
gizmos.UglyDotGizmo,
|
||||
gizmos.ExtrusionGuidesGizmo,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -44,8 +44,12 @@ class ViewportData:
|
||||
|
||||
@classmethod
|
||||
def load(cls):
|
||||
cls.is_loaded = True
|
||||
# Populate data BEFORE flipping is_loaded so a raising ``mode()``
|
||||
# call doesn't leave the class half-loaded (flag set, dict empty).
|
||||
# Subsequent items-callback invocations skip load() on a True flag
|
||||
# and would hit ``cls.data["mode"]`` → KeyError.
|
||||
cls.data = {"mode": cls.mode()}
|
||||
cls.is_loaded = True
|
||||
|
||||
@classmethod
|
||||
def mode(cls) -> tool.Blender.BLENDER_ENUM_ITEMS:
|
||||
|
||||
@@ -60,6 +60,7 @@ import bonsai.core.root
|
||||
import bonsai.core.spatial
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
from bonsai.bim.module.model import preview_base
|
||||
from bonsai.bim.module.model.decorator import ProfileDecorator
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -1183,7 +1184,7 @@ class OverrideDuplicateMove(bpy.types.Operator):
|
||||
operator: bpy.types.Operator, context: bpy.types.Context, linked: bool = False
|
||||
) -> set["rna_enums.OperatorReturnItems"]:
|
||||
# Deep magick from the dawn of time
|
||||
if tool.Ifc.get():
|
||||
if tool.Ifc.get() and tool.Model.has_selected_ifc_objects(include_active=False):
|
||||
IfcStore.execute_ifc_operator(operator, context)
|
||||
return {"FINISHED"}
|
||||
|
||||
@@ -1287,6 +1288,11 @@ class OverrideDuplicateMove(bpy.types.Operator):
|
||||
if part_obj:
|
||||
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)
|
||||
)
|
||||
|
||||
# Deselect everything first
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
|
||||
@@ -2223,6 +2229,8 @@ class OverrideEscape(bpy.types.Operator):
|
||||
bpy.ops.bim.hide_all_openings()
|
||||
elif tool.Aggregate.get_aggregate_props().in_aggregate_mode:
|
||||
bpy.ops.bim.disable_aggregate_mode()
|
||||
elif preview_base.try_cancel_active_preview(context):
|
||||
pass
|
||||
elif active_object := context.active_object:
|
||||
if tool.Blender.Modifier.try_canceling_editing_modifier_parameters_or_path(active_object):
|
||||
pass
|
||||
@@ -2264,6 +2272,8 @@ class OverrideModeSetEdit(bpy.types.Operator, tool.Ifc.Operator):
|
||||
gprops = tool.Geometry.get_geometry_props()
|
||||
if gprops.representation_obj:
|
||||
tool.Geometry.disable_item_mode()
|
||||
if active_obj := bpy.context.active_object:
|
||||
active_obj.select_set(False)
|
||||
else:
|
||||
bonsai.core.aggregate.exit_aggregate_mode(tool.Aggregate)
|
||||
return {"FINISHED"}
|
||||
@@ -2350,6 +2360,7 @@ class OverrideModeSetEdit(bpy.types.Operator, tool.Ifc.Operator):
|
||||
and usage in ("LAYER1", "LAYER2")
|
||||
):
|
||||
self.report({"INFO"}, f"Parametric {usage} elements cannot be edited directly")
|
||||
obj.select_set(False)
|
||||
elif item.is_a("IfcSweptAreaSolid"):
|
||||
tool.Geometry.sync_item_positions()
|
||||
res = tool.Model.import_profile((profile := item.SweptArea), obj=obj)
|
||||
@@ -2358,6 +2369,7 @@ class OverrideModeSetEdit(bpy.types.Operator, tool.Ifc.Operator):
|
||||
{"INFO"},
|
||||
f"Couldn't import profile, editing it directly is not yet supported. Failing profile: {profile}.",
|
||||
)
|
||||
obj.select_set(False)
|
||||
return
|
||||
tool.Ifc.link(item, obj.data)
|
||||
self.enable_edit_mode(context)
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import bpy
|
||||
from bpy.types import Menu, Panel, UIList
|
||||
|
||||
import ifcopenshell.util.unit
|
||||
import bonsai.bim
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.helper import prop_with_search
|
||||
@@ -483,10 +484,32 @@ class BIM_PT_placement(Panel):
|
||||
row.label(text="No Object Placement Found")
|
||||
return
|
||||
|
||||
is_imperial = False
|
||||
if tool.Ifc.get():
|
||||
length_unit = ifcopenshell.util.unit.get_project_unit(tool.Ifc.get(), "LENGTHUNIT")
|
||||
if length_unit and length_unit.Name != "METRE":
|
||||
is_imperial = True
|
||||
|
||||
row = self.layout.row()
|
||||
row.prop(context.active_object, "location", text="Location")
|
||||
row.label(text="Location:")
|
||||
|
||||
if is_imperial:
|
||||
loc = context.active_object.location
|
||||
for i, (axis, comp) in enumerate(zip("XYZ", (loc.x, loc.y, loc.z))):
|
||||
split = self.layout.split(factor=0.6)
|
||||
split.prop(context.active_object, "location", index=i, text=axis)
|
||||
sub = split.row()
|
||||
sub.enabled = False
|
||||
sub.alignment = "LEFT"
|
||||
sub.label(text=tool.Unit.format_distance(comp))
|
||||
else:
|
||||
for i, axis in enumerate("XYZ"):
|
||||
self.layout.prop(context.active_object, "location", index=i, text=axis)
|
||||
|
||||
row = self.layout.row()
|
||||
row.prop(context.active_object, "rotation_euler", text="Rotation")
|
||||
row.label(text="Rotation:")
|
||||
for i, axis in enumerate("XYZ"):
|
||||
self.layout.prop(context.active_object, "rotation_euler", index=i, text=axis)
|
||||
|
||||
if props.blender_offset_type != "NONE":
|
||||
row = self.layout.row(align=True)
|
||||
|
||||
@@ -637,6 +637,16 @@ class EditAssignedMaterial(bpy.types.Operator, tool.Ifc.Operator):
|
||||
usage=material_set_usage,
|
||||
attributes=attributes,
|
||||
)
|
||||
|
||||
for obj in objects:
|
||||
obj_element = tool.Ifc.get_entity(obj)
|
||||
if not obj_element:
|
||||
continue
|
||||
obj_material_usage = ifcopenshell.util.element.get_material(obj_element)
|
||||
if obj_material_usage and obj_material_usage.is_a("IfcMaterialProfileSetUsage"):
|
||||
obj_material_usage.CardinalPoint = material_set_usage.CardinalPoint
|
||||
obj_material_usage.ReferenceExtent = material_set_usage.ReferenceExtent
|
||||
|
||||
model_profile.DumbProfileRecalculator().recalculate(objects)
|
||||
|
||||
bpy.ops.bim.disable_editing_assigned_material(obj=active_obj.name)
|
||||
|
||||
@@ -15,11 +15,15 @@
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was modified with the assistance of an AI coding tool.
|
||||
|
||||
from typing import NamedTuple
|
||||
|
||||
import bpy
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
from . import (
|
||||
array,
|
||||
covering,
|
||||
@@ -57,6 +61,8 @@ classes = (
|
||||
array.Input3DCursorXArray,
|
||||
array.Input3DCursorYArray,
|
||||
array.Input3DCursorZArray,
|
||||
array.EnableEditingParametric,
|
||||
array.AddArrayFromFeatureEdit,
|
||||
product.AddDefaultType,
|
||||
product.AddEmptyType,
|
||||
product.AddOccurrence,
|
||||
@@ -70,19 +76,43 @@ classes = (
|
||||
workspace.BIM_MT_add_representation_item,
|
||||
wall.AddWallsFromSlab,
|
||||
wall.AlignWall,
|
||||
wall.CancelEditingWall,
|
||||
wall.ChangeExtrusionDepth,
|
||||
wall.ChangeExtrusionXAngle,
|
||||
wall.ChangeLayerLength,
|
||||
wall.CycleWallOffset,
|
||||
wall.DrawPolylineWall,
|
||||
wall.EnableEditingWall,
|
||||
wall.ExtendWallHeightToCursor,
|
||||
wall.ExtendWallsToUnderside,
|
||||
wall.RegenerateWallToUnderside,
|
||||
wall.ExtendWallsToWall,
|
||||
wall.ExtendWallsToPolylinePoint,
|
||||
wall.ExtendWallToCursor,
|
||||
wall.FinishEditingWall,
|
||||
wall.FlipWall,
|
||||
wall.GizmoWallAddOpening,
|
||||
wall.GizmoWallEdition,
|
||||
wall.GizmoWallExtendVertically,
|
||||
wall.GizmoWallFilletPreview,
|
||||
wall.GizmoWallFilletReedit,
|
||||
wall.GizmoWallJoinIntersection,
|
||||
wall.GizmoWallUnjoinSingle,
|
||||
wall.JoinWallsIntersection,
|
||||
wall.MergeWall,
|
||||
wall.OffsetWalls,
|
||||
wall.RecalculateWall,
|
||||
wall.RotateWall90,
|
||||
wall.SplitWall,
|
||||
wall.SplitWallAtCursor,
|
||||
wall.ToggleWallOpenings,
|
||||
wall.UnjoinWallPathConnection,
|
||||
wall.UnjoinWalls,
|
||||
wall.EnableWallFilletPreview,
|
||||
wall.FinishWallFilletPreview,
|
||||
wall.CancelWallFilletPreview,
|
||||
wall.EnableWallFilletPreviewFromCorner,
|
||||
wall.CreateWallFillet,
|
||||
opening.AddBoolean,
|
||||
opening.CloneOpening,
|
||||
opening.EditOpenings,
|
||||
@@ -140,10 +170,14 @@ classes = (
|
||||
prop.BIMDoorProperties,
|
||||
prop.BIMRailingProperties,
|
||||
prop.BIMRoofProperties,
|
||||
prop.BIMWallProperties,
|
||||
prop.BIMPolylineProperties,
|
||||
prop.BIMExternalParametricGeometryProperties,
|
||||
prop.BIMWallFilletPreviewProperties,
|
||||
prop.BIMPreviewProperties,
|
||||
ui.BIM_PT_array,
|
||||
ui.BIM_PT_stair,
|
||||
ui.BIM_PT_wall,
|
||||
ui.BIM_PT_sverchok,
|
||||
ui.BIM_PT_window,
|
||||
ui.BIM_PT_door,
|
||||
@@ -264,15 +298,14 @@ def register():
|
||||
bpy.types.Scene.BIMModelProperties = bpy.props.PointerProperty(type=prop.BIMModelProperties)
|
||||
bpy.types.Scene.BIMPolylineProperties = bpy.props.PointerProperty(type=prop.BIMPolylineProperties)
|
||||
bpy.types.Object.BIMArrayProperties = bpy.props.PointerProperty(type=prop.BIMArrayProperties)
|
||||
bpy.types.Object.BIMStairProperties = bpy.props.PointerProperty(type=prop.BIMStairProperties)
|
||||
bpy.types.Object.BIMSverchokProperties = bpy.props.PointerProperty(type=prop.BIMSverchokProperties)
|
||||
bpy.types.Object.BIMWindowProperties = bpy.props.PointerProperty(type=prop.BIMWindowProperties)
|
||||
bpy.types.Object.BIMDoorProperties = bpy.props.PointerProperty(type=prop.BIMDoorProperties)
|
||||
bpy.types.Object.BIMRailingProperties = bpy.props.PointerProperty(type=prop.BIMRailingProperties)
|
||||
bpy.types.Object.BIMRoofProperties = bpy.props.PointerProperty(type=prop.BIMRoofProperties)
|
||||
# Per-parametric-type ``BIM<Name>Properties`` PointerProperties — driven by
|
||||
# ``tool.Parametric.EDIT_TYPES``; adding a registry entry is the single touchpoint.
|
||||
tool.Parametric.register_object_properties(prop)
|
||||
bpy.types.Object.BIMExternalParametricGeometryProperties = bpy.props.PointerProperty(
|
||||
type=prop.BIMExternalParametricGeometryProperties
|
||||
)
|
||||
bpy.types.Scene.BIMPreviewProperties = bpy.props.PointerProperty(type=prop.BIMPreviewProperties)
|
||||
|
||||
bpy.types.VIEW3D_MT_add.prepend(ui.add_menu)
|
||||
bpy.app.handlers.load_post.append(handler.load_post)
|
||||
@@ -281,6 +314,12 @@ def register():
|
||||
|
||||
|
||||
def unregister():
|
||||
# DecorationsHandler is installed lazily by bim.show_openings; tear it down
|
||||
# (along with its persistent depsgraph / undo / redo / load cache handlers)
|
||||
# before the rest of unregister so those handlers can't fire against
|
||||
# half-unloaded module state.
|
||||
opening.DecorationsHandler.uninstall()
|
||||
|
||||
if not bpy.app.background:
|
||||
for tool_data in reversed(tools):
|
||||
bpy.utils.unregister_tool(tool_data.tool)
|
||||
@@ -288,13 +327,10 @@ def unregister():
|
||||
del bpy.types.Scene.BIMModelProperties
|
||||
del bpy.types.Scene.BIMPolylineProperties
|
||||
del bpy.types.Object.BIMArrayProperties
|
||||
del bpy.types.Object.BIMStairProperties
|
||||
del bpy.types.Object.BIMSverchokProperties
|
||||
del bpy.types.Object.BIMWindowProperties
|
||||
del bpy.types.Object.BIMDoorProperties
|
||||
del bpy.types.Object.BIMRailingProperties
|
||||
del bpy.types.Object.BIMRoofProperties
|
||||
tool.Parametric.unregister_object_properties()
|
||||
del bpy.types.Object.BIMExternalParametricGeometryProperties
|
||||
del bpy.types.Scene.BIMPreviewProperties
|
||||
|
||||
bpy.app.handlers.load_post.remove(handler.load_post)
|
||||
bpy.types.VIEW3D_MT_add.remove(ui.add_menu)
|
||||
|
||||
@@ -379,3 +379,129 @@ class Input3DCursorZArray(bpy.types.Operator):
|
||||
else:
|
||||
props.z = cursor.location.z - obj.location.z
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class EnableEditingParametric(bpy.types.Operator):
|
||||
"""Pen-icon dispatcher: fires the gizmo group's per-feature edit operator.
|
||||
|
||||
Bound to every parametric gizmo group's pen icon. The gizmo group's own
|
||||
``enable_editing_operator`` (``bim.enable_editing_door``, ``…_wall``, …)
|
||||
is passed as ``feature_enable_op`` at setup time and invoked here. The
|
||||
indirection lets one gizmo class serve all features without per-feature
|
||||
subclasses."""
|
||||
|
||||
bl_idname = "bim.enable_editing_parametric"
|
||||
bl_label = "Enable Editing"
|
||||
bl_description = "Edit this object's parameters"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
feature_enable_op: bpy.props.StringProperty(
|
||||
default="",
|
||||
description="Operator bl_idname to invoke (e.g., 'bim.enable_editing_door').",
|
||||
)
|
||||
|
||||
def execute(self, context):
|
||||
# Malformed ``feature_enable_op`` (missing dot) would otherwise crash
|
||||
# the unpack with ValueError; treat the same as the empty-string case.
|
||||
parts = self.feature_enable_op.split(".", 1)
|
||||
if len(parts) != 2:
|
||||
return {"CANCELLED"}
|
||||
domain, opname = parts
|
||||
return getattr(getattr(bpy.ops, domain), opname)("INVOKE_DEFAULT")
|
||||
|
||||
|
||||
class AddArrayFromFeatureEdit(bpy.types.Operator, tool.Ifc.Operator):
|
||||
"""Commit any in-progress feature edit and add an array with
|
||||
gizmo-friendly defaults (count=2, offset = bbox extent along the axis).
|
||||
|
||||
Modifier-aware: plain click → X, Shift → Y, Ctrl → Z. Callers can pass
|
||||
``axis="X"`` via EXEC_DEFAULT to bypass the modifier read.
|
||||
|
||||
All three chained operators (feature finish + add_array + enable_editing)
|
||||
run inside one transaction for a single undo step."""
|
||||
|
||||
bl_idname = "bim.add_array_from_feature_edit"
|
||||
bl_label = "Add Array"
|
||||
bl_description = (
|
||||
"Click: add an array along X.\n" "Shift+Click: add an array along Y.\n" "Ctrl+Click: add an array along Z"
|
||||
)
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
axis: bpy.props.EnumProperty(
|
||||
name="Offset Axis",
|
||||
items=[
|
||||
("X", "X", "Offset along the object's X axis (bbox X extent)"),
|
||||
("Y", "Y", "Offset along the object's Y axis (bbox Y extent)"),
|
||||
("Z", "Z", "Offset along the object's Z axis (bbox Z extent)"),
|
||||
],
|
||||
default="X",
|
||||
)
|
||||
|
||||
# Minimum offset to use when the object's bbox extent is tiny — prevents
|
||||
# the second instance from visually overlapping the parent on small
|
||||
# annotations / openings (0.3m ≈ a clearly-separated next-instance distance).
|
||||
MIN_DEFAULT_OFFSET = 0.3
|
||||
|
||||
def invoke(self, context, event):
|
||||
# Modifier-aware axis pick: X by default, Shift → Y, Ctrl → Z.
|
||||
if event.shift:
|
||||
self.axis = "Y"
|
||||
elif event.ctrl:
|
||||
self.axis = "Z"
|
||||
else:
|
||||
self.axis = "X"
|
||||
return self.execute(context)
|
||||
|
||||
def _execute(self, context):
|
||||
obj = context.active_object
|
||||
if obj is None:
|
||||
return {"CANCELLED"}
|
||||
# Commit any in-progress parametric edit lifecycle on this object first — the
|
||||
# user expects "Add Array" to also finalise whatever they were editing
|
||||
# so they don't lose their draft changes.
|
||||
editing = tool.Parametric.is_object_editing(obj, skip_name="array")
|
||||
if editing is not None:
|
||||
finish_op_name = editing.finish_op.removeprefix("bim.")
|
||||
getattr(bpy.ops.bim, finish_op_name)("INVOKE_DEFAULT")
|
||||
# Bounding-box derived offset along the chosen axis, converted from
|
||||
# Blender SI (meters) to IFC project units (which is what
|
||||
# ``BBIM_Array.Data`` stores; the regenerator multiplies by
|
||||
# unit_scale on the way out).
|
||||
axis_idx = "XYZ".index(self.axis)
|
||||
if obj.bound_box:
|
||||
bbox_extent_si = max(c[axis_idx] for c in obj.bound_box) - min(c[axis_idx] for c in obj.bound_box)
|
||||
else:
|
||||
bbox_extent_si = 1.0
|
||||
bbox_extent_si = max(bbox_extent_si, self.MIN_DEFAULT_OFFSET)
|
||||
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
offset_project = bbox_extent_si / si_conversion if si_conversion else bbox_extent_si
|
||||
add_kwargs = {"count": 2, "x": 0.0, "y": 0.0, "z": 0.0}
|
||||
add_kwargs[self.axis.lower()] = offset_project
|
||||
result = bpy.ops.bim.add_array(**add_kwargs)
|
||||
if result != {"FINISHED"}:
|
||||
return result
|
||||
# Restore selection to just the parent. ``regenerate_array`` calls
|
||||
# ``tool.Geometry.duplicate_ifc_objects`` which leaves the newly-created
|
||||
# child selected alongside the parent. The edit-lifecycle gizmos poll on a
|
||||
# single-selected parent, so with both selected the gizmos wouldn't
|
||||
# surface and "ARRAY → enter edit" would feel broken.
|
||||
tool.Blender.select_and_activate_single_object(context, active_object=obj)
|
||||
# Chain straight into array edit for the newly-added layer (always the
|
||||
# last entry in the pset's Data list, by AddArray's append semantics).
|
||||
# The user's expectation after clicking ARRAY is "I want to tweak this
|
||||
# array now" — entering edit mode immediately collapses the 2-click
|
||||
# discover-then-edit flow into one.
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if element is None:
|
||||
return {"FINISHED"}
|
||||
data_text = ifcopenshell.util.element.get_pset(element, "BBIM_Array", "Data")
|
||||
if not data_text:
|
||||
return {"FINISHED"}
|
||||
try:
|
||||
layers = json.loads(data_text)
|
||||
except (ValueError, TypeError):
|
||||
return {"FINISHED"}
|
||||
if not layers:
|
||||
return {"FINISHED"}
|
||||
bpy.ops.bim.enable_editing_array("INVOKE_DEFAULT", item=len(layers) - 1)
|
||||
return {"FINISHED"}
|
||||
|
||||
@@ -108,7 +108,7 @@ class ProfileDecorator:
|
||||
|
||||
obj = context.active_object
|
||||
|
||||
if obj.mode != "EDIT":
|
||||
if obj is None or obj.mode != "EDIT":
|
||||
if exit_edit_mode_callback:
|
||||
ProfileDecorator.uninstall()
|
||||
exit_edit_mode_callback()
|
||||
@@ -2029,3 +2029,151 @@ class BoundingBoxDecorator:
|
||||
else:
|
||||
co1.y += y_overlap / 2 + min_spacing
|
||||
co2.y -= y_overlap / 2 + min_spacing
|
||||
|
||||
|
||||
def _stroke_lines_alpha(
|
||||
context: bpy.types.Context,
|
||||
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]],
|
||||
color_rgb: tuple[float, float, float],
|
||||
line_width: float,
|
||||
line_alpha: float,
|
||||
) -> None:
|
||||
"""Render ``segments`` (a list of ``(start, end)`` tuples) as one
|
||||
anti-aliased LINES batch in world space. Early-returns when
|
||||
``context.region`` is unavailable (e.g. when called from a
|
||||
``_RestrictContext``)."""
|
||||
if not segments:
|
||||
return
|
||||
verts: list[tuple[float, float, float]] = []
|
||||
indices: list[tuple[int, int]] = []
|
||||
for start, end in segments:
|
||||
base = len(verts)
|
||||
verts.append(tuple(start))
|
||||
verts.append(tuple(end))
|
||||
indices.append((base, base + 1))
|
||||
if not tool.Blender.validate_shader_batch_data(verts, indices):
|
||||
return
|
||||
region = getattr(context, "region", None)
|
||||
if region is None:
|
||||
return
|
||||
shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR")
|
||||
shader.bind()
|
||||
shader.uniform_float("viewportSize", (region.width, region.height))
|
||||
shader.uniform_float("lineWidth", line_width)
|
||||
shader.uniform_float("color", (*color_rgb, line_alpha))
|
||||
batch = batch_for_shader(shader, "LINES", {"pos": verts}, indices=indices)
|
||||
gpu.state.blend_set("ALPHA")
|
||||
batch.draw(shader)
|
||||
gpu.state.blend_set("NONE")
|
||||
|
||||
|
||||
class WallFilletPreviewDecorator(tool.Blender.ViewportDecorator):
|
||||
"""GPU preview lines for the wall-fillet flow.
|
||||
|
||||
Polls on ``scene.BIMPreviewProperties.wall_fillet.is_active`` and renders
|
||||
the leg projections + arc + radial construction lines returned by
|
||||
``tool.Wall.compute_wall_fillet_geometry``. The two leg lines show how
|
||||
each wall will be shortened to its tangent point; the arc approximates
|
||||
the rounded corner; the two construction lines (arc center to each
|
||||
tangent point) visually pin the radius.
|
||||
|
||||
Installed once per Blender session from ``bim/handler.py:load_post``
|
||||
and uninstalled in ``bim/module/model/__init__.py:unregister``."""
|
||||
|
||||
LINE_WIDTH_LEG = 1.5
|
||||
LINE_WIDTH_ARC = 2.5
|
||||
LINE_WIDTH_CONSTRUCTION = 1.0
|
||||
LINE_ALPHA = 0.7
|
||||
CONSTRUCTION_ALPHA = 0.4
|
||||
|
||||
def draw(self, context: bpy.types.Context) -> None:
|
||||
scene = context.scene
|
||||
preview_props = getattr(scene, "BIMPreviewProperties", None)
|
||||
props = preview_props.wall_fillet if preview_props is not None else None
|
||||
if props is None or not props.is_active:
|
||||
return
|
||||
ifc_file = tool.Ifc.get()
|
||||
if ifc_file is None:
|
||||
return
|
||||
try:
|
||||
wall_a = ifc_file.by_id(props.wall_a_id)
|
||||
wall_b = ifc_file.by_id(props.wall_b_id)
|
||||
except Exception:
|
||||
return
|
||||
wall_a_obj = tool.Ifc.get_object(wall_a) if wall_a else None
|
||||
wall_b_obj = tool.Ifc.get_object(wall_b) if wall_b else None
|
||||
if wall_a_obj is None or wall_b_obj is None:
|
||||
return
|
||||
|
||||
geom = tool.Wall.compute_wall_fillet_geometry(wall_a_obj, wall_b_obj, props.radius)
|
||||
if geom is None:
|
||||
return
|
||||
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
warning_color = tuple(prefs.decorator_color_error[:3])
|
||||
|
||||
if not geom["valid"]:
|
||||
# Degenerate geometry paints red: invalid_radius shows legs+arc
|
||||
# past the wall ends; invalid_axes shows the parallel/collinear
|
||||
# axes.
|
||||
if geom.get("invalid_radius"):
|
||||
tangent_a = geom.get("tangent_a")
|
||||
tangent_b = geom.get("tangent_b")
|
||||
ref_a = tool.Wall.get_world_reference_line(wall_a_obj)
|
||||
ref_b = tool.Wall.get_world_reference_line(wall_b_obj)
|
||||
if tangent_a is not None and tangent_b is not None and ref_a is not None and ref_b is not None:
|
||||
far_a = self._far_endpoint(ref_a, geom["intersection"])
|
||||
far_b = self._far_endpoint(ref_b, geom["intersection"])
|
||||
legs = [
|
||||
(tuple(far_a), tuple(tangent_a)),
|
||||
(tuple(far_b), tuple(tangent_b)),
|
||||
]
|
||||
_stroke_lines_alpha(context, legs, warning_color, self.LINE_WIDTH_LEG, self.LINE_ALPHA)
|
||||
arc = geom.get("arc") or []
|
||||
if len(arc) >= 2:
|
||||
arc_segments = [(tuple(arc[i]), tuple(arc[i + 1])) for i in range(len(arc) - 1)]
|
||||
_stroke_lines_alpha(context, arc_segments, warning_color, self.LINE_WIDTH_ARC, self.LINE_ALPHA)
|
||||
elif geom.get("invalid_axes"):
|
||||
axes = geom["invalid_axes"]
|
||||
segments = [(tuple(a), tuple(b)) for a, b in axes]
|
||||
_stroke_lines_alpha(context, segments, warning_color, self.LINE_WIDTH_ARC, self.LINE_ALPHA)
|
||||
return
|
||||
|
||||
leg_color = tuple(prefs.decorations_colour[:3])
|
||||
arc_color = tuple(prefs.decorator_color_selected[:3])
|
||||
|
||||
# Resolved against the IFC reference line, not mesh bounds, so trimmed
|
||||
# walls and openings don't shift the leg endpoints.
|
||||
ref_a = tool.Wall.get_world_reference_line(wall_a_obj)
|
||||
ref_b = tool.Wall.get_world_reference_line(wall_b_obj)
|
||||
if ref_a is not None and ref_b is not None and geom["intersection"] is not None:
|
||||
far_a = self._far_endpoint(ref_a, geom["intersection"])
|
||||
far_b = self._far_endpoint(ref_b, geom["intersection"])
|
||||
legs = [
|
||||
(tuple(far_a), tuple(geom["tangent_a"])),
|
||||
(tuple(far_b), tuple(geom["tangent_b"])),
|
||||
]
|
||||
_stroke_lines_alpha(context, legs, leg_color, self.LINE_WIDTH_LEG, self.LINE_ALPHA)
|
||||
|
||||
arc = geom["arc"]
|
||||
if len(arc) >= 2:
|
||||
arc_segments = [(tuple(arc[i]), tuple(arc[i + 1])) for i in range(len(arc) - 1)]
|
||||
_stroke_lines_alpha(context, arc_segments, arc_color, self.LINE_WIDTH_ARC, self.LINE_ALPHA)
|
||||
|
||||
# Dim construction lines from arc_center to each tangent point so
|
||||
# the radius reads as concrete during drag.
|
||||
arc_center = geom.get("arc_center")
|
||||
if arc_center is not None:
|
||||
construction = [
|
||||
(tuple(arc_center), tuple(geom["tangent_a"])),
|
||||
(tuple(arc_center), tuple(geom["tangent_b"])),
|
||||
]
|
||||
_stroke_lines_alpha(context, construction, arc_color, self.LINE_WIDTH_CONSTRUCTION, self.CONSTRUCTION_ALPHA)
|
||||
|
||||
@staticmethod
|
||||
def _far_endpoint(reference_line, intersection):
|
||||
"""Endpoint of ``reference_line`` furthest from ``intersection``."""
|
||||
p1, p2 = reference_line
|
||||
d1 = (p1.x - intersection[0]) ** 2 + (p1.y - intersection[1]) ** 2 + (p1.z - intersection[2]) ** 2
|
||||
d2 = (p2.x - intersection[0]) ** 2 + (p2.y - intersection[1]) ** 2 + (p2.z - intersection[2]) ** 2
|
||||
return p2 if d2 >= d1 else p1
|
||||
|
||||
@@ -38,6 +38,7 @@ 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.window import create_bm_box, create_bm_window
|
||||
from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bonsai.bim.module.model.prop import BIMDoorProperties
|
||||
@@ -566,103 +567,58 @@ class AddDoor(bpy.types.Operator, tool.Ifc.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class CancelEditingDoor(bpy.types.Operator, tool.Ifc.Operator):
|
||||
class _DoorEditMixin(FeatureModifierEditMixin):
|
||||
"""Type-specific hooks for door parametric-edit operators. Multi-object —
|
||||
iterates ``tool.Blender.get_selected_objects()`` so a finish/cancel applies
|
||||
to every selected door at once."""
|
||||
|
||||
pset_name = "BBIM_Door"
|
||||
|
||||
@classmethod
|
||||
def _iter_targets(cls, context: bpy.types.Context) -> list[bpy.types.Object]:
|
||||
return tool.Blender.get_selected_objects()
|
||||
|
||||
@classmethod
|
||||
def _is_element_type(cls, element):
|
||||
return tool.Blender.Modifier.is_door(element)
|
||||
|
||||
@classmethod
|
||||
def _get_props(cls, obj: bpy.types.Object):
|
||||
return tool.Model.get_door_props(obj)
|
||||
|
||||
@classmethod
|
||||
def _update_modifier_representation(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
|
||||
update_door_modifier_representation(obj)
|
||||
|
||||
|
||||
class CancelEditingDoor(_DoorEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.cancel_editing_door"
|
||||
bl_label = "Cancel Editing Door on Selected Objects"
|
||||
bl_description = "Cancel editing and revert door parameters to their previous values"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def cancel_editing_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):
|
||||
return
|
||||
props = tool.Model.get_door_props(obj)
|
||||
data = json.loads(ifcopenshell.util.element.get_pset(element, "BBIM_Door", "Data"))
|
||||
data.update(data.pop("lining_properties"))
|
||||
data.update(data.pop("panel_properties"))
|
||||
|
||||
# restore previous settings since editing was canceled
|
||||
props.set_props_kwargs_from_ifc_data(data)
|
||||
|
||||
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
|
||||
core.switch_representation(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
obj=obj,
|
||||
representation=body,
|
||||
)
|
||||
|
||||
props.is_editing = False
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]: # noqa: ARG002
|
||||
for obj in tool.Blender.get_selected_objects():
|
||||
self.cancel_editing_door_on_object(obj)
|
||||
return {"FINISHED"}
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
return self._cancel_targets(context)
|
||||
|
||||
|
||||
class FinishEditingDoor(bpy.types.Operator, tool.Ifc.Operator):
|
||||
class FinishEditingDoor(_DoorEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.finish_editing_door"
|
||||
bl_label = "Finish Editing Door on Selected Objects"
|
||||
bl_description = "Apply changes and finish editing door parameters"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def finish_editing_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):
|
||||
return
|
||||
props = tool.Model.get_door_props(obj)
|
||||
|
||||
door_data = props.get_general_kwargs(convert_to_project_units=True)
|
||||
lining_props = props.get_lining_kwargs(convert_to_project_units=True)
|
||||
panel_props = props.get_panel_kwargs(convert_to_project_units=True)
|
||||
|
||||
door_data["lining_properties"] = lining_props
|
||||
door_data["panel_properties"] = panel_props
|
||||
|
||||
props.is_editing = False
|
||||
|
||||
update_door_modifier_representation(obj)
|
||||
element_type = ifcopenshell.util.element.get_type(element)
|
||||
if element_type:
|
||||
tool.Model.mark_thumbnail_for_update(element_type)
|
||||
|
||||
pset = tool.Pset.get_element_pset(element, "BBIM_Door")
|
||||
door_data = tool.Ifc.get().createIfcText(json.dumps(door_data, default=list))
|
||||
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": door_data})
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]: # noqa: ARG002
|
||||
for obj in tool.Blender.get_selected_objects():
|
||||
self.finish_editing_door_on_object(obj)
|
||||
return {"FINISHED"}
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
return self._finish_targets(context)
|
||||
|
||||
|
||||
class EnableEditingDoor(bpy.types.Operator, tool.Ifc.Operator):
|
||||
class EnableEditingDoor(_DoorEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.enable_editing_door"
|
||||
bl_label = "Enable Editing Door on Selected Objects"
|
||||
bl_description = "Enter edit mode to modify door parameters interactively"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def edit_door_on_obj(self, obj: bpy.types.Object) -> None:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
assert element
|
||||
if not tool.Blender.Modifier.is_door(element):
|
||||
return
|
||||
props = tool.Model.get_door_props(obj)
|
||||
data = json.loads(ifcopenshell.util.element.get_pset(element, "BBIM_Door", "Data"))
|
||||
data.update(data.pop("lining_properties"))
|
||||
data.update(data.pop("panel_properties"))
|
||||
data.update(tool.Model.get_constituents_props_data(element))
|
||||
|
||||
# required since we could load pset from .ifc and BIMDoorProperties won't be set
|
||||
props.set_props_kwargs_from_ifc_data(data)
|
||||
props.is_editing = True
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]: # noqa: ARG002
|
||||
for obj in tool.Blender.get_selected_objects():
|
||||
self.edit_door_on_obj(obj)
|
||||
return {"FINISHED"}
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
return self._enable_targets(context)
|
||||
|
||||
|
||||
class RemoveDoor(bpy.types.Operator, tool.Ifc.Operator):
|
||||
@@ -751,8 +707,8 @@ class CycleDoorType(bpy.types.Operator, tool.Ifc.Operator, gizmo.CycleTypeMixin)
|
||||
bl_label = "Cycle Door Type"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
element_checker = "is_door"
|
||||
props_getter = "get_door_props"
|
||||
element_checker = tool.Parametric.is_door
|
||||
props_getter = tool.Model.get_door_props
|
||||
type_literal = tool.Model.DoorType
|
||||
type_attr = "door_type"
|
||||
|
||||
@@ -879,7 +835,7 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
),
|
||||
]
|
||||
|
||||
props_getter = "get_door_props"
|
||||
props_getter = tool.Model.get_door_props
|
||||
gizmo_pref_name = "door"
|
||||
|
||||
@classmethod
|
||||
@@ -910,13 +866,11 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
self.gizmo_door_type = self.create_arc_gizmo(
|
||||
special_color,
|
||||
"bim.toggle_door_swing",
|
||||
prop_path="BIMDoorProperties.door_type",
|
||||
flip_geometry=False,
|
||||
)
|
||||
self.gizmo_flip_arc = self.create_arc_gizmo(
|
||||
inactive_color,
|
||||
"bim.toggle_door_swing",
|
||||
prop_path="BIMDoorProperties.door_type",
|
||||
flip_geometry=True,
|
||||
flip_local_axes="XY",
|
||||
)
|
||||
@@ -939,7 +893,7 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
|
||||
def update_swing_gizmos(self, mw: Matrix, props: "BIMDoorProperties") -> None:
|
||||
"""Update swing gizmo position and color based on editing state."""
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
prefs = self.get_addon_prefs()
|
||||
door_gizmo_prefs = prefs.gizmos.door
|
||||
|
||||
door_type_visible = self.update_gizmo_visibility(
|
||||
|
||||
@@ -41,8 +41,187 @@ from mathutils import Matrix, Vector
|
||||
|
||||
import bonsai.core.geometry
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim import decorator_cache
|
||||
from bonsai.bim.module.drawing.decoration import DecoratorData
|
||||
|
||||
# Multi-entry cache for the opening preview's dissolved-edges fallback.
|
||||
# Single-entry wouldn't fit: the draw handler iterates every active opening
|
||||
# per frame, each with its own mesh. Bumped wholesale on the shared
|
||||
# decorator-cache token (depsgraph / undo / redo / load), one slot per
|
||||
# (mesh.session_uid, angle_limit). Outlier vs. the per-object caches below —
|
||||
# consulted only on world-draw-data miss, so the global wipe rarely fires in
|
||||
# steady state and the simpler invalidation is enough.
|
||||
_dissolved_edges_cache: dict[
|
||||
tuple[int, float],
|
||||
tuple[list[Vector], list[tuple[int, int]]],
|
||||
] = {}
|
||||
_dissolved_edges_cache_token: int = -1
|
||||
|
||||
|
||||
def _get_cached_dissolved_edges(
|
||||
mesh: bpy.types.Mesh,
|
||||
angle_limit: float = radians(1.0),
|
||||
) -> tuple[list[Vector], list[tuple[int, int]]]:
|
||||
global _dissolved_edges_cache_token
|
||||
token = decorator_cache.get_decorator_cache_token()
|
||||
if token != _dissolved_edges_cache_token:
|
||||
_dissolved_edges_cache.clear()
|
||||
_dissolved_edges_cache_token = token
|
||||
key = (mesh.session_uid, angle_limit)
|
||||
cached = _dissolved_edges_cache.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
result = tool.Geometry.get_dissolved_edges(mesh, angle_limit=angle_limit)
|
||||
_dissolved_edges_cache[key] = result
|
||||
return result
|
||||
|
||||
|
||||
# Per-object epoch: bumped only when this specific object's transform or geometry
|
||||
# updates land in the depsgraph delta. Invalidation work scales with the number
|
||||
# of changed objects, not total scene size — moving one object leaves every
|
||||
# other entry valid. Bumped by the depsgraph handler below; cleared on
|
||||
# undo/redo/load alongside the cache dicts.
|
||||
_object_epochs: dict[int, int] = {}
|
||||
|
||||
|
||||
@bpy.app.handlers.persistent
|
||||
def _bump_object_epochs_for_decoration(*args) -> None:
|
||||
# depsgraph_update_post is called as (scene, depsgraph) in 4.x but the
|
||||
# *args signature follows decorator_cache's defensive idiom.
|
||||
depsgraph = args[1] if len(args) >= 2 else None
|
||||
if depsgraph is None or not hasattr(depsgraph, "updates"):
|
||||
return
|
||||
for u in depsgraph.updates:
|
||||
if not isinstance(u.id, bpy.types.Object):
|
||||
continue
|
||||
if not (u.is_updated_geometry or u.is_updated_transform):
|
||||
continue
|
||||
# u.id is the evaluated COW copy; the cache keys are written from the
|
||||
# original Object (read by the draw handler), and session_uid can
|
||||
# differ across the COW boundary. Resolve to the original before keying.
|
||||
original = getattr(u.id, "original", u.id)
|
||||
if original is None:
|
||||
continue
|
||||
uid = original.session_uid
|
||||
_object_epochs[uid] = _object_epochs.get(uid, 0) + 1
|
||||
|
||||
|
||||
@bpy.app.handlers.persistent
|
||||
def _clear_decoration_caches_globally(*args) -> None:
|
||||
# Undo/redo/load: depsgraph deltas can't be trusted to describe the
|
||||
# transition, so wipe every per-object cache state.
|
||||
_object_epochs.clear()
|
||||
_world_draw_data_cache.clear()
|
||||
_batch_cache.clear()
|
||||
|
||||
|
||||
def _decoration_invalidation_hooks() -> tuple:
|
||||
return (
|
||||
bpy.app.handlers.undo_post,
|
||||
bpy.app.handlers.redo_post,
|
||||
bpy.app.handlers.load_post,
|
||||
)
|
||||
|
||||
|
||||
def install_decoration_cache_handlers() -> None:
|
||||
if _bump_object_epochs_for_decoration not in bpy.app.handlers.depsgraph_update_post:
|
||||
bpy.app.handlers.depsgraph_update_post.append(_bump_object_epochs_for_decoration)
|
||||
for hook in _decoration_invalidation_hooks():
|
||||
if _clear_decoration_caches_globally not in hook:
|
||||
hook.append(_clear_decoration_caches_globally)
|
||||
|
||||
|
||||
def uninstall_decoration_cache_handlers() -> None:
|
||||
try:
|
||||
bpy.app.handlers.depsgraph_update_post.remove(_bump_object_epochs_for_decoration)
|
||||
except ValueError:
|
||||
pass
|
||||
for hook in _decoration_invalidation_hooks():
|
||||
try:
|
||||
hook.remove(_clear_decoration_caches_globally)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
# Per-object world-space draw payload: line_verts (dissolved or ios_edges-filtered),
|
||||
# verts (full mesh, indexed by loop_triangles), edges_indices, tris. Entries are
|
||||
# (epoch, payload) tuples; lookup compares epoch to _object_epochs[uid], so a
|
||||
# stale entry for an object that didn't change since the last build still hits.
|
||||
_world_draw_data_cache: dict[
|
||||
int,
|
||||
tuple[
|
||||
int,
|
||||
tuple[
|
||||
list[tuple[float, float, float]],
|
||||
list[tuple[float, float, float]],
|
||||
list[tuple[int, int]],
|
||||
list[tuple[int, ...]],
|
||||
],
|
||||
],
|
||||
] = {}
|
||||
|
||||
|
||||
def _get_cached_world_draw_data(
|
||||
obj: bpy.types.Object,
|
||||
) -> tuple[
|
||||
list[tuple[float, float, float]],
|
||||
list[tuple[float, float, float]],
|
||||
list[tuple[int, int]],
|
||||
list[tuple[int, ...]],
|
||||
]:
|
||||
uid = obj.session_uid
|
||||
epoch = _object_epochs.get(uid, 0)
|
||||
entry = _world_draw_data_cache.get(uid)
|
||||
if entry is not None and entry[0] == epoch:
|
||||
return entry[1]
|
||||
|
||||
mw = obj.matrix_world
|
||||
verts = [tuple(mw @ v.co) for v in obj.data.vertices]
|
||||
obj.data.calc_loop_triangles()
|
||||
tris = [tuple(t.vertices) for t in obj.data.loop_triangles]
|
||||
|
||||
ios_edges_attribute = obj.data.attributes.get("ios_edges")
|
||||
if ios_edges_attribute:
|
||||
# Loader-curated edges: read the attribute aligned with bm.edges order.
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(obj.data)
|
||||
edges_indices = [
|
||||
tuple(v.index for v in e.verts) for i, e in enumerate(bm.edges) if ios_edges_attribute.data[i].value
|
||||
]
|
||||
bm.free()
|
||||
line_verts = verts
|
||||
else:
|
||||
dissolved, edges_indices = _get_cached_dissolved_edges(obj.data)
|
||||
line_verts = [tuple(mw @ v) for v in dissolved]
|
||||
|
||||
result = (line_verts, verts, edges_indices, tris)
|
||||
_world_draw_data_cache[uid] = (epoch, result)
|
||||
return result
|
||||
|
||||
|
||||
# GPUBatch cache: skip per-frame batch_for_shader. Entries are (epoch, batch);
|
||||
# lookup compares epoch to _object_epochs[uid] so other objects' batches stay
|
||||
# alive when one object's depsgraph delta bumps only its own epoch. The cached
|
||||
# batches reference GPU-side buffers tied to Blender's built-in shaders, which
|
||||
# are themselves cached by name (gpu.shader.from_builtin returns the same
|
||||
# handle each call), so they stay drawable across frames.
|
||||
_batch_cache: dict[tuple[int, str], tuple[int, "gpu.types.GPUBatch"]] = {}
|
||||
|
||||
|
||||
def _get_cached_batch_or_none(cache_key: tuple[int, str]) -> "gpu.types.GPUBatch | None":
|
||||
uid = cache_key[0]
|
||||
epoch = _object_epochs.get(uid, 0)
|
||||
entry = _batch_cache.get(cache_key)
|
||||
if entry is not None and entry[0] == epoch:
|
||||
return entry[1]
|
||||
return None
|
||||
|
||||
|
||||
def _store_batch_in_cache(cache_key: tuple[int, str], batch: "gpu.types.GPUBatch") -> None:
|
||||
uid = cache_key[0]
|
||||
epoch = _object_epochs.get(uid, 0)
|
||||
_batch_cache[cache_key] = (epoch, batch)
|
||||
|
||||
|
||||
class FilledOpeningGenerator:
|
||||
def generate(
|
||||
@@ -941,7 +1120,6 @@ class SelectBoolean(Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
# TODO: merge with ProfileDecorator?
|
||||
class DecorationsHandler:
|
||||
installed = None
|
||||
|
||||
@@ -951,6 +1129,7 @@ class DecorationsHandler:
|
||||
cls.uninstall()
|
||||
handler = cls()
|
||||
cls.installed = SpaceView3D.draw_handler_add(handler, (context,), "WINDOW", "POST_VIEW")
|
||||
install_decoration_cache_handlers()
|
||||
|
||||
@classmethod
|
||||
def uninstall(cls):
|
||||
@@ -959,15 +1138,46 @@ class DecorationsHandler:
|
||||
except ValueError:
|
||||
pass
|
||||
cls.installed = None
|
||||
uninstall_decoration_cache_handlers()
|
||||
|
||||
def draw_batch(self, shader_type, content_pos, color, indices=None):
|
||||
def _get_or_build_batch(self, shader, shader_type, content_pos, indices=None, cache_key=None):
|
||||
if cache_key is not None:
|
||||
cached = _get_cached_batch_or_none(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
|
||||
return
|
||||
shader = self.line_shader if shader_type == "LINES" else self.shader
|
||||
return None
|
||||
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
|
||||
if cache_key is not None:
|
||||
_store_batch_in_cache(cache_key, batch)
|
||||
return batch
|
||||
|
||||
def draw_batch(self, shader_type, content_pos, color, indices=None, cache_key=None):
|
||||
shader = self.line_shader if shader_type == "LINES" else self.shader
|
||||
batch = self._get_or_build_batch(shader, shader_type, content_pos, indices, cache_key=cache_key)
|
||||
if batch is None:
|
||||
return
|
||||
shader.uniform_float("color", color)
|
||||
batch.draw(shader)
|
||||
|
||||
def _draw_lines_with_occlusion(self, verts, color, edges_indices, occluded_alpha: float = 0.25, cache_key=None):
|
||||
# One batch, two draws: front pass at full color, occluded pass at
|
||||
# `occluded_alpha`. Save/restore depth_test matches the pattern in
|
||||
# bim/module/structural/decorator.py so callers' state survives.
|
||||
batch = self._get_or_build_batch(self.line_shader, "LINES", verts, edges_indices, cache_key=cache_key)
|
||||
if batch is None:
|
||||
return
|
||||
original_depth_test = gpu.state.depth_test_get()
|
||||
gpu.state.depth_test_set("LESS_EQUAL")
|
||||
self.line_shader.uniform_float("color", color)
|
||||
batch.draw(self.line_shader)
|
||||
gpu.state.depth_test_set("GREATER")
|
||||
dimmed = list(color)
|
||||
dimmed[3] = occluded_alpha
|
||||
self.line_shader.uniform_float("color", dimmed)
|
||||
batch.draw(self.line_shader)
|
||||
gpu.state.depth_test_set(original_depth_test)
|
||||
|
||||
def __call__(self, context):
|
||||
props = tool.Model.get_model_props()
|
||||
if not props.openings:
|
||||
@@ -1039,23 +1249,20 @@ class DecorationsHandler:
|
||||
self.draw_batch("LINES", verts, selected_elements_color, selected_edges)
|
||||
self.draw_batch("POINTS", unselected_vertices, unselected_elements_color)
|
||||
self.draw_batch("POINTS", selected_vertices, selected_elements_color)
|
||||
obj.data.calc_loop_triangles()
|
||||
tris = [tuple(t.vertices) for t in obj.data.loop_triangles]
|
||||
self.draw_batch("TRIS", verts, transparent_color(special_elements_color), tris)
|
||||
else:
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(obj.data)
|
||||
|
||||
verts = [tuple(obj.matrix_world @ v.co) for v in bm.verts]
|
||||
if ios_edges_attribute := obj.data.attributes.get("ios_edges"):
|
||||
edges = [e for i, e in enumerate(bm.edges) if ios_edges_attribute.data[i].value]
|
||||
else:
|
||||
edges = bm.edges
|
||||
edges_indices = [tuple([v.index for v in e.verts]) for e in edges]
|
||||
|
||||
line_verts, verts, edges_indices, tris = _get_cached_world_draw_data(obj)
|
||||
color = selected_elements_color if obj in context.selected_objects else special_elements_color
|
||||
self.draw_batch("LINES", verts, color, edges_indices)
|
||||
|
||||
obj.data.calc_loop_triangles()
|
||||
tris = [tuple(t.vertices) for t in obj.data.loop_triangles]
|
||||
self.draw_batch("TRIS", verts, transparent_color(special_elements_color), tris)
|
||||
self._draw_lines_with_occlusion(line_verts, color, edges_indices, cache_key=(obj.session_uid, "lines"))
|
||||
self.draw_batch(
|
||||
"TRIS",
|
||||
verts,
|
||||
transparent_color(special_elements_color),
|
||||
tris,
|
||||
cache_key=(obj.session_uid, "tris"),
|
||||
)
|
||||
|
||||
if "HalfSpaceSolid" in obj.name:
|
||||
# Arrow shape
|
||||
@@ -1069,7 +1276,4 @@ class DecorationsHandler:
|
||||
]
|
||||
edges = [(0, 1), (1, 2), (1, 3), (1, 4), (1, 5)]
|
||||
color = selected_elements_color if obj in context.selected_objects else special_elements_color
|
||||
self.draw_batch("LINES", verts, color, edges)
|
||||
|
||||
if obj.mode != "EDIT":
|
||||
bm.free()
|
||||
self._draw_lines_with_occlusion(verts, color, edges, cache_key=(obj.session_uid, "arrow"))
|
||||
|
||||
@@ -75,6 +75,7 @@ class PolylineOperator:
|
||||
self.is_typing = False
|
||||
self.snap_angle = None
|
||||
self.snapping_points = []
|
||||
self.unit_scale = 1.0
|
||||
self.instructions = {
|
||||
"Cycle Input": {"icons": True, "keys": ["EVENT_TAB"]},
|
||||
"Distance Input": {"icons": True, "keys": ["EVENT_D"]},
|
||||
|
||||
@@ -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.
|
||||
|
||||
"""Shared helpers for Bonsai's parametric preview flows.
|
||||
|
||||
Multiple Bonsai features follow the same Scene-level preview pattern:
|
||||
|
||||
Enable<X>Preview — validates a selection, populates draft state on
|
||||
``Scene.BIMPreviewProperties.<x>``, flips ``is_active``.
|
||||
Gizmo<X>Preview — polls on ``is_active``, surfaces tunable widgets +
|
||||
validate/cancel icons.
|
||||
<X>PreviewDecorator — GPU lines drawn while ``is_active`` is True.
|
||||
Finish<X>Preview — direct ``bpy.ops.bim.<verb>(...)`` call with kwargs
|
||||
read off the draft state, then clears it.
|
||||
Cancel<X>Preview — pure state reset.
|
||||
|
||||
The MEP bend and wall fillet flows are the two current callers. They write
|
||||
their Finish / Cancel operators directly, matching the convention used
|
||||
throughout the rest of ``bim/module/model/`` for operator-to-operator
|
||||
dispatch (explicit ``bpy.ops.bim.X(kwarg=value)`` at the call site, no
|
||||
string indirection). This module hosts the cross-cutting accessors only;
|
||||
no base class layer.
|
||||
|
||||
The GPU draw-handler lifecycle for ``<X>PreviewDecorator`` lives on the
|
||||
feature-neutral ``tool.Blender.ViewportDecorator`` base, which every
|
||||
viewport decorator (preview or otherwise) inherits from."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import bpy
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
# --- Props accessors ---------------------------------------------------------
|
||||
|
||||
|
||||
def get_preview_props(context: bpy.types.Context, attr: str):
|
||||
"""Resolve a child preview PropertyGroup under ``Scene.BIMPreviewProperties``.
|
||||
|
||||
Returns ``None`` if the umbrella isn't attached yet — true briefly
|
||||
during addon register and during plug-out, so polls / draw callbacks
|
||||
must defend against ``None`` rather than assuming the prop is always
|
||||
available. Also tolerates contexts without a ``scene`` attribute
|
||||
(test mocks built from ``SimpleNamespace``)."""
|
||||
scene = getattr(context, "scene", None)
|
||||
if scene is None:
|
||||
return None
|
||||
preview = getattr(scene, "BIMPreviewProperties", None)
|
||||
return getattr(preview, attr, None) if preview is not None else None
|
||||
|
||||
|
||||
def is_preview_active(context: bpy.types.Context, attr: str) -> bool:
|
||||
"""``True`` while a specific preview is open. Used by sibling gizmo
|
||||
polls to hide themselves so the preview is the only interactive
|
||||
surface in the viewport (the bend / fillet preview groups take over
|
||||
the same selection's icon stack)."""
|
||||
props = get_preview_props(context, attr)
|
||||
return bool(props is not None and props.is_active)
|
||||
|
||||
|
||||
def any_preview_active(context: bpy.types.Context) -> bool:
|
||||
"""``True`` if any registered preview is currently open. Sister gizmo
|
||||
polls call this to hide themselves uniformly during ANY preview, so a
|
||||
new preview registered in ``PREVIEW_CANCEL_OPS`` automatically gates
|
||||
every parametric gizmo without each one growing a specific check."""
|
||||
for attr, _op_name in PREVIEW_CANCEL_OPS:
|
||||
if is_preview_active(context, attr):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# --- Lazy closure factories --------------------------------------------------
|
||||
#
|
||||
# Used by preview gizmo groups when wiring ``BIM_GT_gizmo_dimension``'s
|
||||
# ``move_get_cb`` / ``move_set_cb`` callbacks. The closures re-resolve
|
||||
# ``bpy.context.scene`` per CALL rather than capturing it at setup() time
|
||||
# — the captured Scene's RNA struct can be freed on file open / undo, and
|
||||
# referencing a freed struct crashes Blender. Lazy lookup survives the
|
||||
# whole undo / reload lifecycle.
|
||||
|
||||
|
||||
def make_props_callback(attr: str) -> Callable[[], Any]:
|
||||
"""Return a zero-arg callable that lazily fetches the preview props.
|
||||
|
||||
Equivalent to ``getattr(bpy.context.scene.BIMPreviewProperties, attr)``
|
||||
with full defensiveness against missing scene / missing umbrella."""
|
||||
|
||||
def _props():
|
||||
scene = bpy.context.scene
|
||||
preview = getattr(scene, "BIMPreviewProperties", None) if scene else None
|
||||
return getattr(preview, attr, None) if preview is not None else None
|
||||
|
||||
return _props
|
||||
|
||||
|
||||
def make_dim_getter(props_callback: Callable[[], Any], field: str) -> Callable[[], float]:
|
||||
"""Factory for ``BIM_GT_gizmo_dimension.move_get_cb`` reading a single
|
||||
FloatProperty off the live preview state. Returns ``0.0`` defensively
|
||||
when the props are temporarily unavailable so the widget doesn't crash
|
||||
Blender during plug-out / reload."""
|
||||
|
||||
def _get() -> float:
|
||||
props = props_callback()
|
||||
return getattr(props, field) if props is not None else 0.0
|
||||
|
||||
return _get
|
||||
|
||||
|
||||
def make_dim_setter(
|
||||
props_callback: Callable[[], Any],
|
||||
field: str,
|
||||
min_value: float = 0.001,
|
||||
) -> Callable[[float], None]:
|
||||
"""Factory for ``BIM_GT_gizmo_dimension.move_set_cb`` writing a single
|
||||
FloatProperty + tagging viewport areas for redraw so the GPU preview
|
||||
decorator tracks the value live during drag. Clamps at ``min_value``
|
||||
to match the FloatProperty's declared lower bound."""
|
||||
|
||||
def _set(value: float) -> None:
|
||||
props = props_callback()
|
||||
if props is None:
|
||||
return
|
||||
setattr(props, field, max(min_value, float(value)))
|
||||
tool.Blender.update_all_viewports()
|
||||
|
||||
return _set
|
||||
|
||||
|
||||
# --- Shared Enable lifecycle helpers -----------------------------------------
|
||||
|
||||
|
||||
def sync_uncommitted_moves(objects: list) -> None:
|
||||
"""Push any Blender-side translation / rotation of ``objects`` back to
|
||||
their IFC ``ObjectPlacement`` before a preview decorator starts reading
|
||||
``obj.matrix_world`` per frame.
|
||||
|
||||
Without this sync, a user who grabbed-moved an object but didn't commit
|
||||
the move sees the live preview at the dragged position while the final
|
||||
commit lands at the stale IFC position — a confusing "where did my
|
||||
preview go?" experience. Both bend and fillet enable paths call this
|
||||
on the relevant pair just before activating the preview."""
|
||||
for obj in objects:
|
||||
tool.Geometry.commit_placement_if_moved(obj, apply_scale=False)
|
||||
|
||||
|
||||
# --- Esc dispatch ------------------------------------------------------------
|
||||
|
||||
PREVIEW_CANCEL_OPS: tuple[tuple[str, str], ...] = (
|
||||
("bend", "cancel_bend_preview"),
|
||||
("wall_fillet", "cancel_wall_fillet_preview"),
|
||||
)
|
||||
"""Registry of ``(child PointerProperty on Scene.BIMPreviewProperties, bim
|
||||
operator name)`` consulted by the Esc handler. Adding a new preview means
|
||||
appending one tuple; the forward-compat test pins that every preview
|
||||
PropertyGroup with ``is_active`` has an entry here."""
|
||||
|
||||
|
||||
def try_cancel_active_preview(context: bpy.types.Context) -> bool:
|
||||
"""Cancel every registered preview that is currently active.
|
||||
|
||||
Returns ``True`` iff at least one preview was cancelled. Multiple
|
||||
previews can be simultaneously active (e.g. a stale bend preview opened
|
||||
just before the user starts a wall fillet) — one Esc must clear them
|
||||
all rather than forcing the user to tap Esc once per preview.
|
||||
|
||||
Tags 3D viewports for redraw on success — the Esc keymap entry runs
|
||||
outside a viewport mouse event so the gizmo poll wouldn't re-evaluate
|
||||
until the next interaction without an explicit redraw."""
|
||||
cancelled = False
|
||||
for attr, op_name in PREVIEW_CANCEL_OPS:
|
||||
if is_preview_active(context, attr):
|
||||
getattr(bpy.ops.bim, op_name)()
|
||||
cancelled = True
|
||||
if cancelled:
|
||||
tool.Blender.update_all_viewports(context)
|
||||
return cancelled
|
||||
|
||||
|
||||
def discard_pending_previews(scene: bpy.types.Scene) -> None:
|
||||
"""Clear every active preview under ``Scene.BIMPreviewProperties`` so
|
||||
saved preview state never resurfaces on file load.
|
||||
|
||||
Mirrors ``tool.Parametric.heal_stale_edit_flags`` for the object-level
|
||||
parametric-edit lifecycle — except previews are *discarded* rather than
|
||||
validated. A preview's only UI cue is its in-viewport widget; reloading
|
||||
a ``.blend`` saved mid-preview restores the flag but not the surrounding
|
||||
user attention, and a stuck ``is_active`` silently hides every sibling
|
||||
gizmo poll gated on it.
|
||||
|
||||
Iterates ``PREVIEW_CANCEL_OPS`` so any preview registered for Esc
|
||||
cancellation is automatically covered here too. Sets ``is_active``
|
||||
directly rather than dispatching the cancel operator: load_post may
|
||||
fire before ``bpy.context.screen`` is reattached, and the cancel
|
||||
operators bail on ``context.screen is None``."""
|
||||
preview = getattr(scene, "BIMPreviewProperties", None)
|
||||
if preview is None:
|
||||
return
|
||||
for attr, _op_name in PREVIEW_CANCEL_OPS:
|
||||
child = getattr(preview, attr, None)
|
||||
if child is not None and getattr(child, "is_active", False):
|
||||
child.is_active = False
|
||||
@@ -15,6 +15,8 @@
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was modified with the assistance of an AI coding tool.
|
||||
|
||||
import math
|
||||
from collections.abc import Callable
|
||||
@@ -193,6 +195,32 @@ def update_stair(self: "BIMStairProperties", context: bpy.types.Context) -> None
|
||||
_get_updater("stair", "regenerate_stair_mesh")(obj)
|
||||
|
||||
|
||||
def update_wall(self: "BIMWallProperties", context: bpy.types.Context) -> None:
|
||||
"""Regenerate wall mesh preview when property changes. Does NOT touch IFC."""
|
||||
obj = context.active_object
|
||||
if obj and self.is_editing:
|
||||
_get_updater("wall", "regenerate_wall_mesh_from_props")(obj)
|
||||
|
||||
|
||||
def update_wall_offset_baseline(self: "BIMWallProperties", context: bpy.types.Context) -> None:
|
||||
"""Recompute the preview-only ``offset`` when the draft baseline cycles. Does not touch IFC.
|
||||
|
||||
``offset`` itself has no ``update`` callback on purpose — adding one would make
|
||||
every baseline cycle rebuild the bmesh twice (once via offset's callback, once
|
||||
explicitly below)."""
|
||||
obj = context.active_object
|
||||
if not (obj and self.is_editing):
|
||||
return
|
||||
t = self.thickness
|
||||
if self.desired_offset_baseline == "CENTER":
|
||||
self.offset = -t / 2
|
||||
elif self.desired_offset_baseline == "INTERIOR":
|
||||
self.offset = -t
|
||||
else: # EXTERIOR
|
||||
self.offset = 0.0
|
||||
_get_updater("wall", "regenerate_wall_mesh_from_props")(obj)
|
||||
|
||||
|
||||
def update_railing(self: "BIMRailingProperties", context: bpy.types.Context) -> None:
|
||||
"""Regenerate railing mesh when property changes."""
|
||||
if self.is_editing:
|
||||
@@ -1631,6 +1659,118 @@ class BIMRoofProperties(PropertyGroup):
|
||||
setattr(target_props, prop_name, prop_value)
|
||||
|
||||
|
||||
class BIMWallProperties(PropertyGroup):
|
||||
"""Transient draft state for parametric wall gizmo editing.
|
||||
|
||||
Populated from IFC on `bim.enable_editing_wall`, mutated by gizmo drags during edit
|
||||
(preview only — no IFC writes), and either committed by `bim.finish_editing_wall`
|
||||
or discarded by `bim.cancel_editing_wall`.
|
||||
|
||||
The `snap_*` fields are the values captured on enable; `finish_editing_wall` compares
|
||||
current vs snap to skip unchanged params and guarantee a no-op session leaves the
|
||||
IFC file byte-identical.
|
||||
"""
|
||||
|
||||
is_editing: bpy.props.BoolProperty(
|
||||
default=False,
|
||||
description="True while wall parametric edit mode is active.",
|
||||
)
|
||||
mesh_dirty: bpy.props.BoolProperty(
|
||||
default=False,
|
||||
options={"HIDDEN", "SKIP_SAVE"},
|
||||
description=(
|
||||
"True while the visible mesh is the preview box; cleared once the real "
|
||||
"IFC-derived geometry is restored (on commit or cancel)."
|
||||
),
|
||||
)
|
||||
length: bpy.props.FloatProperty(
|
||||
name="Length",
|
||||
default=1.0,
|
||||
min=0.01,
|
||||
subtype="DISTANCE",
|
||||
update=update_wall,
|
||||
description="Wall length along its reference axis (preview value; committed on finish).",
|
||||
)
|
||||
height: bpy.props.FloatProperty(
|
||||
name="Height",
|
||||
default=3.0,
|
||||
min=0.01,
|
||||
subtype="DISTANCE",
|
||||
update=update_wall,
|
||||
description="Wall vertical height (preview value; committed on finish).",
|
||||
)
|
||||
x_angle: bpy.props.FloatProperty(
|
||||
name="Slope (X Angle)",
|
||||
default=0.0,
|
||||
soft_min=-math.pi / 3,
|
||||
soft_max=math.pi / 3,
|
||||
subtype="ANGLE",
|
||||
update=update_wall,
|
||||
description="Slope angle: tilt of the wall's top face along +Y (preview value; committed on finish).",
|
||||
)
|
||||
thickness: bpy.props.FloatProperty(
|
||||
name="Thickness",
|
||||
default=0.2,
|
||||
min=0.001,
|
||||
subtype="DISTANCE",
|
||||
description="Wall thickness captured from IFC at edit-enable; not gizmo-bound.",
|
||||
)
|
||||
offset: bpy.props.FloatProperty(
|
||||
name="Offset",
|
||||
default=0.0,
|
||||
subtype="DISTANCE",
|
||||
description="Layer-set offset captured from IFC at edit-enable; driven by desired_offset_baseline.",
|
||||
)
|
||||
desired_offset_baseline: bpy.props.EnumProperty(
|
||||
items=[
|
||||
("EXTERIOR", "Exterior", "Reference axis at the exterior face"),
|
||||
("CENTER", "Center", "Reference axis at the wall centreline"),
|
||||
("INTERIOR", "Interior", "Reference axis at the interior face"),
|
||||
],
|
||||
name="Desired Offset Baseline",
|
||||
default="CENTER",
|
||||
update=update_wall_offset_baseline,
|
||||
description="Which face of the wall the reference axis aligns to (preview value; committed on finish).",
|
||||
)
|
||||
anchor_x: bpy.props.FloatProperty(
|
||||
default=0.0,
|
||||
subtype="DISTANCE",
|
||||
description="Local-X of the wall's axis polyline start, so the preview box lands where the IFC mesh does.",
|
||||
)
|
||||
|
||||
snap_length: bpy.props.FloatProperty(description="Snapshot of length at edit-enable; commit skips no-op writes.")
|
||||
snap_height: bpy.props.FloatProperty(description="Snapshot of height at edit-enable; commit skips no-op writes.")
|
||||
snap_thickness: bpy.props.FloatProperty(
|
||||
description="Snapshot of thickness at edit-enable; commit skips no-op writes."
|
||||
)
|
||||
snap_offset: bpy.props.FloatProperty(description="Snapshot of offset at edit-enable; commit skips no-op writes.")
|
||||
snap_x_angle: bpy.props.FloatProperty(
|
||||
subtype="ANGLE",
|
||||
description="Snapshot of x_angle at edit-enable; commit skips no-op writes.",
|
||||
)
|
||||
snap_offset_baseline: bpy.props.StringProperty(
|
||||
default="",
|
||||
description="Snapshot of desired_offset_baseline at edit-enable; commit skips no-op writes.",
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
is_editing: bool
|
||||
mesh_dirty: bool
|
||||
length: float
|
||||
height: float
|
||||
x_angle: float
|
||||
thickness: float
|
||||
offset: float
|
||||
desired_offset_baseline: Literal["EXTERIOR", "CENTER", "INTERIOR"]
|
||||
anchor_x: float
|
||||
snap_length: float
|
||||
snap_height: float
|
||||
snap_thickness: float
|
||||
snap_offset: float
|
||||
snap_x_angle: float
|
||||
snap_offset_baseline: str
|
||||
|
||||
|
||||
class SnapMousePoint(PropertyGroup):
|
||||
x: bpy.props.FloatProperty(name="X")
|
||||
y: bpy.props.FloatProperty(name="Y")
|
||||
@@ -1762,3 +1902,65 @@ class BIMExternalParametricGeometryProperties(bpy.types.PropertyGroup):
|
||||
geometry_source: Literal["GEONODES", "IFCSVERCHOK"]
|
||||
geo_nodes: Union[bpy.types.GeometryNodeTree, None]
|
||||
sverchok_nodes: Union[sverchok.node_tree.SverchCustomTree, None]
|
||||
|
||||
|
||||
class BIMWallFilletPreviewProperties(PropertyGroup):
|
||||
"""Scene-level pending state for the wall-fillet preview flow.
|
||||
|
||||
Scene-level because the fillet spans two walls and commits a third
|
||||
(corner) wall between them. ``SKIP_SAVE`` fields throughout."""
|
||||
|
||||
is_active: bpy.props.BoolProperty(
|
||||
default=False,
|
||||
options={"SKIP_SAVE"},
|
||||
description="True while the wall-fillet preview flow is active.",
|
||||
)
|
||||
wall_a_id: bpy.props.IntProperty(
|
||||
default=0,
|
||||
options={"SKIP_SAVE"},
|
||||
description=(
|
||||
"IFC element id of the active wall — the corner wall inherits its "
|
||||
"material layer set, height, x_angle, and type."
|
||||
),
|
||||
)
|
||||
wall_b_id: bpy.props.IntProperty(
|
||||
default=0,
|
||||
options={"SKIP_SAVE"},
|
||||
description="IFC element id of the other selected wall.",
|
||||
)
|
||||
radius: bpy.props.FloatProperty(
|
||||
name="Radius",
|
||||
default=0.5,
|
||||
soft_min=-10.0,
|
||||
soft_max=10.0,
|
||||
subtype="DISTANCE",
|
||||
unit="LENGTH",
|
||||
options={"SKIP_SAVE"},
|
||||
description="Radius of the circular arc connecting the two walls.",
|
||||
)
|
||||
editing_corner_id: bpy.props.IntProperty(
|
||||
default=0,
|
||||
options={"SKIP_SAVE"},
|
||||
description=(
|
||||
"IFC element id of an existing fillet corner being re-edited "
|
||||
"(non-zero only on the pen-icon re-edit flow). The create "
|
||||
"operator deletes this corner + its connections before recreating "
|
||||
"with the new radius."
|
||||
),
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
is_active: bool
|
||||
wall_a_id: int
|
||||
wall_b_id: int
|
||||
radius: float
|
||||
editing_corner_id: int
|
||||
|
||||
|
||||
class BIMPreviewProperties(PropertyGroup):
|
||||
"""Umbrella for parametric-edit preview drafts attached to ``Scene``."""
|
||||
|
||||
wall_fillet: bpy.props.PointerProperty(type=BIMWallFilletPreviewProperties)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
wall_fillet: BIMWallFilletPreviewProperties
|
||||
|
||||
@@ -34,6 +34,7 @@ import bonsai.core.root
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.model.data import RailingData, refresh
|
||||
from bonsai.bim.module.model.decorator import ProfileDecorator
|
||||
from bonsai.bim.parametric_lifecycle import PathPreservingEditMixin
|
||||
|
||||
# reference:
|
||||
# https://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcRailing.htm
|
||||
@@ -92,7 +93,6 @@ def update_railing_modifier_ifc_data(context: bpy.types.Context) -> None:
|
||||
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
|
||||
representation_data = {
|
||||
"railing_type": props.railing_type,
|
||||
"context": body,
|
||||
"railing_path": railing_path,
|
||||
"use_manual_supports": props.use_manual_supports,
|
||||
@@ -406,66 +406,65 @@ class CopyRailingParameters(bpy.types.Operator, tool.Ifc.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class EnableEditingRailing(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.enable_editing_railing"
|
||||
bl_label = "Enable Editing Railing"
|
||||
bl_options = {"REGISTER"}
|
||||
class _RailingEditMixin(PathPreservingEditMixin):
|
||||
"""Type-specific hooks for railing parametric-edit operators. Single-object
|
||||
(active_object). ``path_data`` is preserved through the edit; the separate
|
||||
``Enable/Finish/CancelEditingRailingPath`` operators handle path editing."""
|
||||
|
||||
def _execute(self, context):
|
||||
obj = context.active_object
|
||||
assert obj
|
||||
props = tool.Model.get_railing_props(obj)
|
||||
data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Railing")["data_dict"]
|
||||
pset_name = "BBIM_Railing"
|
||||
|
||||
@classmethod
|
||||
def _is_element_type(cls, element):
|
||||
return tool.Blender.Modifier.is_railing(element)
|
||||
|
||||
@classmethod
|
||||
def _get_props(cls, obj: bpy.types.Object):
|
||||
return tool.Model.get_railing_props(obj)
|
||||
|
||||
@classmethod
|
||||
def _post_load_data(cls, data: dict) -> dict:
|
||||
# BIMRailingProperties.path_data is a StringProperty holding JSON.
|
||||
data["path_data"] = json.dumps(data["path_data"])
|
||||
return data
|
||||
|
||||
# required since we could load pset from .ifc and BIMRailingProperties won't be set
|
||||
props.set_props_kwargs_from_ifc_data(data)
|
||||
@classmethod
|
||||
def _update_pset(cls, element, data: dict) -> None:
|
||||
update_bbim_railing_pset(element, data)
|
||||
|
||||
props.is_editing = True
|
||||
return {"FINISHED"}
|
||||
@classmethod
|
||||
def _update_modifier_ifc_data(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
|
||||
update_railing_modifier_ifc_data(context)
|
||||
|
||||
|
||||
class CancelEditingRailing(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.cancel_editing_railing"
|
||||
bl_label = "Cancel Editing Railing"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
def _execute(self, context):
|
||||
obj = context.active_object
|
||||
assert obj
|
||||
data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Railing")["data_dict"]
|
||||
props = tool.Model.get_railing_props(obj)
|
||||
|
||||
# restore previous settings since editing was canceled
|
||||
props.set_props_kwargs_from_ifc_data(data)
|
||||
@classmethod
|
||||
def _update_modifier_bmesh(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
|
||||
update_railing_modifier_bmesh(context)
|
||||
|
||||
props.is_editing = False
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class FinishEditingRailing(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.finish_editing_railing"
|
||||
bl_label = "Finish Editing Railing"
|
||||
bl_options = {"REGISTER"}
|
||||
class EnableEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.enable_editing_railing"
|
||||
bl_label = "Enable Editing Railing"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def _execute(self, context):
|
||||
obj = context.active_object
|
||||
assert obj
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
assert element
|
||||
props = tool.Model.get_railing_props(obj)
|
||||
return self._enable_targets(context)
|
||||
|
||||
pset_data = tool.Model.get_modeling_bbim_pset_data(bpy.context.active_object, "BBIM_Railing")
|
||||
path_data = pset_data["data_dict"]["path_data"]
|
||||
|
||||
railing_data = props.get_general_kwargs(convert_to_project_units=True)
|
||||
railing_data["path_data"] = path_data
|
||||
props.is_editing = False
|
||||
class CancelEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.cancel_editing_railing"
|
||||
bl_label = "Cancel Editing Railing"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
update_bbim_railing_pset(element, railing_data)
|
||||
update_railing_modifier_ifc_data(context)
|
||||
return {"FINISHED"}
|
||||
def _execute(self, context):
|
||||
return self._cancel_targets(context)
|
||||
|
||||
|
||||
class FinishEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.finish_editing_railing"
|
||||
bl_label = "Finish Editing Railing"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def _execute(self, context):
|
||||
return self._finish_targets(context)
|
||||
|
||||
|
||||
class FlipRailingPathOrder(bpy.types.Operator, tool.Ifc.Operator):
|
||||
|
||||
@@ -34,6 +34,7 @@ import bonsai.core.root
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.model.data import RoofData, refresh
|
||||
from bonsai.bim.module.model.decorator import ProfileDecorator
|
||||
from bonsai.bim.parametric_lifecycle import PathPreservingEditMixin
|
||||
|
||||
# reference:
|
||||
# https://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcRoof.htm
|
||||
@@ -608,61 +609,59 @@ class AddRoof(bpy.types.Operator, tool.Ifc.Operator):
|
||||
tool.Model.add_body_representation(obj)
|
||||
|
||||
|
||||
class EnableEditingRoof(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.enable_editing_roof"
|
||||
bl_label = "Enable Editing Roof"
|
||||
bl_options = {"REGISTER"}
|
||||
class _RoofEditMixin(PathPreservingEditMixin):
|
||||
"""Type-specific hooks for roof parametric-edit operators. Single-object
|
||||
(active_object). ``path_data`` is preserved through the edit; the separate
|
||||
``Enable/Finish/CancelEditingRoofPath`` operators handle path editing."""
|
||||
|
||||
def _execute(self, context):
|
||||
obj = context.active_object
|
||||
assert obj
|
||||
props = tool.Model.get_roof_props(obj)
|
||||
data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Roof")["data_dict"]
|
||||
# required since we could load pset from .ifc and BIMRoofProperties won't be set
|
||||
props.set_props_kwargs_from_ifc_data(data)
|
||||
props.is_editing = True
|
||||
return {"FINISHED"}
|
||||
pset_name = "BBIM_Roof"
|
||||
|
||||
@classmethod
|
||||
def _is_element_type(cls, element):
|
||||
return tool.Blender.Modifier.is_roof(element)
|
||||
|
||||
class CancelEditingRoof(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.cancel_editing_roof"
|
||||
bl_label = "Cancel Editing Roof"
|
||||
bl_options = {"REGISTER"}
|
||||
@classmethod
|
||||
def _get_props(cls, obj: bpy.types.Object):
|
||||
return tool.Model.get_roof_props(obj)
|
||||
|
||||
def _execute(self, context):
|
||||
obj = context.active_object
|
||||
assert obj
|
||||
data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Roof")["data_dict"]
|
||||
props = tool.Model.get_roof_props(obj)
|
||||
@classmethod
|
||||
def _update_pset(cls, element, data: dict) -> None:
|
||||
update_bbim_roof_pset(element, data)
|
||||
|
||||
# restore previous settings since editing was canceled
|
||||
props.set_props_kwargs_from_ifc_data(data)
|
||||
@classmethod
|
||||
def _update_modifier_ifc_data(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
|
||||
update_roof_modifier_ifc_data(context)
|
||||
|
||||
@classmethod
|
||||
def _update_modifier_bmesh(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
|
||||
update_roof_modifier_bmesh(obj)
|
||||
|
||||
props.is_editing = False
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class FinishEditingRoof(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.finish_editing_roof"
|
||||
bl_label = "Finish Editing Roof"
|
||||
bl_options = {"REGISTER"}
|
||||
class EnableEditingRoof(_RoofEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.enable_editing_roof"
|
||||
bl_label = "Enable Editing Roof"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def _execute(self, context):
|
||||
obj = context.active_object
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
props = tool.Model.get_roof_props(obj)
|
||||
return self._enable_targets(context)
|
||||
|
||||
pset_data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Roof")
|
||||
path_data = pset_data["data_dict"]["path_data"]
|
||||
|
||||
roof_data = props.get_general_kwargs(convert_to_project_units=True)
|
||||
roof_data["path_data"] = path_data
|
||||
props.is_editing = False
|
||||
class CancelEditingRoof(_RoofEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.cancel_editing_roof"
|
||||
bl_label = "Cancel Editing Roof"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
update_bbim_roof_pset(element, roof_data)
|
||||
update_roof_modifier_ifc_data(context)
|
||||
return {"FINISHED"}
|
||||
def _execute(self, context):
|
||||
return self._cancel_targets(context)
|
||||
|
||||
|
||||
class FinishEditingRoof(_RoofEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.finish_editing_roof"
|
||||
bl_label = "Finish Editing Roof"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def _execute(self, context):
|
||||
return self._finish_targets(context)
|
||||
|
||||
|
||||
class EnableEditingRoofPath(bpy.types.Operator, tool.Ifc.Operator):
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was modified with the assistance of an AI coding tool.
|
||||
|
||||
import json
|
||||
|
||||
@@ -262,7 +264,6 @@ class FinishEditingStair(bpy.types.Operator, tool.Ifc.Operator):
|
||||
|
||||
# Use the special method that includes custom_tread_lock for IFC storage
|
||||
data = props.get_props_kwargs_for_ifc_export(convert_to_project_units=True)
|
||||
props.is_editing = False
|
||||
regenerate_stair_mesh(obj)
|
||||
tool.Model.add_body_representation(obj)
|
||||
|
||||
@@ -272,6 +273,7 @@ class FinishEditingStair(bpy.types.Operator, tool.Ifc.Operator):
|
||||
|
||||
# update IfcStairFlight properties
|
||||
update_ifc_stair_props(obj)
|
||||
props.is_editing = False
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
@@ -428,7 +430,7 @@ class CycleStairType(bpy.types.Operator, gizmo.CycleTypeMixin):
|
||||
bl_label = "Cycle Stair Type"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
props_getter = "get_stair_props"
|
||||
props_getter = tool.Model.get_stair_props
|
||||
type_literal = tool.Model.StairType
|
||||
type_attr = "stair_type"
|
||||
skip_element_check = True
|
||||
@@ -578,7 +580,7 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
]
|
||||
|
||||
# Metadata-driven dispatch for props and preferences
|
||||
props_getter = "get_stair_props"
|
||||
props_getter = tool.Model.get_stair_props
|
||||
gizmo_pref_name = "stair"
|
||||
|
||||
@classmethod
|
||||
@@ -591,14 +593,12 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
"VIEW3D_GT_lock",
|
||||
self.COLOR_BLUE,
|
||||
"bim.toggle_stair_property",
|
||||
prop_path="BIMStairProperties.total_length_lock",
|
||||
property_name="total_length_lock",
|
||||
)
|
||||
self.tread_lock_gizmo = self.create_icon_gizmo(
|
||||
"VIEW3D_GT_lock",
|
||||
(1.0, 1.0, 1.0),
|
||||
"bim.toggle_stair_property",
|
||||
prop_path="BIMStairProperties.custom_tread_lock",
|
||||
property_name="custom_tread_lock",
|
||||
)
|
||||
self.plus_gizmo = self.create_icon_gizmo(
|
||||
@@ -608,29 +608,23 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
"VIEW3D_GT_minus", self.COLOR_RED, "bim.adjust_stair_treads", increment=-1
|
||||
)
|
||||
|
||||
def _refresh_element_specific(self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties") -> None:
|
||||
"""Update stair-specific lock and tread count gizmos."""
|
||||
billboard_rot = gizmo.get_billboard_rotation(context)
|
||||
self.update_lock_gizmo(mw, props, billboard_rot)
|
||||
def _refresh_element_specific(
|
||||
self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties" # noqa: ARG002
|
||||
) -> None:
|
||||
"""Update stair-specific lock and tread count gizmos. Lock positioning is
|
||||
handled per-frame in the dimension-positioning hook."""
|
||||
self.update_lock_gizmo(props)
|
||||
self.update_tread_lock_gizmo(props)
|
||||
self.update_tread_count_gizmos(props)
|
||||
|
||||
def update_lock_gizmo(self, mw: Matrix, props: "BIMStairProperties", billboard_rot: Matrix) -> None:
|
||||
"""Update lock gizmo visibility, color, and position."""
|
||||
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 positioning
|
||||
|
||||
return # Hidden, skip color update
|
||||
self.lock_gizmo.color = self.COLOR_RED if props.total_length_lock else self.COLOR_GREEN
|
||||
|
||||
total_run = props.get_total_run()
|
||||
local_transform = (
|
||||
Matrix.Translation(Vector((total_run + self.ICON_Z_OFFSET, -self.GIZMO_OFFSET, -self.GIZMO_OFFSET)))
|
||||
@ billboard_rot
|
||||
@ Matrix.Scale(self.EDITING_ICON_SCALE, 4)
|
||||
)
|
||||
self.lock_gizmo.matrix_basis = mw @ local_transform
|
||||
|
||||
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"):
|
||||
@@ -650,11 +644,11 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
)
|
||||
|
||||
def _update_dimension_gizmo_positions(
|
||||
self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties"
|
||||
self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties" # noqa: ARG002
|
||||
) -> None:
|
||||
"""Update dimension gizmo positions based on camera view direction."""
|
||||
viewing_from_negative_y, viewing_from_negative_x = self.get_local_view_direction(context, mw)
|
||||
billboard_rot = gizmo.get_billboard_rotation(context)
|
||||
viewing_from_negative_y, viewing_from_negative_x = self._frame_view_dir
|
||||
billboard_rot = self._frame_billboard_rot
|
||||
total_run = props.get_total_run()
|
||||
riser_height = props.get_riser_height()
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ from typing import TYPE_CHECKING, Any
|
||||
import bpy
|
||||
from bpy.types import Panel
|
||||
|
||||
import ifcopenshell.util.unit
|
||||
import bonsai.bim
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.helper import prop_with_search
|
||||
@@ -303,6 +304,8 @@ class BIM_PT_stair(bpy.types.Panel):
|
||||
row = self.layout.row(align=True)
|
||||
row.label(text="Stair parameters", icon="IPO_CONSTANT")
|
||||
|
||||
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
|
||||
if props.is_editing:
|
||||
calculated_params = tool.Model.get_active_stair_calculated_params()
|
||||
row = self.layout.row(align=True)
|
||||
@@ -322,22 +325,61 @@ class BIM_PT_stair(bpy.types.Panel):
|
||||
row.label(text=f"{prop_name}:")
|
||||
row = self.layout.row(align=True)
|
||||
for prop_value_item in prop_value:
|
||||
row.label(text=str(prop_value_item))
|
||||
if isinstance(prop_value_item, float):
|
||||
row.label(text=tool.Unit.format_distance(prop_value_item * si_conversion))
|
||||
else:
|
||||
row.label(text=str(prop_value_item))
|
||||
else:
|
||||
row.label(text=prop_name)
|
||||
row.label(text=str(prop_value))
|
||||
if isinstance(prop_value, float):
|
||||
row.label(text=tool.Unit.format_distance(prop_value * si_conversion))
|
||||
else:
|
||||
row.label(text=str(prop_value))
|
||||
|
||||
# calculated properties
|
||||
for prop_name, prop_value in calculated_params.items():
|
||||
row = self.layout.row(align=True)
|
||||
row.label(text=prop_name)
|
||||
row.label(text=str(prop_value))
|
||||
if isinstance(prop_value, float):
|
||||
row.label(text=tool.Unit.format_distance(prop_value * si_conversion))
|
||||
else:
|
||||
row.label(text=str(prop_value))
|
||||
else:
|
||||
row = self.layout.row()
|
||||
row.label(text="No Stair Found")
|
||||
row.operator("bim.add_stair", icon="ADD", text="")
|
||||
|
||||
|
||||
class BIM_PT_wall(bpy.types.Panel):
|
||||
bl_label = "Wall"
|
||||
bl_idname = "BIM_PT_wall"
|
||||
bl_space_type = "PROPERTIES"
|
||||
bl_region_type = "WINDOW"
|
||||
bl_context = "scene"
|
||||
bl_options = {"DEFAULT_CLOSED"}
|
||||
bl_parent_id = "BIM_PT_tab_parametric_geometry"
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
obj = context.active_object
|
||||
if not obj:
|
||||
return False
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
return bool(element) and tool.Blender.Modifier.is_wall(element)
|
||||
|
||||
def draw(self, context):
|
||||
obj = context.active_object
|
||||
if obj is None:
|
||||
return
|
||||
props = tool.Model.get_wall_props(obj)
|
||||
row = self.layout.row(align=True)
|
||||
if props.is_editing:
|
||||
row.operator("bim.finish_editing_wall", icon="CHECKMARK", text="Finish Editing")
|
||||
row.operator("bim.cancel_editing_wall", icon="CANCEL", text="")
|
||||
else:
|
||||
row.operator("bim.enable_editing_wall", icon="GREASEPENCIL", text="Edit Wall")
|
||||
|
||||
|
||||
class BIM_PT_sverchok(bpy.types.Panel):
|
||||
bl_label = "Sverchok"
|
||||
bl_idname = "BIM_PT_sverchok"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -39,6 +39,7 @@ import bonsai.core.root
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.drawing import gizmos as gizmo
|
||||
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
|
||||
from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bonsai.bim.module.model.prop import BIMWindowProperties
|
||||
@@ -482,90 +483,53 @@ class AddWindow(bpy.types.Operator, tool.Ifc.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class CancelEditingWindow(bpy.types.Operator, tool.Ifc.Operator):
|
||||
class _WindowEditMixin(FeatureModifierEditMixin):
|
||||
"""Type-specific hooks for window parametric-edit operators. Single-object
|
||||
by design (window edits target the active object only)."""
|
||||
|
||||
pset_name = "BBIM_Window"
|
||||
|
||||
@classmethod
|
||||
def _is_element_type(cls, element):
|
||||
return tool.Blender.Modifier.is_window(element)
|
||||
|
||||
@classmethod
|
||||
def _get_props(cls, obj: bpy.types.Object):
|
||||
return tool.Model.get_window_props(obj)
|
||||
|
||||
@classmethod
|
||||
def _update_modifier_representation(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
|
||||
update_window_modifier_representation(context)
|
||||
|
||||
|
||||
class CancelEditingWindow(_WindowEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.cancel_editing_window"
|
||||
bl_label = "Cancel Editing Window"
|
||||
bl_description = "Cancel editing and revert window parameters to their previous values"
|
||||
bl_options = {"REGISTER"}
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
obj = context.active_object
|
||||
assert obj
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
assert element
|
||||
data = json.loads(ifcopenshell.util.element.get_pset(element, "BBIM_Window", "Data"))
|
||||
data.update(data.pop("lining_properties"))
|
||||
data.update(data.pop("panel_properties"))
|
||||
props = tool.Model.get_window_props(obj)
|
||||
props.set_props_kwargs_from_ifc_data(data)
|
||||
|
||||
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
|
||||
bonsai.core.geometry.switch_representation(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
obj=obj,
|
||||
representation=body,
|
||||
)
|
||||
|
||||
props.is_editing = False
|
||||
return {"FINISHED"}
|
||||
return self._cancel_targets(context)
|
||||
|
||||
|
||||
class FinishEditingWindow(bpy.types.Operator, tool.Ifc.Operator):
|
||||
class FinishEditingWindow(_WindowEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.finish_editing_window"
|
||||
bl_label = "Finish Editing Window"
|
||||
bl_description = "Apply changes and finish editing window parameters"
|
||||
bl_options = {"REGISTER"}
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
obj = context.active_object
|
||||
assert obj
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
assert element
|
||||
props = tool.Model.get_window_props(obj)
|
||||
|
||||
window_data = props.get_general_kwargs(convert_to_project_units=True)
|
||||
lining_props = props.get_lining_kwargs(convert_to_project_units=True)
|
||||
panel_props = props.get_panel_kwargs(convert_to_project_units=True)
|
||||
|
||||
window_data["lining_properties"] = lining_props
|
||||
window_data["panel_properties"] = panel_props
|
||||
|
||||
props.is_editing = False
|
||||
|
||||
update_window_modifier_representation(context)
|
||||
element_type = ifcopenshell.util.element.get_type(element)
|
||||
if element_type:
|
||||
tool.Model.mark_thumbnail_for_update(element_type)
|
||||
|
||||
pset = tool.Pset.get_element_pset(element, "BBIM_Window")
|
||||
window_data = tool.Ifc.get().createIfcText(json.dumps(window_data, default=list))
|
||||
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": window_data})
|
||||
return {"FINISHED"}
|
||||
return self._finish_targets(context)
|
||||
|
||||
|
||||
class EnableEditingWindow(bpy.types.Operator, tool.Ifc.Operator):
|
||||
class EnableEditingWindow(_WindowEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.enable_editing_window"
|
||||
bl_label = "Enable Editing Window"
|
||||
bl_description = "Enter edit mode to modify window parameters interactively"
|
||||
bl_options = {"REGISTER"}
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
obj = context.active_object
|
||||
assert obj
|
||||
props = tool.Model.get_window_props(obj)
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
assert element
|
||||
data = json.loads(ifcopenshell.util.element.get_pset(element, "BBIM_Window", "Data"))
|
||||
data.update(data.pop("lining_properties"))
|
||||
data.update(data.pop("panel_properties"))
|
||||
data.update(tool.Model.get_constituents_props_data(element))
|
||||
|
||||
# required since we could load pset from .ifc and BIMWindowProperties won't be set
|
||||
props.set_props_kwargs_from_ifc_data(data)
|
||||
|
||||
props.is_editing = True
|
||||
return {"FINISHED"}
|
||||
return self._enable_targets(context)
|
||||
|
||||
|
||||
class RemoveWindow(bpy.types.Operator, tool.Ifc.Operator):
|
||||
@@ -594,8 +558,8 @@ class CycleWindowType(bpy.types.Operator, tool.Ifc.Operator, gizmo.CycleTypeMixi
|
||||
bl_label = "Cycle Window Type"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
element_checker = "is_window"
|
||||
props_getter = "get_window_props"
|
||||
element_checker = tool.Parametric.is_window
|
||||
props_getter = tool.Model.get_window_props
|
||||
type_literal = tool.Model.WindowType
|
||||
type_attr = "window_type"
|
||||
|
||||
@@ -781,7 +745,7 @@ class GizmoWindowEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
DimensionGizmoConfig(attr_name="lining_offset", axis=(0, 1, 0), min_value=-10.0),
|
||||
]
|
||||
|
||||
props_getter = "get_window_props"
|
||||
props_getter = tool.Model.get_window_props
|
||||
gizmo_pref_name = "window"
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -841,7 +841,7 @@ class EditObjectUI:
|
||||
row = cls.layout.row(align=True)
|
||||
row.separator()
|
||||
row.label(text="Operations") if ui_context != "TOOL_HEADER" else row
|
||||
cls.draw_regen_operations(row)
|
||||
cls.draw_regen_operations(row, ui_context)
|
||||
|
||||
if AuthoringData.data["active_material_usage"] == "LAYER2":
|
||||
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
|
||||
@@ -962,20 +962,14 @@ class EditObjectUI:
|
||||
return row
|
||||
|
||||
@classmethod
|
||||
def draw_regen_operations(cls, row):
|
||||
custom_icon = custom_icon_previews.get("REGEN", custom_icon_previews["IFC"]).icon_id
|
||||
|
||||
def draw_regen_operations(cls, row, ui_context):
|
||||
if AuthoringData.data["is_regenable_element"]:
|
||||
op = row.operator("bim.hotkey", text="", icon_value=custom_icon)
|
||||
description = "Recalculate Element Geometry\nHotkey: S G"
|
||||
op.hotkey = "S_G"
|
||||
op.description = description.strip()
|
||||
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
|
||||
add_layout_hotkey_operator(row, "Regen", "S_G", "Recalculate Element Geometry", ui_context)
|
||||
|
||||
if PortData.data["total_ports"] > 0:
|
||||
op = row.operator("bim.hotkey", text="", icon_value=custom_icon)
|
||||
description = f"{bpy.ops.bim.regenerate_distribution_element.__doc__}\n\nHotkey: S G"
|
||||
op.hotkey = "S_G"
|
||||
op.description = description.strip()
|
||||
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)
|
||||
|
||||
@classmethod
|
||||
def draw_void(cls, context, row):
|
||||
@@ -1300,9 +1294,15 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bpy.ops.bim.generate_space()
|
||||
return
|
||||
if self.active_material_usage == "LAYER2":
|
||||
bpy.ops.bim.recalculate_wall()
|
||||
if element and tool.Model.has_underside_connection(element):
|
||||
bpy.ops.bim.regenerate_wall_to_underside()
|
||||
else:
|
||||
bpy.ops.bim.recalculate_wall()
|
||||
elif self.active_material_usage == "LAYER3":
|
||||
bpy.ops.bim.recalculate_slab()
|
||||
wall_objs = tool.Model.get_connected_wall_objs(element)
|
||||
if wall_objs:
|
||||
core.regenerate_wall_to_underside(tool.Ifc, tool.Geometry, tool.Model, wall_objs)
|
||||
elif tool.System.get_ports(element):
|
||||
bpy.ops.bim.regenerate_distribution_element()
|
||||
elif self.active_material_usage == "PROFILE":
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was modified with the assistance of an AI coding tool.
|
||||
|
||||
import datetime
|
||||
import json
|
||||
@@ -61,6 +63,7 @@ import bonsai.core.project as core
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim import export_ifc, import_ifc
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
from bonsai.bim.module.model import preview_base
|
||||
from bonsai.bim.module.model.decorator import FaceAreaDecorator, PolylineDecorator
|
||||
from bonsai.bim.module.model.polyline import PolylineOperator
|
||||
from bonsai.bim.module.project.data import LinksData, ProjectLibraryData
|
||||
@@ -1903,11 +1906,11 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
|
||||
|
||||
self.use_relative_path = tool.Project.get_project_props().use_relative_project_path
|
||||
props = tool.Blender.get_bim_props()
|
||||
if (filepath := props.ifc_file) and not self.should_save_as:
|
||||
self.filepath = str(tool.Blender.ensure_blender_path_is_abs(Path(filepath)))
|
||||
return self.execute(context)
|
||||
|
||||
return ExportHelper.invoke(self, context, event)
|
||||
filepath = props.ifc_file
|
||||
if not filepath or self.should_save_as:
|
||||
return ExportHelper.invoke(self, context, event)
|
||||
self.filepath = str(tool.Blender.ensure_blender_path_is_abs(Path(filepath)))
|
||||
return self.execute(context)
|
||||
|
||||
def check(self, context):
|
||||
# ExportHelper is automatically adjusting suffix to `filename_ext`.
|
||||
@@ -1933,6 +1936,20 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
|
||||
return {"FINISHED"}
|
||||
|
||||
def _execute(self, context):
|
||||
committed, failed_commits = tool.Parametric.commit_pending_edits()
|
||||
# Previews are session-transient — discard rather than commit. Sibling
|
||||
# gizmo polls gate on each preview's is_active flag, and a stuck flag
|
||||
# persisted through the save would silently hide them on reload.
|
||||
preview_base.discard_pending_previews(context.scene)
|
||||
# Suffix is appended to the IFC save-success report below so the auto-commit
|
||||
# info isn't immediately overwritten by the success message in Blender's
|
||||
# status bar (only the latest self.report({"INFO"}, ...) sticks).
|
||||
commit_suffix = f" (auto-committed {committed} pending parametric edit(s))" if committed else ""
|
||||
if failed_commits:
|
||||
names = ", ".join(o.name for o in failed_commits)
|
||||
msg = f"Auto-commit failed for {len(failed_commits)} object(s): {names}"
|
||||
print(f"Bonsai: {msg} (their drafts are NOT saved to the IFC file).")
|
||||
self.report({"ERROR"}, msg)
|
||||
start = time.time()
|
||||
logger = logging.getLogger("ExportIFC")
|
||||
path_log = tool.Blender.get_data_dir_path("process.log")
|
||||
@@ -2001,7 +2018,7 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
|
||||
blendmetadata_path = output_file + suffix
|
||||
self.report(
|
||||
{"INFO"},
|
||||
f'IFC Project "{os.path.basename(output_file)}" And Metadata File Saved to: {os.path.basename(blendmetadata_path)}',
|
||||
f'IFC Project "{os.path.basename(output_file)}" And Metadata File Saved to: {os.path.basename(blendmetadata_path)}{commit_suffix}',
|
||||
)
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, f"Failed to save blend metadata file: {e}")
|
||||
@@ -2011,7 +2028,7 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
|
||||
bpy.ops.wm.save_mainfile(filepath=bpy.data.filepath)
|
||||
self.report(
|
||||
{"INFO"},
|
||||
f'IFC Project "{os.path.basename(output_file)}" {"" if not save_blend_file else "And Current Blend File Are"} Saved',
|
||||
f'IFC Project "{os.path.basename(output_file)}" {"" if not save_blend_file else "And Current Blend File Are"} Saved{commit_suffix}',
|
||||
)
|
||||
|
||||
bonsai.bim.handler.refresh_ui_data()
|
||||
|
||||
@@ -302,6 +302,15 @@ class SelectSimilarContainer(bpy.types.Operator):
|
||||
is_recursive=self.is_recursive,
|
||||
)
|
||||
self.is_recursive = True # <-- forcibly reset
|
||||
|
||||
element = tool.Ifc.get_entity(context.active_object)
|
||||
if element:
|
||||
container = tool.Spatial.get_container(element)
|
||||
if container:
|
||||
result = f'location="{container.Name}"'
|
||||
bpy.context.window_manager.clipboard = result
|
||||
self.report({"INFO"}, f"({result}) was copied to the clipboard.")
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
|
||||
@@ -151,7 +151,8 @@ class BIM_PT_type_attributes(Panel):
|
||||
row = layout.row(align=True)
|
||||
row.label(text=attribute["name"])
|
||||
value = get_display_value(attribute["value"])
|
||||
row.label(text=value)
|
||||
op = row.operator("bim.select_similar", text=value, icon="NONE", emboss=False)
|
||||
op.key = "type." + attribute["name"]
|
||||
|
||||
|
||||
def add_object_button(self, context):
|
||||
|
||||
@@ -0,0 +1,611 @@
|
||||
# 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 operator mixins for parametric-edit operators.
|
||||
|
||||
Edit-lifecycle mixins (Enable / Finish / Cancel):
|
||||
`FeatureModifierEditMixin` — door, window (BBIM_<Type> pset; nested
|
||||
lining/panel properties; Finish + Cancel route through
|
||||
``ifcopenshell.api.feature``).
|
||||
`PathPreservingEditMixin` — railing, roof (path_data preserved across
|
||||
edit; only general kwargs are user-editable).
|
||||
|
||||
Pattern selection (which approach a new feature should adopt):
|
||||
Every parametric edit lifecycle commits to one of three patterns. Pick by
|
||||
answering "does the feature share the Enable→Finish→Cancel shape that
|
||||
one of the existing mixins already encodes?":
|
||||
|
||||
A. Inherit one of the shared mixins below and route through
|
||||
`tool.Parametric.build_edit_lifecycle`:
|
||||
|
||||
- `FeatureModifierEditMixin` when the feature stores its pset as
|
||||
`{general fields} + {lining_properties: {...}} + {panel_properties: {...}}`
|
||||
and Finish must call a per-type `update_<type>_modifier_representation`.
|
||||
|
||||
- `PathPreservingEditMixin` when the feature's pset carries a
|
||||
`path_data` field that survives general-kwarg edits untouched, with
|
||||
a separate Enable/Finish/Cancel lifecycle for path editing itself.
|
||||
|
||||
B. Write a per-feature mixin that subclasses `ParametricEditMixinBase`
|
||||
and provides `_enable_targets` / `_finish_targets` / `_cancel_targets`,
|
||||
then route through `build_edit_lifecycle`. Pick this when the
|
||||
feature's pset roundtrip or representation handling diverges from the
|
||||
shared mixins but the Enable→Finish→Cancel shape still fits.
|
||||
|
||||
C. Declare standalone Enable/Finish/Cancel Operator subclasses (no
|
||||
factory) when the feature's parameter-change logic is sufficiently
|
||||
unique that even a per-feature mixin would force optional hooks or
|
||||
dead branches. Such operators MUST call the matrix_world drift
|
||||
helpers (`tool.Geometry.commit_placement_if_moved` on Enable/Finish,
|
||||
`tool.Geometry.restore_or_rebaseline_placement` on Cancel) — the
|
||||
drift contract is enforced uniformly regardless of which pattern the
|
||||
operators 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`.
|
||||
|
||||
This module hosts operator-side mixins that import ``bonsai.tool`` freely.
|
||||
The lightweight parametric registry consumed at addon-enable time must stay
|
||||
free of such imports and lives separately in ``tool/parametric.py``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from typing import ClassVar, get_args
|
||||
|
||||
import bpy
|
||||
import ifcopenshell.util.element
|
||||
from bpy.app.handlers import persistent
|
||||
from ifcopenshell import entity_instance
|
||||
|
||||
import bonsai.core.geometry
|
||||
import bonsai.tool as tool
|
||||
|
||||
|
||||
class ParametricEditMixinBase:
|
||||
"""Common scaffolding for parametric edit-lifecycle mixins.
|
||||
|
||||
Each per-type subclass provides four hooks:
|
||||
|
||||
``pset_name``: BBIM_<Type> pset identifier
|
||||
``_is_element_type(element)``: IFC element predicate
|
||||
``_get_props(obj)``: PropertyGroup accessor
|
||||
``_iter_targets(context)``: list of objects to act on (default: ``[active_object]``)
|
||||
|
||||
Drift handling is built in: pre-edit matrix_world drift commits to IFC on
|
||||
Enable, in-edit drag commits on Finish, and Cancel restores the committed
|
||||
IFC placement. This prevents an uncommitted drag from disappearing on
|
||||
Finish or snapping back on Cancel.
|
||||
|
||||
Operator subclasses call one of ``_enable_targets`` / ``_finish_targets`` /
|
||||
``_cancel_targets`` from their ``_execute`` method."""
|
||||
|
||||
pset_name: ClassVar[str]
|
||||
|
||||
@classmethod
|
||||
def _iter_targets(cls, context: bpy.types.Context) -> list[bpy.types.Object]:
|
||||
obj = context.active_object
|
||||
return [obj] if obj else []
|
||||
|
||||
@classmethod
|
||||
def _is_element_type(cls, element: entity_instance) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
def _get_props(cls, obj: bpy.types.Object):
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
def _resolve(cls, obj: bpy.types.Object):
|
||||
"""Look up ``(element, props)`` for ``obj`` if it matches this type, else None.
|
||||
|
||||
Common predicate guard for every lifecycle method — collapses the
|
||||
``element = tool.Ifc.get_entity(obj); assert element; if not is_<type>(element): return``
|
||||
triplet into one call."""
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element or not cls._is_element_type(element):
|
||||
return None
|
||||
return element, cls._get_props(obj)
|
||||
|
||||
@classmethod
|
||||
def _handle_drift_on_enable(cls, obj: bpy.types.Object) -> None:
|
||||
tool.Geometry.commit_placement_if_moved(obj, apply_scale=False)
|
||||
|
||||
@classmethod
|
||||
def _handle_drift_on_finish(cls, obj: bpy.types.Object) -> None:
|
||||
tool.Geometry.commit_placement_if_moved(obj)
|
||||
|
||||
@classmethod
|
||||
def _handle_drift_on_cancel(cls, obj: bpy.types.Object, element: entity_instance) -> None:
|
||||
tool.Geometry.restore_or_rebaseline_placement(obj, element)
|
||||
|
||||
@classmethod
|
||||
def _mark_type_thumbnail_dirty(cls, element: entity_instance) -> None:
|
||||
"""Mark the element's type's preview thumbnail for refresh so the
|
||||
property-panel preview reflects post-edit geometry. No-op for
|
||||
occurrences without a backing type."""
|
||||
element_type = ifcopenshell.util.element.get_type(element)
|
||||
if element_type:
|
||||
tool.Model.mark_thumbnail_for_update(element_type)
|
||||
|
||||
|
||||
class FeatureModifierEditMixin(ParametricEditMixinBase):
|
||||
"""Lifecycle for door- and window-style parametric modifier operators.
|
||||
|
||||
Enable:
|
||||
Read BBIM_<Type> pset JSON → unwrap ``lining_properties`` and
|
||||
``panel_properties`` → merge constituents data → set draft props →
|
||||
``is_editing = True``.
|
||||
|
||||
Finish:
|
||||
Gather ``general / lining / panel`` kwargs (project units) → nest →
|
||||
``is_editing = False`` → call ``_update_modifier_representation`` →
|
||||
mark thumbnail → write back to BBIM_<Type> pset via
|
||||
``ifcopenshell.api.pset.edit_pset``.
|
||||
|
||||
Cancel:
|
||||
Read BBIM_<Type> pset JSON → unwrap → restore draft props →
|
||||
``switch_representation`` to the Body representation →
|
||||
``is_editing = False``."""
|
||||
|
||||
@classmethod
|
||||
def _update_modifier_representation(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
|
||||
"""Hook: call the per-type ``update_<type>_modifier_representation``."""
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
def _enable_one(cls, obj: bpy.types.Object) -> None:
|
||||
resolved = cls._resolve(obj)
|
||||
if resolved is None:
|
||||
return
|
||||
element, props = resolved
|
||||
cls._handle_drift_on_enable(obj)
|
||||
data = json.loads(ifcopenshell.util.element.get_pset(element, cls.pset_name, "Data"))
|
||||
data.update(data.pop("lining_properties"))
|
||||
data.update(data.pop("panel_properties"))
|
||||
data.update(tool.Model.get_constituents_props_data(element))
|
||||
# required since the pset can be loaded from .ifc and the PropertyGroup
|
||||
# would otherwise still hold its default values
|
||||
props.set_props_kwargs_from_ifc_data(data)
|
||||
props.is_editing = True
|
||||
|
||||
@classmethod
|
||||
def _finish_one(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
|
||||
resolved = cls._resolve(obj)
|
||||
if resolved is None:
|
||||
return
|
||||
element, props = resolved
|
||||
data = props.get_general_kwargs(convert_to_project_units=True)
|
||||
data["lining_properties"] = props.get_lining_kwargs(convert_to_project_units=True)
|
||||
data["panel_properties"] = props.get_panel_kwargs(convert_to_project_units=True)
|
||||
cls._update_modifier_representation(obj, context)
|
||||
cls._mark_type_thumbnail_dirty(element)
|
||||
tool.Pset.write_bbim_data(element, cls.pset_name, data)
|
||||
cls._handle_drift_on_finish(obj)
|
||||
# Set only on success: if any IFC op above raised, the user's draft survives for retry.
|
||||
props.is_editing = False
|
||||
|
||||
@classmethod
|
||||
def _cancel_one(cls, obj: bpy.types.Object) -> None:
|
||||
resolved = cls._resolve(obj)
|
||||
if resolved is None:
|
||||
return
|
||||
element, props = resolved
|
||||
# Cancel must always clear is_editing — leaving it True after a
|
||||
# restore-failure would block the user from re-entering edit mode and
|
||||
# the next save's stale-flag heal would silently roll back the
|
||||
# cancellation. Wrap the restore in try/finally so the flag flips
|
||||
# even on partial failure.
|
||||
try:
|
||||
data = json.loads(ifcopenshell.util.element.get_pset(element, cls.pset_name, "Data"))
|
||||
data.update(data.pop("lining_properties"))
|
||||
data.update(data.pop("panel_properties"))
|
||||
props.set_props_kwargs_from_ifc_data(data)
|
||||
body = tool.Geometry.get_body_representation(element)
|
||||
bonsai.core.geometry.switch_representation(tool.Ifc, tool.Geometry, obj=obj, representation=body)
|
||||
cls._handle_drift_on_cancel(obj, element)
|
||||
finally:
|
||||
props.is_editing = False
|
||||
|
||||
def _enable_targets(self, context: bpy.types.Context) -> set[str]:
|
||||
for obj in self._iter_targets(context):
|
||||
self._enable_one(obj)
|
||||
return {"FINISHED"}
|
||||
|
||||
def _finish_targets(self, context: bpy.types.Context) -> set[str]:
|
||||
for obj in self._iter_targets(context):
|
||||
self._finish_one(obj, context)
|
||||
return {"FINISHED"}
|
||||
|
||||
def _cancel_targets(self, context: bpy.types.Context) -> set[str]:
|
||||
for obj in self._iter_targets(context):
|
||||
self._cancel_one(obj)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class PathPreservingEditMixin(ParametricEditMixinBase):
|
||||
"""Lifecycle for railing- and roof-style parametric modifier operators.
|
||||
|
||||
Distinctive: ``path_data`` is part of the BBIM_<Type> pset but is **not**
|
||||
user-editable through this lifecycle — it survives the edit untouched, only
|
||||
general kwargs are diffed. (Path editing has its own separate operator
|
||||
pair, ``Enable/Finish/CancelEditing<Type>Path``, out of scope here.)
|
||||
|
||||
Enable:
|
||||
Fetch pset data via ``tool.Model.get_modeling_bbim_pset_data`` → set
|
||||
draft props → ``is_editing = True``. The subclass post-load hook
|
||||
can reshape the dict to fit the PropertyGroup's storage layout
|
||||
(e.g., pre-serialise a structured pset value to JSON for a
|
||||
``StringProperty`` field).
|
||||
|
||||
Finish:
|
||||
Read fresh pset → keep ``path_data`` → gather ``general`` kwargs
|
||||
(project units) → reassemble → ``is_editing = False`` → call
|
||||
``_update_pset`` (per-type pset writer) → call ``_update_modifier_ifc_data``
|
||||
(per-type geometry commit).
|
||||
|
||||
Cancel:
|
||||
Read fresh pset → restore draft props → call
|
||||
``_restore_viewport_after_cancel`` (per-type viewport restore — typically
|
||||
rebuilds the bmesh preview, but subclasses may load a different
|
||||
representation entirely) → ``is_editing = False``."""
|
||||
|
||||
@classmethod
|
||||
def _post_load_data(cls, data: dict) -> dict:
|
||||
"""Hook: optionally transform the pset data dict after loading and before
|
||||
passing to ``set_props_kwargs_from_ifc_data``. Default: pass-through.
|
||||
|
||||
Override when the PropertyGroup stores a structured pset field as a
|
||||
serialised primitive — e.g., a list/dict value mapped onto a
|
||||
``StringProperty`` requires JSON-encoding here."""
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def _update_pset(cls, element: entity_instance, data: dict) -> None:
|
||||
"""Hook: per-type pset writer (``update_bbim_<type>_pset``)."""
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
def _update_modifier_ifc_data(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
|
||||
"""Hook: per-type ``update_<type>_modifier_ifc_data`` — commits the
|
||||
modified geometry to IFC. Signature accepts ``(obj, context)`` so
|
||||
subclasses can forward either argument to their existing helper."""
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
def _restore_viewport_after_cancel(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
|
||||
"""Hook: restore the viewport mesh to match the just-restored draft props.
|
||||
|
||||
Most subclasses rebuild a bmesh preview from props. Subclasses whose
|
||||
committed IFC representation diverges from the preview may switch
|
||||
the mesh back to the committed representation instead."""
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
def _enable_one(cls, obj: bpy.types.Object) -> None:
|
||||
resolved = cls._resolve(obj)
|
||||
if resolved is None:
|
||||
return
|
||||
_element, props = resolved
|
||||
cls._handle_drift_on_enable(obj)
|
||||
data = tool.Model.get_modeling_bbim_pset_data(obj, cls.pset_name)["data_dict"]
|
||||
data = cls._post_load_data(data)
|
||||
props.set_props_kwargs_from_ifc_data(data)
|
||||
props.is_editing = True
|
||||
|
||||
@classmethod
|
||||
def _finish_one(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
|
||||
resolved = cls._resolve(obj)
|
||||
if resolved is None:
|
||||
return
|
||||
element, props = resolved
|
||||
pset_data = tool.Model.get_modeling_bbim_pset_data(obj, cls.pset_name)
|
||||
stored = pset_data["data_dict"]
|
||||
data = props.get_general_kwargs(convert_to_project_units=True)
|
||||
data["path_data"] = stored["path_data"]
|
||||
# Skip the pset commit when the draft is identical to the stored pset:
|
||||
# an Enable → Finish-without-changes cycle should not pollute the
|
||||
# representation list or burn an undo entry. Drift commit still runs
|
||||
# unconditionally — matrix_world drift is independent of pset content.
|
||||
if data != stored:
|
||||
cls._update_pset(element, data)
|
||||
cls._update_modifier_ifc_data(obj, context)
|
||||
cls._mark_type_thumbnail_dirty(element)
|
||||
cls._handle_drift_on_finish(obj)
|
||||
# Set only on success: if any IFC op above raised, the user's draft survives for retry.
|
||||
props.is_editing = False
|
||||
|
||||
@classmethod
|
||||
def _cancel_one(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
|
||||
resolved = cls._resolve(obj)
|
||||
if resolved is None:
|
||||
return
|
||||
element, props = resolved
|
||||
try:
|
||||
pset_data = tool.Model.get_modeling_bbim_pset_data(obj, cls.pset_name)
|
||||
stored = pset_data["data_dict"]
|
||||
draft = props.get_general_kwargs(convert_to_project_units=True)
|
||||
draft["path_data"] = stored["path_data"]
|
||||
nothing_changed = draft == stored
|
||||
data = cls._post_load_data(stored)
|
||||
props.set_props_kwargs_from_ifc_data(data)
|
||||
# Skip the viewport rebuild on a no-op cancel: the mesh on screen is
|
||||
# still the committed representation, and the per-type viewport-restore
|
||||
# hook may be expensive (some subclasses reload a high-poly IFC
|
||||
# representation rather than rebuild a preview mesh).
|
||||
if not nothing_changed:
|
||||
cls._restore_viewport_after_cancel(obj, context)
|
||||
cls._handle_drift_on_cancel(obj, element)
|
||||
finally:
|
||||
# Always clear the flag — see ``FeatureModifierEditMixin._cancel_one``
|
||||
# for the rationale.
|
||||
props.is_editing = False
|
||||
|
||||
def _enable_targets(self, context: bpy.types.Context) -> set[str]:
|
||||
for obj in self._iter_targets(context):
|
||||
self._enable_one(obj)
|
||||
return {"FINISHED"}
|
||||
|
||||
def _finish_targets(self, context: bpy.types.Context) -> set[str]:
|
||||
for obj in self._iter_targets(context):
|
||||
self._finish_one(obj, context)
|
||||
return {"FINISHED"}
|
||||
|
||||
def _cancel_targets(self, context: bpy.types.Context) -> set[str]:
|
||||
for obj in self._iter_targets(context):
|
||||
self._cancel_one(obj, context)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
# --- Type-selection mixins (Cycle / Pick) ------------------------------------
|
||||
|
||||
|
||||
class TypeAccessorBase:
|
||||
"""Shared contract for operators that resolve and write a Literal type
|
||||
attribute on a Bonsai PropertyGroup.
|
||||
|
||||
Subclasses define ``element_checker``, ``props_getter``, ``type_literal``,
|
||||
``type_attr``; ``skip_element_check`` bypasses element validation. Concrete
|
||||
subclasses (``CycleTypeMixin``, ``PickTypeMixin``) add the interaction
|
||||
shape on top.
|
||||
|
||||
Test doubles must be set on the operator instance — the predicates are
|
||||
bound at class-definition time, so patching the underlying tool module
|
||||
has no effect."""
|
||||
|
||||
element_checker: Callable[[entity_instance], bool]
|
||||
props_getter: Callable[[bpy.types.Object], bpy.types.PropertyGroup]
|
||||
type_literal: type
|
||||
type_attr: str
|
||||
skip_element_check: bool = False
|
||||
|
||||
def _resolve_target(self, context: bpy.types.Context) -> bpy.types.Object | None:
|
||||
"""Return the active object iff it passes ``element_checker`` (or the
|
||||
check is skipped). ``None`` signals the operator should bail with
|
||||
``{'CANCELLED'}``."""
|
||||
obj = context.active_object
|
||||
if not obj:
|
||||
return None
|
||||
if not self.skip_element_check:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element or not self.element_checker(element):
|
||||
return None
|
||||
return obj
|
||||
|
||||
|
||||
class CycleTypeMixin(TypeAccessorBase):
|
||||
"""Operator mixin that cycles through ``type_literal``'s values.
|
||||
|
||||
Shift-click reverses direction."""
|
||||
|
||||
reverse: bpy.props.BoolProperty(name="Reverse", default=False, options={"HIDDEN", "SKIP_SAVE"})
|
||||
|
||||
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]:
|
||||
self.reverse = event.shift
|
||||
return self.execute(context)
|
||||
|
||||
def _cycle_type(self, context: bpy.types.Context) -> set[str]:
|
||||
obj = self._resolve_target(context)
|
||||
if obj is None:
|
||||
return {"CANCELLED"}
|
||||
|
||||
props = self.props_getter(obj)
|
||||
types = get_args(self.type_literal)
|
||||
current = getattr(props, self.type_attr)
|
||||
idx = types.index(current) if current in types else 0
|
||||
direction = -1 if self.reverse else 1
|
||||
setattr(props, self.type_attr, types[(idx + direction) % len(types)])
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class PickTypeMixin(TypeAccessorBase):
|
||||
"""Operator mixin that opens a popup menu listing ``type_literal``'s values.
|
||||
|
||||
Empty ``value`` ⇒ ``invoke`` opens the popup; non-empty ⇒ the user picked
|
||||
an item and ``_pick_type`` applies it.
|
||||
|
||||
When invoked mid-click (e.g. from a gizmo's ``target_set_operator``), the
|
||||
menu opens only after the originating ``LEFTMOUSE`` releases. Otherwise
|
||||
the still-pressed click flows straight into Blender's drag-through-pick
|
||||
gesture and the menu commits whichever item the cursor drifts over on
|
||||
release. Other invocation paths (command-palette / F3, EXEC_DEFAULT, F6
|
||||
redo) bypass the wait and open the menu immediately.
|
||||
|
||||
The ``value`` StringProperty is declared on this mixin but registered via
|
||||
the concrete Operator subclass's MRO scan — do not instantiate the mixin
|
||||
standalone."""
|
||||
|
||||
# Carries the picked value through invoke→execute; empty default
|
||||
# distinguishes "open popup" from "apply".
|
||||
value: bpy.props.StringProperty(default="", options={"HIDDEN", "SKIP_SAVE"})
|
||||
|
||||
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]:
|
||||
"""Open the picker menu, or apply a value that was preset by a
|
||||
menu-item click.
|
||||
|
||||
Routing through ``execute()`` keeps subclass IFC-transaction wrapping
|
||||
in the loop and means F6 redo / ``EXEC_DEFAULT`` reach the apply path."""
|
||||
if self.value:
|
||||
return self.execute(context)
|
||||
|
||||
if self._resolve_target(context) is None:
|
||||
return {"CANCELLED"}
|
||||
|
||||
if event.value == "PRESS":
|
||||
context.window_manager.modal_handler_add(self)
|
||||
return {"RUNNING_MODAL"}
|
||||
return self._open_picker(context)
|
||||
|
||||
def modal(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]:
|
||||
if event.type == "LEFTMOUSE" and event.value == "RELEASE":
|
||||
self._open_picker(context)
|
||||
# INTERFACE does not remove a modal handler; only FINISHED /
|
||||
# CANCELLED do.
|
||||
return {"CANCELLED"}
|
||||
if event.type in {"RIGHTMOUSE", "ESC"}:
|
||||
return {"CANCELLED"}
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
def _open_picker(self, context: bpy.types.Context) -> set[str]:
|
||||
bl_idname = self.bl_idname
|
||||
values = list(get_args(self.type_literal))
|
||||
|
||||
def draw(menu_self, _menu_context):
|
||||
layout = menu_self.layout
|
||||
for v in values:
|
||||
op = layout.operator(bl_idname, text=v)
|
||||
op.value = v
|
||||
|
||||
context.window_manager.popup_menu(draw, title=self.bl_label, icon="MENU_PANEL")
|
||||
# The type change is a two-step interaction: this invocation just OPENS
|
||||
# the menu (no state change yet); a SECOND invocation fires when the
|
||||
# user clicks a menu item — that one writes ``props.<type_attr>`` and
|
||||
# returns FINISHED. By returning INTERFACE here (and not FINISHED), the
|
||||
# menu-open step is excluded from Blender's undo stack so the user
|
||||
# gets exactly ONE undo entry per type change. If we returned FINISHED
|
||||
# here too, the stack would gain a no-op "opened the menu" entry that
|
||||
# Ctrl+Z would dismiss before reverting the actual type change —
|
||||
# confusing UX where the first Ctrl+Z appears to do nothing.
|
||||
return {"INTERFACE"}
|
||||
|
||||
def _pick_type(self, context: bpy.types.Context) -> set[str]:
|
||||
if not self.value:
|
||||
# No-op rather than re-open the menu, so command-palette misuse
|
||||
# doesn't infinite-loop.
|
||||
return {"CANCELLED"}
|
||||
|
||||
obj = self._resolve_target(context)
|
||||
if obj is None:
|
||||
return {"CANCELLED"}
|
||||
|
||||
if self.value not in get_args(self.type_literal):
|
||||
self.report({"WARNING"}, f"Unknown {self.type_attr}: {self.value!r}")
|
||||
return {"CANCELLED"}
|
||||
|
||||
props = self.props_getter(obj)
|
||||
setattr(props, self.type_attr, self.value)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
# --- Undo-resync registry ----------------------------------------------------
|
||||
#
|
||||
# Per-type regenerators called from ``resync_parametric_drafts_after_undo``
|
||||
# (wired into ``bim/handler.py:undo_post`` and ``redo_post``) so the preview
|
||||
# mesh of an in-progress parametric draft repaints after Ctrl+Z / Ctrl+Shift+Z.
|
||||
#
|
||||
# Each regenerator is a one-line lazy-import + call. Lazy imports because
|
||||
# ``bonsai.bim.parametric_lifecycle`` loads before ``bim/module/model/*``
|
||||
# at addon enable; a module-level import would cycle. Each function-local
|
||||
# import lands at first call, after the feature module has registered.
|
||||
#
|
||||
# Types with no entry — door, window, railing, etc. — are IFC-derived: undo
|
||||
# of an IFC mutation already restores the entity, and ``switch_representation``
|
||||
# repaints the mesh as a side effect of the next refresh. They don't need a
|
||||
# bespoke preview regenerator.
|
||||
|
||||
|
||||
def _wall_undo_regenerator(obj: bpy.types.Object) -> None:
|
||||
from bonsai.bim.module.model.wall import regenerate_wall_mesh_from_props
|
||||
|
||||
regenerate_wall_mesh_from_props(obj)
|
||||
|
||||
|
||||
def _stair_undo_regenerator(obj: bpy.types.Object) -> None:
|
||||
from bonsai.bim.module.model.stair import regenerate_stair_mesh
|
||||
|
||||
regenerate_stair_mesh(obj)
|
||||
|
||||
|
||||
def _roof_undo_regenerator(obj: bpy.types.Object) -> None:
|
||||
from bonsai.bim.module.model.roof import update_roof_modifier_bmesh
|
||||
|
||||
update_roof_modifier_bmesh(obj)
|
||||
|
||||
|
||||
UNDO_REGENERATORS: dict[str, Callable[[bpy.types.Object], None]] = {
|
||||
"wall": _wall_undo_regenerator,
|
||||
"stair": _stair_undo_regenerator,
|
||||
"roof": _roof_undo_regenerator,
|
||||
}
|
||||
|
||||
|
||||
def resync_parametric_drafts_after_undo() -> None:
|
||||
"""Re-render preview meshes for every parametric draft currently active.
|
||||
|
||||
Walks all objects, skips any not in a registered parametric edit,
|
||||
dispatches to the per-type regenerator in ``UNDO_REGENERATORS``. A type
|
||||
without an entry is left alone — its preview is either already correct
|
||||
(IFC-derived) or has no draft preview mesh."""
|
||||
for obj in bpy.data.objects:
|
||||
feature = tool.Parametric.is_object_editing(obj)
|
||||
if feature is None:
|
||||
continue
|
||||
regenerator = UNDO_REGENERATORS.get(feature.name)
|
||||
if regenerator is None:
|
||||
continue
|
||||
regenerator(obj)
|
||||
tool.Blender.update_all_viewports()
|
||||
|
||||
|
||||
@persistent
|
||||
def _resync_on_undo(scene: bpy.types.Scene) -> None:
|
||||
resync_parametric_drafts_after_undo()
|
||||
|
||||
|
||||
def install_parametric_lifecycle_handlers() -> None:
|
||||
"""Append the undo-resync callback to undo_post and redo_post; idempotent.
|
||||
|
||||
Caller must invoke this AFTER appending the central undo/redo handlers so
|
||||
regenerators see restored IFC state — bpy.app.handlers fire in append order."""
|
||||
for hook in (bpy.app.handlers.undo_post, bpy.app.handlers.redo_post):
|
||||
if _resync_on_undo not in hook:
|
||||
hook.append(_resync_on_undo)
|
||||
|
||||
|
||||
def uninstall_parametric_lifecycle_handlers() -> None:
|
||||
for hook in (bpy.app.handlers.undo_post, bpy.app.handlers.redo_post):
|
||||
try:
|
||||
hook.remove(_resync_on_undo)
|
||||
except ValueError:
|
||||
pass
|
||||
+112
-31
@@ -15,6 +15,8 @@
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was modified with the assistance of an AI coding tool.
|
||||
|
||||
import os
|
||||
import platform
|
||||
@@ -380,6 +382,76 @@ class GizmoPreferencesStair(bpy.types.PropertyGroup):
|
||||
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."""
|
||||
|
||||
@@ -391,12 +463,14 @@ class GizmoPreferences(bpy.types.PropertyGroup):
|
||||
door: bpy.props.PointerProperty(type=GizmoPreferencesDoor)
|
||||
window: bpy.props.PointerProperty(type=GizmoPreferencesWindow)
|
||||
stair: bpy.props.PointerProperty(type=GizmoPreferencesStair)
|
||||
wall: bpy.props.PointerProperty(type=GizmoPreferencesWall)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
draw_gizmos_in_3d_viewport: bool
|
||||
door: GizmoPreferencesDoor
|
||||
window: GizmoPreferencesWindow
|
||||
stair: GizmoPreferencesStair
|
||||
wall: GizmoPreferencesWall
|
||||
|
||||
|
||||
class DocPreferences(bpy.types.PropertyGroup):
|
||||
@@ -849,49 +923,56 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
|
||||
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
|
||||
|
||||
door_gizmos = self.gizmos.door
|
||||
gizmo_prop_names = {p.attr_name for p in GizmoDoorEdition.dimension_gizmo_props}
|
||||
# Add special gizmos not in dimension_gizmo_props
|
||||
gizmo_prop_names.update(("swing_arc", "flip_arc"))
|
||||
try:
|
||||
annotations = door_gizmos.__annotations__
|
||||
except AttributeError:
|
||||
annotations = type(door_gizmos).__annotations__
|
||||
for prop in annotations:
|
||||
if prop in gizmo_prop_names:
|
||||
layout.prop(door_gizmos, prop)
|
||||
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
|
||||
|
||||
window_gizmos = self.gizmos.window
|
||||
gizmo_prop_names = {p.attr_name for p in GizmoWindowEdition.dimension_gizmo_props}
|
||||
try:
|
||||
annotations = window_gizmos.__annotations__
|
||||
except AttributeError:
|
||||
annotations = type(window_gizmos).__annotations__
|
||||
for prop in annotations:
|
||||
if prop in gizmo_prop_names:
|
||||
layout.prop(window_gizmos, prop)
|
||||
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
|
||||
|
||||
stair_gizmos = self.gizmos.stair
|
||||
gizmo_prop_names = {p.attr_name for p in GizmoStairEdition.dimension_gizmo_props}
|
||||
# Add special gizmos not in dimension_gizmo_props
|
||||
special_gizmo_names = {"lock", "plus", "minus", "cycle"}
|
||||
try:
|
||||
annotations = stair_gizmos.__annotations__
|
||||
except AttributeError:
|
||||
annotations = type(stair_gizmos).__annotations__
|
||||
for prop in annotations:
|
||||
if prop in gizmo_prop_names or prop in special_gizmo_names:
|
||||
layout.prop(stair_gizmos, prop)
|
||||
self._draw_parametric_gizmo_parameters(
|
||||
layout, self.gizmos.stair, GizmoStairEdition, frozenset({"lock", "plus", "minus", "cycle"})
|
||||
)
|
||||
|
||||
def draw_wall_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
|
||||
from bonsai.bim.module.model.wall import GizmoWallEdition
|
||||
|
||||
self._draw_parametric_gizmo_parameters(
|
||||
layout,
|
||||
self.gizmos.wall,
|
||||
GizmoWallEdition,
|
||||
frozenset({"cycle", "scissors", "extend", "extend_height", "rotate", "toggle_openings"}),
|
||||
)
|
||||
|
||||
def draw_model_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
|
||||
layout.prop(self, "occurrence_name_style")
|
||||
|
||||
@@ -93,6 +93,8 @@ def enter_aggregate_mode(
|
||||
aggregator: type[tool.Aggregate],
|
||||
obj: bpy.types.Object,
|
||||
):
|
||||
if not aggregator.get_aggregate_props().in_aggregate_mode:
|
||||
aggregator.save_previous_selection()
|
||||
aggregator.update_previous_aggregate_mode_state()
|
||||
if aggregator.get_higher_aggregate():
|
||||
aggregator.disable_aggregate_mode()
|
||||
@@ -107,6 +109,7 @@ def exit_aggregate_mode(aggregator: type[tool.Aggregate]):
|
||||
aggregator.enable_aggregate_mode(new_obj)
|
||||
else:
|
||||
aggregator.disable_aggregate_mode()
|
||||
aggregator.restore_previous_selection()
|
||||
|
||||
|
||||
class IncompatibleAggregateError(Exception):
|
||||
|
||||
@@ -15,10 +15,13 @@
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was modified with the assistance of an AI coding tool.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Literal, Optional
|
||||
import math
|
||||
from typing import TYPE_CHECKING, Any, Literal, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import bpy
|
||||
@@ -31,6 +34,24 @@ if TYPE_CHECKING:
|
||||
OffsetType = Literal["CENTER", "EXTERIOR", "INTERIOR"]
|
||||
|
||||
|
||||
# Arc sample count for fillet preview polylines. 24 samples produces a visually
|
||||
# smooth arc at common viewport scales without bloating the GPU batch.
|
||||
FILLET_DEFAULT_ARC_RESOLUTION = 24
|
||||
# Dot-product floor for treating two wall-axis segments as parallel — below
|
||||
# this the projected intersection is too sensitive to floating-point noise
|
||||
# to be useful as a junction apex. Calibrated to ~2° from parallel.
|
||||
PARALLEL_DOT_THRESHOLD = 0.9994
|
||||
# Perpendicular distance (SI metres) under which two parallel wall axes are
|
||||
# considered to share the same infinite line. Calibrated to absorb sub-50mm
|
||||
# placement drift between authored-joined walls without merging genuinely
|
||||
# offset parallel walls.
|
||||
COLLINEAR_LINE_TOLERANCE = 0.05
|
||||
# Default proximity (SI metres) for classifying a layer offset against the
|
||||
# canonical EXTERIOR / CENTER / INTERIOR baselines. Tight enough that ordinary
|
||||
# millimetre-scale modelling intent always falls into the nearest baseline.
|
||||
BASELINE_OFFSET_TOLERANCE = 0.001
|
||||
|
||||
|
||||
def unjoin_walls(
|
||||
ifc: type[tool.Ifc],
|
||||
blender: type[tool.Blender],
|
||||
@@ -140,23 +161,73 @@ def align_objects(
|
||||
model.align_objects(reference_obj, objs, align_type)
|
||||
|
||||
|
||||
def regenerate_wall_to_underside(
|
||||
ifc: type[tool.Ifc],
|
||||
geometry: type[tool.Geometry],
|
||||
model: type[tool.Model],
|
||||
wall_objs: list[bpy.types.Object],
|
||||
) -> None:
|
||||
"""Re-clip walls to their connected underside objects after the slab has moved."""
|
||||
clipped_objs = []
|
||||
for obj in wall_objs:
|
||||
wall = ifc.get_entity(obj)
|
||||
slab_objs = model.get_connected_slab_objs(wall)
|
||||
if not slab_objs:
|
||||
continue
|
||||
if ifc.is_moved(obj):
|
||||
geometry.run_edit_object_placement(obj=obj)
|
||||
# Sync each slab's Blender mesh to its current IFC representation before
|
||||
# reading face geometry, so a changed profile is picked up correctly.
|
||||
model.reload_body_representation(slab_objs)
|
||||
model.remove_wall_to_underside_booleans(wall)
|
||||
for slab_obj in slab_objs:
|
||||
clip = model.get_slab_clipping_bmesh(slab_obj)
|
||||
if clip:
|
||||
model.clip_wall_to_slab(wall, clip)
|
||||
clipped_objs.append(obj)
|
||||
if clipped_objs:
|
||||
model.reload_body_representation(clipped_objs)
|
||||
|
||||
|
||||
def extend_wall_to_slab(
|
||||
ifc: type[tool.Ifc],
|
||||
geometry: type[tool.Geometry],
|
||||
model: type[tool.Model],
|
||||
slab_obj: bpy.types.Object,
|
||||
slab_objs: list[bpy.types.Object],
|
||||
wall_objs: list[bpy.types.Object],
|
||||
) -> None:
|
||||
if not (clip := model.get_slab_clipping_bmesh(slab_obj)):
|
||||
return # Nothing to clip?
|
||||
slab = ifc.get_entity(slab_obj)
|
||||
# If any wall is currently in item mode, exit it before modifying the
|
||||
# representation. Leaving stale item objects around causes delete_ifc_item
|
||||
# to later remove the extrusion (or other pre-boolean items) from inside
|
||||
# the boolean chain, corrupting the IFC model.
|
||||
geom_props = geometry.get_geometry_props()
|
||||
if geom_props.representation_obj in wall_objs:
|
||||
geometry.disable_item_mode()
|
||||
clipped_walls = []
|
||||
for obj in wall_objs:
|
||||
if ifc.is_moved(obj):
|
||||
geometry.run_edit_object_placement(obj=obj)
|
||||
wall = ifc.get_entity(obj)
|
||||
model.clip_wall_to_slab(wall, clip)
|
||||
model.connect_wall_to_slab(wall, slab)
|
||||
model.reload_body_representation(wall_objs)
|
||||
# Merge previously connected slabs with newly requested ones so that
|
||||
# re-running the operator never produces duplicate booleans and never
|
||||
# silently discards clips that were applied in an earlier call.
|
||||
existing = model.get_connected_slab_objs(wall)
|
||||
seen = {id(s) for s in existing}
|
||||
all_slab_objs = list(existing) + [s for s in slab_objs if id(s) not in seen]
|
||||
# Remove stale booleans once, then re-clip against the full set.
|
||||
model.remove_wall_to_underside_booleans(wall)
|
||||
did_clip = False
|
||||
for slab_obj in all_slab_objs:
|
||||
clip = model.get_slab_clipping_bmesh(slab_obj)
|
||||
if not clip:
|
||||
continue
|
||||
model.clip_wall_to_slab(wall, clip)
|
||||
model.connect_wall_to_slab(wall, ifc.get_entity(slab_obj))
|
||||
did_clip = True
|
||||
if did_clip:
|
||||
clipped_walls.append(obj)
|
||||
if clipped_walls:
|
||||
model.reload_body_representation(clipped_walls)
|
||||
|
||||
|
||||
class RequireTwoWallsError(Exception):
|
||||
@@ -173,3 +244,438 @@ class RequireAtLeastTwoElements(Exception):
|
||||
|
||||
class RequireLayeredElement(Exception):
|
||||
pass
|
||||
|
||||
|
||||
# --- Wall geometry math (pure) ------------------------------------------------
|
||||
# Tuple in / tuple out so these helpers run without ``bpy`` or ``mathutils``.
|
||||
# Callers convert ``mathutils.Vector`` at the boundary.
|
||||
|
||||
|
||||
def baseline_from_offset(offset: float, thickness: float, tolerance: float = BASELINE_OFFSET_TOLERANCE) -> str:
|
||||
"""Classify a numeric layer offset as EXTERIOR / CENTER / INTERIOR.
|
||||
|
||||
Handles both POSITIVE and NEGATIVE direction_sense walls. Returns the
|
||||
closest canonical baseline; falls back to ``"CENTER"`` when nothing is
|
||||
within ``tolerance``."""
|
||||
candidates = (
|
||||
("EXTERIOR", 0.0),
|
||||
("CENTER", -thickness / 2),
|
||||
("INTERIOR", -thickness),
|
||||
("EXTERIOR", thickness),
|
||||
("CENTER", thickness / 2),
|
||||
("INTERIOR", 0.0),
|
||||
)
|
||||
best = min(candidates, key=lambda c: abs(offset - c[1]))
|
||||
return best[0] if abs(offset - best[1]) < tolerance else "CENTER"
|
||||
|
||||
|
||||
def project_axis_intersection(
|
||||
seg_a: tuple[tuple[float, float, float], tuple[float, float, float]],
|
||||
seg_b: tuple[tuple[float, float, float], tuple[float, float, float]],
|
||||
parallel_threshold: float,
|
||||
) -> Optional[tuple[float, float, float]]:
|
||||
"""Compute the 2D (X,Y plane) intersection of two world-space axis segments.
|
||||
|
||||
Each segment is a pair of 3-tuples. Returns the intersection as a 3-tuple
|
||||
(Z is the average of the four input Zs, for visual placement) or ``None`` if
|
||||
the segments are parallel within ``parallel_threshold`` (a dot-product magnitude
|
||||
threshold — see ``PARALLEL_DOT_THRESHOLD`` for the calibrated value)."""
|
||||
p1, p2 = seg_a
|
||||
p3, p4 = seg_b
|
||||
d1x, d1y = p2[0] - p1[0], p2[1] - p1[1]
|
||||
d2x, d2y = p4[0] - p3[0], p4[1] - p3[1]
|
||||
d1_len = (d1x * d1x + d1y * d1y) ** 0.5
|
||||
d2_len = (d2x * d2x + d2y * d2y) ** 0.5
|
||||
if d1_len < 1e-9 or d2_len < 1e-9:
|
||||
return None
|
||||
dot = (d1x * d2x + d1y * d2y) / (d1_len * d2_len)
|
||||
if abs(dot) >= parallel_threshold:
|
||||
return None
|
||||
denom = d1x * d2y - d1y * d2x
|
||||
if abs(denom) < 1e-9:
|
||||
return None
|
||||
t = ((p3[0] - p1[0]) * d2y - (p3[1] - p1[1]) * d2x) / denom
|
||||
ix = p1[0] + t * d1x
|
||||
iy = p1[1] + t * d1y
|
||||
iz = (p1[2] + p2[2] + p3[2] + p4[2]) / 4
|
||||
return (ix, iy, iz)
|
||||
|
||||
|
||||
def opening_is_past_cut(min_t: float, cut_percentage: float) -> bool:
|
||||
"""True when the opening's near edge sits past the cut on the t axis.
|
||||
|
||||
Strict inequality is load-bearing: a boundary touch or NaN keeps the
|
||||
opening on both walls — the safe default when extent resolution fails."""
|
||||
return min_t > cut_percentage
|
||||
|
||||
|
||||
def opening_is_before_cut(max_t: float, cut_percentage: float) -> bool:
|
||||
"""True when the opening's far edge sits before the cut on the t axis."""
|
||||
return max_t < cut_percentage
|
||||
|
||||
|
||||
def opening_straddles_cut(min_t: float, max_t: float, cut_percentage: float) -> bool:
|
||||
"""True when the opening's extent crosses the cut on the t axis."""
|
||||
return min_t < cut_percentage < max_t
|
||||
|
||||
|
||||
WallJoinState = Literal["joined", "collinear", "intersect", "none"]
|
||||
|
||||
|
||||
def classify_wall_join_state(
|
||||
seg_a: tuple[tuple[float, float, float], tuple[float, float, float]],
|
||||
seg_b: tuple[tuple[float, float, float], tuple[float, float, float]],
|
||||
are_joined: bool,
|
||||
parallel_threshold: float,
|
||||
collinear_tolerance: float,
|
||||
) -> tuple[WallJoinState, Optional[tuple[float, float, float]]]:
|
||||
"""Classify a wall pair's geometric state — ``(state, intersection)``.
|
||||
|
||||
Priority: ``"joined"`` (caller-supplied flag) → ``"collinear"`` →
|
||||
``"intersect"`` (projected point returned) → ``"none"`` (parallel,
|
||||
non-collinear)."""
|
||||
if are_joined:
|
||||
return "joined", None
|
||||
if are_axes_collinear(seg_a, seg_b, parallel_threshold, collinear_tolerance):
|
||||
return "collinear", None
|
||||
intersection = project_axis_intersection(seg_a, seg_b, parallel_threshold)
|
||||
if intersection is None:
|
||||
return "none", None
|
||||
return "intersect", intersection
|
||||
|
||||
|
||||
def wall_join_preview_lines(
|
||||
seg_a: tuple[tuple[float, float, float], tuple[float, float, float]],
|
||||
seg_b: tuple[tuple[float, float, float], tuple[float, float, float]],
|
||||
intersection: tuple[float, float, float],
|
||||
) -> list[tuple[tuple[float, float, float], tuple[float, float, float]]]:
|
||||
"""Two segments showing each wall axis extending to ``intersection``.
|
||||
|
||||
Each segment runs from the input axis's nearest endpoint to the
|
||||
intersection, held at that wall's own Z. Returned in input order
|
||||
``[floor_a, floor_b]``."""
|
||||
ix, iy, _ = intersection
|
||||
|
||||
def _nearest(seg: tuple[tuple[float, float, float], tuple[float, float, float]]) -> tuple[float, float, float]:
|
||||
return min(seg, key=lambda p: (p[0] - ix) ** 2 + (p[1] - iy) ** 2)
|
||||
|
||||
near_a = _nearest(seg_a)
|
||||
near_b = _nearest(seg_b)
|
||||
return [
|
||||
(near_a, (ix, iy, near_a[2])),
|
||||
(near_b, (ix, iy, near_b[2])),
|
||||
]
|
||||
|
||||
|
||||
def resolve_extend_walls_target(
|
||||
target_obj: Any,
|
||||
objs: list[Any],
|
||||
reverse: bool,
|
||||
) -> tuple[Any, list[Any]]:
|
||||
"""Pick which object is the extend-target and which are extended.
|
||||
|
||||
Default direction: ``objs`` are extended to meet ``target_obj``.
|
||||
Reversed direction (``reverse=True``) swaps the pair — equivalent to
|
||||
having passed them in the opposite order. The swap is well-defined only
|
||||
for the 1+1 case (one target + one other); for ``n>1`` it would be
|
||||
ambiguous, so the default direction is preserved instead."""
|
||||
if reverse and target_obj is not None and len(objs) == 1:
|
||||
return objs[0], [target_obj]
|
||||
return target_obj, objs
|
||||
|
||||
|
||||
def displacement_from_x_angle(height: float, x_angle: float) -> float:
|
||||
"""Top-edge horizontal displacement for a wall of given vertical ``height``
|
||||
and slope ``x_angle`` (radians). Inverse of ``x_angle_from_displacement``."""
|
||||
return height * math.tan(x_angle)
|
||||
|
||||
|
||||
def x_angle_from_displacement(height: float, displacement: float) -> float:
|
||||
"""Recover slope ``x_angle`` (radians) from a top-edge horizontal displacement.
|
||||
|
||||
``height`` is clamped to ``max(height, 1e-6)`` so zero-height walls map
|
||||
cleanly to ``±π/2`` instead of dividing by zero."""
|
||||
return math.atan2(displacement, max(height, 1e-6))
|
||||
|
||||
|
||||
def vertical_height_from_extrusion_depth(extrusion_depth: float, x_angle: float) -> float:
|
||||
"""Vertical height of a wall given its slanted extrusion depth and slope.
|
||||
|
||||
``IfcExtrudedAreaSolid.Depth`` measures along the (possibly slanted) extrusion
|
||||
direction. The vertical height the user thinks of is ``depth * cos(x_angle)``.
|
||||
Unit-agnostic: the result is in the same units as ``extrusion_depth``."""
|
||||
return extrusion_depth * abs(math.cos(x_angle))
|
||||
|
||||
|
||||
def extrusion_depth_from_vertical_height(vertical_height: float, x_angle: float) -> float:
|
||||
"""``vertical_height / cos(x_angle)`` with ``cos`` clamped at ``1e-6`` to
|
||||
stay finite near ``±π/2``."""
|
||||
return vertical_height / max(abs(math.cos(x_angle)), 1e-6)
|
||||
|
||||
|
||||
def length_and_height_from_extrusion(
|
||||
extrusion_depth: float,
|
||||
x_angle: float,
|
||||
reference_line_x_extent: float,
|
||||
unit_scale: float,
|
||||
) -> tuple[float, float]:
|
||||
"""SI ``(length, vertical_height)`` of a LAYER2 wall.
|
||||
|
||||
Height is the *vertical* projection of the slanted depth, not the
|
||||
slanted depth itself."""
|
||||
length = reference_line_x_extent * unit_scale
|
||||
height = vertical_height_from_extrusion_depth(extrusion_depth * unit_scale, x_angle)
|
||||
return length, height
|
||||
|
||||
|
||||
def are_axes_collinear(
|
||||
seg_a: tuple[tuple[float, float, float], tuple[float, float, float]],
|
||||
seg_b: tuple[tuple[float, float, float], tuple[float, float, float]],
|
||||
parallel_threshold: float = PARALLEL_DOT_THRESHOLD,
|
||||
line_tolerance: float = COLLINEAR_LINE_TOLERANCE,
|
||||
) -> bool:
|
||||
"""True if both axis segments lie on the same infinite line in plan.
|
||||
|
||||
Two conditions: directions must be (anti-)parallel within ``parallel_threshold``,
|
||||
AND any endpoint of B must lie on A's infinite line within ``line_tolerance``.
|
||||
Plan-only (Z ignored)."""
|
||||
d1x, d1y = seg_a[1][0] - seg_a[0][0], seg_a[1][1] - seg_a[0][1]
|
||||
d2x, d2y = seg_b[1][0] - seg_b[0][0], seg_b[1][1] - seg_b[0][1]
|
||||
d1_len = (d1x * d1x + d1y * d1y) ** 0.5
|
||||
d2_len = (d2x * d2x + d2y * d2y) ** 0.5
|
||||
if d1_len < 1e-9 or d2_len < 1e-9:
|
||||
return False
|
||||
if abs((d1x * d2x + d1y * d2y) / (d1_len * d2_len)) < parallel_threshold:
|
||||
return False
|
||||
# Project seg_b[0] onto the infinite line through seg_a; the perpendicular
|
||||
# distance to the original point tells us how far off the line B sits.
|
||||
nx, ny = d1x / d1_len, d1y / d1_len
|
||||
dx, dy = seg_b[0][0] - seg_a[0][0], seg_b[0][1] - seg_a[0][1]
|
||||
t = dx * nx + dy * ny
|
||||
proj_x = seg_a[0][0] + nx * t
|
||||
proj_y = seg_a[0][1] + ny * t
|
||||
perp_x = seg_b[0][0] - proj_x
|
||||
perp_y = seg_b[0][1] - proj_y
|
||||
return (perp_x * perp_x + perp_y * perp_y) ** 0.5 < line_tolerance
|
||||
|
||||
|
||||
def closest_endpoint_midpoint(
|
||||
seg_a: tuple[tuple[float, float, float], tuple[float, float, float]],
|
||||
seg_b: tuple[tuple[float, float, float], tuple[float, float, float]],
|
||||
) -> tuple[float, float, float]:
|
||||
"""Midpoint of the closest endpoint pair between two segments."""
|
||||
endpoints_a = (seg_a[0], seg_a[1])
|
||||
endpoints_b = (seg_b[0], seg_b[1])
|
||||
|
||||
def _distance_sq(p: tuple[float, float, float], q: tuple[float, float, float]) -> float:
|
||||
return (p[0] - q[0]) ** 2 + (p[1] - q[1]) ** 2 + (p[2] - q[2]) ** 2
|
||||
|
||||
closest_pair = min(((a, b) for a in endpoints_a for b in endpoints_b), key=lambda pair: _distance_sq(*pair))
|
||||
a, b = closest_pair
|
||||
return ((a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2)
|
||||
|
||||
|
||||
def compute_path_connection_location(
|
||||
seg_self: tuple[tuple[float, float, float], tuple[float, float, float]],
|
||||
self_conn_type: str,
|
||||
seg_other: tuple[tuple[float, float, float], tuple[float, float, float]],
|
||||
other_conn_type: str,
|
||||
parallel_threshold: float = PARALLEL_DOT_THRESHOLD,
|
||||
) -> tuple[float, float, float]:
|
||||
"""World-space location of a single ``IfcRelConnectsPathElements`` between
|
||||
two wall axes.
|
||||
|
||||
Priority: ``self``'s ATSTART/ATEND endpoint → ``other``'s ATSTART/ATEND
|
||||
endpoint → axis intersection → closest-endpoint midpoint fallback."""
|
||||
if self_conn_type == "ATSTART":
|
||||
return seg_self[0]
|
||||
if self_conn_type == "ATEND":
|
||||
return seg_self[1]
|
||||
if other_conn_type == "ATSTART":
|
||||
return seg_other[0]
|
||||
if other_conn_type == "ATEND":
|
||||
return seg_other[1]
|
||||
intersection = project_axis_intersection(seg_self, seg_other, parallel_threshold)
|
||||
if intersection is not None:
|
||||
return intersection
|
||||
return closest_endpoint_midpoint(seg_self, seg_other)
|
||||
|
||||
|
||||
def _vec_sub(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]:
|
||||
return (a[0] - b[0], a[1] - b[1], a[2] - b[2])
|
||||
|
||||
|
||||
def _vec_dot(a: tuple[float, float, float], b: tuple[float, float, float]) -> float:
|
||||
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
||||
|
||||
|
||||
def _vec_cross(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]:
|
||||
return (a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0])
|
||||
|
||||
|
||||
def _vec_length(v: tuple[float, float, float]) -> float:
|
||||
return (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]) ** 0.5
|
||||
|
||||
|
||||
def _rotate_around_axis(
|
||||
v: tuple[float, float, float],
|
||||
axis: tuple[float, float, float],
|
||||
angle: float,
|
||||
) -> tuple[float, float, float]:
|
||||
"""Rotate ``v`` around unit-length ``axis`` by ``angle`` radians."""
|
||||
cos_a = math.cos(angle)
|
||||
sin_a = math.sin(angle)
|
||||
dot = _vec_dot(axis, v)
|
||||
cross = _vec_cross(axis, v)
|
||||
k = 1.0 - cos_a
|
||||
return (
|
||||
v[0] * cos_a + cross[0] * sin_a + axis[0] * dot * k,
|
||||
v[1] * cos_a + cross[1] * sin_a + axis[1] * dot * k,
|
||||
v[2] * cos_a + cross[2] * sin_a + axis[2] * dot * k,
|
||||
)
|
||||
|
||||
|
||||
def compute_fillet_polylines(
|
||||
seg_a: tuple[tuple[float, float, float], tuple[float, float, float]],
|
||||
seg_b: tuple[tuple[float, float, float], tuple[float, float, float]],
|
||||
radius: float,
|
||||
arc_resolution: int = FILLET_DEFAULT_ARC_RESOLUTION,
|
||||
parallel_threshold: float = PARALLEL_DOT_THRESHOLD,
|
||||
) -> dict:
|
||||
"""Preview polylines for a circular fillet at the junction of two axes.
|
||||
|
||||
Returns a dict with ``valid``, ``reason``, ``intersection``, ``tangent_a``
|
||||
/ ``tangent_b``, ``arc`` (``arc_resolution + 1`` samples), ``arc_center``,
|
||||
``arc_radius``, ``sweep_angle``, ``sweep_axis``, ``tangent_offset``,
|
||||
``wall_a_join_side`` / ``wall_b_join_side`` (ATSTART/ATEND/None),
|
||||
``invalid_radius`` (tangent overshoots — arc + tangents still populated
|
||||
for warning rendering), and ``invalid_axes`` (set on parallel)."""
|
||||
blank: dict = {
|
||||
"valid": False,
|
||||
"reason": None,
|
||||
"intersection": None,
|
||||
"tangent_a": None,
|
||||
"tangent_b": None,
|
||||
"arc": [],
|
||||
"arc_center": None,
|
||||
"arc_radius": radius,
|
||||
"sweep_angle": 0.0,
|
||||
"sweep_axis": None,
|
||||
"tangent_offset": 0.0,
|
||||
"wall_a_join_side": None,
|
||||
"wall_b_join_side": None,
|
||||
"invalid_radius": False,
|
||||
"invalid_axes": None,
|
||||
}
|
||||
|
||||
intersection = project_axis_intersection(seg_a, seg_b, parallel_threshold)
|
||||
if intersection is None:
|
||||
return {**blank, "reason": "parallel", "invalid_axes": [seg_a, seg_b]}
|
||||
|
||||
def _classify(seg, ipt):
|
||||
d0 = (seg[0][0] - ipt[0]) ** 2 + (seg[0][1] - ipt[1]) ** 2 + (seg[0][2] - ipt[2]) ** 2
|
||||
d1 = (seg[1][0] - ipt[0]) ** 2 + (seg[1][1] - ipt[1]) ** 2 + (seg[1][2] - ipt[2]) ** 2
|
||||
if d0 <= d1:
|
||||
return seg[0], seg[1], "ATSTART"
|
||||
return seg[1], seg[0], "ATEND"
|
||||
|
||||
near_a, far_a, side_a = _classify(seg_a, intersection)
|
||||
near_b, far_b, side_b = _classify(seg_b, intersection)
|
||||
|
||||
# Direction along each segment AWAY from the corner. ``far - intersection``
|
||||
# handles both the shared-corner and extended-axes cases uniformly.
|
||||
dir_a_raw = _vec_sub(far_a, intersection)
|
||||
dir_b_raw = _vec_sub(far_b, intersection)
|
||||
far_len_a = _vec_length(dir_a_raw)
|
||||
far_len_b = _vec_length(dir_b_raw)
|
||||
if far_len_a < 1e-9 or far_len_b < 1e-9:
|
||||
return {**blank, "reason": "near_collinear", "intersection": intersection}
|
||||
dir_a = (dir_a_raw[0] / far_len_a, dir_a_raw[1] / far_len_a, dir_a_raw[2] / far_len_a)
|
||||
dir_b = (dir_b_raw[0] / far_len_b, dir_b_raw[1] / far_len_b, dir_b_raw[2] / far_len_b)
|
||||
|
||||
cos_angle = max(-1.0, min(1.0, _vec_dot(dir_a, dir_b)))
|
||||
angle = math.acos(cos_angle)
|
||||
sweep_angle = math.pi - angle
|
||||
if sweep_angle < 1e-3 or sweep_angle > math.pi - 1e-3:
|
||||
return {
|
||||
**blank,
|
||||
"reason": "near_collinear",
|
||||
"intersection": intersection,
|
||||
"sweep_angle": sweep_angle,
|
||||
"wall_a_join_side": side_a,
|
||||
"wall_b_join_side": side_b,
|
||||
}
|
||||
|
||||
tangent_offset = radius * math.tan(sweep_angle / 2)
|
||||
tangent_a = (
|
||||
intersection[0] + dir_a[0] * tangent_offset,
|
||||
intersection[1] + dir_a[1] * tangent_offset,
|
||||
intersection[2] + dir_a[2] * tangent_offset,
|
||||
)
|
||||
tangent_b = (
|
||||
intersection[0] + dir_b[0] * tangent_offset,
|
||||
intersection[1] + dir_b[1] * tangent_offset,
|
||||
intersection[2] + dir_b[2] * tangent_offset,
|
||||
)
|
||||
|
||||
plane_normal_raw = _vec_cross(dir_a, dir_b)
|
||||
pn_len = _vec_length(plane_normal_raw)
|
||||
if pn_len < 1e-9:
|
||||
return {**blank, "reason": "near_collinear", "intersection": intersection}
|
||||
plane_normal = (
|
||||
plane_normal_raw[0] / pn_len,
|
||||
plane_normal_raw[1] / pn_len,
|
||||
plane_normal_raw[2] / pn_len,
|
||||
)
|
||||
|
||||
perp_a = _vec_cross(plane_normal, dir_a)
|
||||
if _vec_dot(perp_a, dir_b) < 0:
|
||||
perp_a = (-perp_a[0], -perp_a[1], -perp_a[2])
|
||||
|
||||
arc_center = (
|
||||
tangent_a[0] + perp_a[0] * radius,
|
||||
tangent_a[1] + perp_a[1] * radius,
|
||||
tangent_a[2] + perp_a[2] * radius,
|
||||
)
|
||||
|
||||
v_a = _vec_sub(tangent_a, arc_center)
|
||||
v_b = _vec_sub(tangent_b, arc_center)
|
||||
sweep_axis = plane_normal
|
||||
if _vec_dot(_vec_cross(v_a, v_b), plane_normal) < 0:
|
||||
sweep_axis = (-plane_normal[0], -plane_normal[1], -plane_normal[2])
|
||||
|
||||
arc_points: list[tuple[float, float, float]] = []
|
||||
for i in range(arc_resolution + 1):
|
||||
t = i / arc_resolution
|
||||
rotated = _rotate_around_axis(v_a, sweep_axis, sweep_angle * t)
|
||||
arc_points.append(
|
||||
(
|
||||
arc_center[0] + rotated[0],
|
||||
arc_center[1] + rotated[1],
|
||||
arc_center[2] + rotated[2],
|
||||
)
|
||||
)
|
||||
|
||||
# Overshoot check only for convex fillets (positive ``tangent_offset``);
|
||||
# the inverted-fillet case puts tangents past the intersection.
|
||||
invalid_radius = tangent_offset > 0 and (tangent_offset > far_len_a or tangent_offset > far_len_b)
|
||||
|
||||
return {
|
||||
"valid": not invalid_radius,
|
||||
"reason": "invalid_radius" if invalid_radius else None,
|
||||
"intersection": intersection,
|
||||
"tangent_a": tangent_a,
|
||||
"tangent_b": tangent_b,
|
||||
"arc": arc_points,
|
||||
"arc_center": arc_center,
|
||||
"arc_radius": radius,
|
||||
"sweep_angle": sweep_angle,
|
||||
"sweep_axis": sweep_axis,
|
||||
"tangent_offset": tangent_offset,
|
||||
"wall_a_join_side": side_a,
|
||||
"wall_b_join_side": side_b,
|
||||
"leg_a_available": far_len_a,
|
||||
"leg_b_available": far_len_b,
|
||||
"invalid_radius": invalid_radius,
|
||||
"invalid_axes": None,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Iterable
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import bonsai.core.geometry
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import bpy
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
|
||||
Z_ROTATION_ALIGNMENT_TOLERANCE = 1e-9
|
||||
|
||||
|
||||
def _z_rotation_diff(target_z: float, source_z: float) -> float:
|
||||
"""Signed Z-Euler difference wrapped to [-π, π]."""
|
||||
return (target_z - source_z + math.pi) % (2 * math.pi) - math.pi
|
||||
|
||||
|
||||
def copy_z_rotation_to_selected(
|
||||
ifc: type[tool.Ifc],
|
||||
geometry: type[tool.Geometry],
|
||||
surveyor: type[tool.Surveyor],
|
||||
*,
|
||||
active: bpy.types.Object,
|
||||
targets: Iterable[bpy.types.Object],
|
||||
flip: bool = False,
|
||||
) -> int:
|
||||
"""Apply ``active``'s Z-Euler rotation to each target."""
|
||||
source_z = surveyor.get_z_rotation(active)
|
||||
if flip:
|
||||
source_z += math.pi
|
||||
rotated = 0
|
||||
for obj in targets:
|
||||
if abs(_z_rotation_diff(surveyor.get_z_rotation(obj), source_z)) < Z_ROTATION_ALIGNMENT_TOLERANCE:
|
||||
continue
|
||||
surveyor.set_z_rotation(obj, source_z)
|
||||
rotated += 1
|
||||
if ifc.get_entity(obj) is not None:
|
||||
bonsai.core.geometry.edit_object_placement(ifc, geometry, surveyor, obj=obj)
|
||||
return rotated
|
||||
@@ -67,7 +67,8 @@ def assign_container(
|
||||
if products := [e for e in root_elements if spatial.can_contain(container, root_element)]:
|
||||
ifc.run("spatial.assign_container", products=products, relating_structure=container)
|
||||
for element in all_elements:
|
||||
collector.assign(ifc.get_object(element))
|
||||
if obj := ifc.get_object(element):
|
||||
collector.assign(obj)
|
||||
|
||||
|
||||
def enable_editing_container(spatial: type[tool.Spatial], obj: bpy.types.Object) -> None:
|
||||
|
||||
@@ -415,6 +415,17 @@ class Drawing:
|
||||
def update_embedded_svg_location(cls, uri, old_location, new_location): pass
|
||||
|
||||
|
||||
@interface
|
||||
class Duplicate:
|
||||
def get_decomposition_relationships(cls, objs): pass
|
||||
def get_connection_relationships(cls, objs): pass
|
||||
def get_port_connection_relationships(cls, objs): pass
|
||||
def recreate_decompositions(cls, relationships, old_to_new): pass
|
||||
def recreate_connections(cls, relationship, old_to_new): pass
|
||||
def recreate_port_connections(cls, snapshot, old_to_new): pass
|
||||
def consume_warnings(cls): pass
|
||||
|
||||
|
||||
@interface
|
||||
class Feature:
|
||||
def add_feature(cls, featured_obj, featured_objs): pass
|
||||
@@ -445,8 +456,10 @@ class Geometry:
|
||||
def get_representation_name(cls, representation): pass
|
||||
def get_styles(cls, obj): pass
|
||||
def get_total_representation_items(cls, obj): pass
|
||||
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
|
||||
@@ -668,6 +681,9 @@ class Model:
|
||||
def export_profile(cls, obj, position=None): pass
|
||||
def generate_occurrence_name(cls, element_type, ifc_class): pass
|
||||
def get_extrusion(cls, representation): pass
|
||||
def get_connected_slab_objs(cls, wall): pass
|
||||
def get_connected_wall_objs(cls, slab): pass
|
||||
def has_underside_connection(cls, element): pass
|
||||
def get_manual_booleans(cls, element): pass
|
||||
def get_material_layer_parameters(cls, element): pass
|
||||
def get_slab_clipping_bmesh(cls, obj): pass
|
||||
@@ -683,6 +699,7 @@ class Model:
|
||||
def regenerate_profile(cls, obj): pass
|
||||
def regenerate_slab(cls, obj): pass
|
||||
def reload_body_representation(cls, obj_or_objects): pass
|
||||
def remove_wall_to_underside_booleans(cls, wall): pass
|
||||
def replace_object_ifc_representation(cls, ifc_file, ifc_context, obj, new_representation): pass
|
||||
|
||||
|
||||
@@ -776,6 +793,12 @@ class Profile:
|
||||
def get_profile(cls, element): pass
|
||||
|
||||
|
||||
@interface
|
||||
class Parametric:
|
||||
def get_geom_generation(cls) -> int: pass
|
||||
def refresh_post_commit(cls) -> None: pass
|
||||
|
||||
|
||||
@interface
|
||||
class Pset:
|
||||
def add_proposed_property(cls, name, value, props): pass
|
||||
@@ -859,7 +882,6 @@ class Root:
|
||||
def assign_body_styles(cls, element, obj): pass
|
||||
def copy_representation(cls, source, dest): pass
|
||||
def does_type_have_representations(cls, element): pass
|
||||
def get_decomposition_relationships(cls, objs): pass
|
||||
def get_default_container(cls): pass
|
||||
def get_element_representation(cls, element, context): pass
|
||||
def get_element_type(cls, element): pass
|
||||
@@ -873,7 +895,6 @@ class Root:
|
||||
def is_in_nest_mode(cls, element): pass
|
||||
def is_spatial_element(cls, element): pass
|
||||
def link_object_data(cls, source_obj, destination_obj): pass
|
||||
def recreate_decompositions(cls, relationships, old_to_new): pass
|
||||
def run_geometry_add_representation(cls, obj=None, context=None, ifc_representation_class=None, profile_set_usage=None): pass
|
||||
def set_object_name(cls, obj, element): pass
|
||||
|
||||
@@ -1017,6 +1038,8 @@ class Spatial:
|
||||
def get_container(cls, element): pass
|
||||
def get_decomposed_elements(cls, container, recursive): pass
|
||||
def get_decomposition(cls, element): pass
|
||||
def get_host_element(cls, filling): pass
|
||||
def get_host_wall(cls, filling): pass
|
||||
def get_object_matrix(cls, obj): pass
|
||||
def get_relative_object_matrix(cls, target_obj, relative_to_obj): pass
|
||||
def get_root_element(cls, element): pass
|
||||
@@ -1137,6 +1160,8 @@ class Style:
|
||||
@interface
|
||||
class Surveyor:
|
||||
def get_absolute_matrix(cls, obj): pass
|
||||
def get_z_rotation(cls, obj): pass
|
||||
def set_z_rotation(cls, obj, z): pass
|
||||
|
||||
|
||||
@interface
|
||||
@@ -1203,6 +1228,42 @@ class Voider:
|
||||
def void(cls, opening_obj, building_obj): pass
|
||||
|
||||
|
||||
@interface
|
||||
class Array:
|
||||
def bake_children_transform(cls, parent_element, item): pass
|
||||
def constrain_children_to_parent(cls, parent_element): pass
|
||||
def get_all_children_objects(cls, parent_element): pass
|
||||
def get_all_objects(cls, parent_element): pass
|
||||
def get_child_layer_index(cls, child_element): pass
|
||||
def get_children_objects(cls, modifier_data): pass
|
||||
def get_modifiers_data(cls, parent_element): pass
|
||||
def get_parent_element(cls, element): pass
|
||||
def get_parent_object(cls, element): pass
|
||||
def remove_constraints(cls, parent_element): pass
|
||||
def set_children_lock_state(cls, parent_element, item, lock_state): pass
|
||||
|
||||
|
||||
@interface
|
||||
class Slab:
|
||||
def read_geometry(cls, obj): pass
|
||||
|
||||
|
||||
@interface
|
||||
class Wall:
|
||||
def collinear_boundary_world(cls, seg_a, seg_b): pass
|
||||
def compute_wall_fillet_geometry(cls, wall_a_obj, wall_b_obj, radius, arc_resolution): pass
|
||||
def get_axis_local_extent(cls, wall): pass
|
||||
def get_length_and_height(cls, wall): pass
|
||||
def get_world_reference_line(cls, obj): pass
|
||||
def get_x_angle(cls, wall): pass
|
||||
def has_layer2_usage(cls, wall): pass
|
||||
def is_straight_axis(cls, wall): pass
|
||||
def path_connection_location_world(cls, seg_self, self_conn_type, seg_other, other_conn_type, parallel_threshold): pass
|
||||
def read_geometry(cls, obj): pass
|
||||
def validate_for_parametric_edit(cls, obj): pass
|
||||
def walk_connected_walls(cls, start_element, node_cap): pass
|
||||
|
||||
|
||||
@interface
|
||||
class Web:
|
||||
pass
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
# ruff: noqa: F401
|
||||
|
||||
from bonsai.tool.aggregate import Aggregate
|
||||
from bonsai.tool.array import Array
|
||||
from bonsai.tool.attribute import Attribute
|
||||
from bonsai.tool.bcf import Bcf
|
||||
from bonsai.tool.blender import Blender
|
||||
@@ -37,6 +38,7 @@ from bonsai.tool.debug import Debug
|
||||
from bonsai.tool.demo import Demo
|
||||
from bonsai.tool.document import Document
|
||||
from bonsai.tool.drawing import Drawing
|
||||
from bonsai.tool.duplicate import Duplicate
|
||||
from bonsai.tool.feature import Feature
|
||||
from bonsai.tool.geometry import Geometry
|
||||
from bonsai.tool.georeference import Georeference
|
||||
@@ -51,6 +53,7 @@ from bonsai.tool.misc import Misc
|
||||
from bonsai.tool.model import Model
|
||||
from bonsai.tool.nest import Nest
|
||||
from bonsai.tool.owner import Owner
|
||||
from bonsai.tool.parametric import Parametric
|
||||
from bonsai.tool.patch import Patch
|
||||
from bonsai.tool.polyline import Polyline
|
||||
from bonsai.tool.profile import Profile
|
||||
@@ -63,6 +66,7 @@ from bonsai.tool.resource import Resource
|
||||
from bonsai.tool.root import Root
|
||||
from bonsai.tool.search import Search
|
||||
from bonsai.tool.sequence import Sequence
|
||||
from bonsai.tool.slab import Slab
|
||||
from bonsai.tool.snap import Snap
|
||||
from bonsai.tool.spatial import Spatial
|
||||
from bonsai.tool.structural import Structural
|
||||
@@ -72,4 +76,5 @@ from bonsai.tool.system import System
|
||||
from bonsai.tool.tester import Tester
|
||||
from bonsai.tool.type import Type
|
||||
from bonsai.tool.unit import Unit
|
||||
from bonsai.tool.wall import Wall
|
||||
from bonsai.tool.web import Web
|
||||
|
||||
@@ -205,6 +205,27 @@ class Aggregate(bonsai.core.tool.Aggregate):
|
||||
props.in_aggregate_mode = True
|
||||
return {"FINISHED"}
|
||||
|
||||
@classmethod
|
||||
def save_previous_selection(cls) -> None:
|
||||
props = cls.get_aggregate_props()
|
||||
props.previously_selected_objects.clear()
|
||||
for obj in bpy.context.selected_objects:
|
||||
entry = props.previously_selected_objects.add()
|
||||
entry.obj = obj
|
||||
|
||||
@classmethod
|
||||
def restore_previous_selection(cls) -> None:
|
||||
props = cls.get_aggregate_props()
|
||||
for obj in bpy.context.selected_objects:
|
||||
obj.select_set(False)
|
||||
for entry in props.previously_selected_objects:
|
||||
if entry.obj:
|
||||
try:
|
||||
entry.obj.select_set(True)
|
||||
except Exception:
|
||||
pass
|
||||
props.previously_selected_objects.clear()
|
||||
|
||||
@classmethod
|
||||
def disable_aggregate_mode(cls):
|
||||
context = bpy.context
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
# 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.
|
||||
|
||||
"""Bonsai parametric array service.
|
||||
|
||||
Top-level array-domain helpers. The ``BBIM_Array`` pset on a parent ``IfcElement``
|
||||
holds the list of layers; each layer holds the GUIDs of its child replicas. These
|
||||
helpers navigate that graph and manage the Blender-side CHILD_OF constraint that
|
||||
pins children to the parent's matrix_world."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Generator
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import ifcopenshell.util.element
|
||||
|
||||
import bonsai.core.tool
|
||||
import bonsai.tool as tool
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ifcopenshell import entity_instance
|
||||
|
||||
|
||||
class Array(bonsai.core.tool.Array):
|
||||
@classmethod
|
||||
def bake_children_transform(cls, parent_element: entity_instance, item: int) -> None:
|
||||
modifier_data = list(cls.get_modifiers_data(parent_element))[item]
|
||||
children = cls.get_children_objects(modifier_data)
|
||||
for child in children:
|
||||
constraint = next((c for c in child.constraints if c.type == "CHILD_OF"), None)
|
||||
if constraint:
|
||||
with bpy.context.temp_override(object=child):
|
||||
bpy.ops.constraint.apply(constraint=constraint.name, owner="OBJECT")
|
||||
|
||||
@classmethod
|
||||
def constrain_children_to_parent(cls, parent_element: ifcopenshell.entity_instance) -> None:
|
||||
if not (parent_obj := tool.Ifc.get_object(parent_element)):
|
||||
return # Filtered out, arrayed void, etc
|
||||
assert isinstance(parent_obj, bpy.types.Object)
|
||||
children = cls.get_all_children_objects(parent_element)
|
||||
for child in children:
|
||||
constraint = next((c for c in child.constraints if c.type == "CHILD_OF"), None)
|
||||
if constraint:
|
||||
child.constraints.remove(constraint)
|
||||
constraint = child.constraints.new("CHILD_OF")
|
||||
constraint.name = "BBIM_Array_CHILD_OF"
|
||||
assert isinstance(constraint, bpy.types.ChildOfConstraint)
|
||||
constraint.target = parent_obj
|
||||
|
||||
@classmethod
|
||||
def set_children_lock_state(
|
||||
cls, parent_element: ifcopenshell.entity_instance, item: int, lock_state: bool = True
|
||||
) -> None:
|
||||
modifier_data = list(cls.get_modifiers_data(parent_element))[item]
|
||||
children = cls.get_children_objects(modifier_data)
|
||||
for child_obj in children:
|
||||
tool.Blender.lock_transform(child_obj, lock_state)
|
||||
|
||||
@classmethod
|
||||
def remove_constraints(cls, parent_element: ifcopenshell.entity_instance) -> None:
|
||||
children = cls.get_all_children_objects(parent_element)
|
||||
for child in children:
|
||||
constraint = next((c for c in child.constraints if c.type == "CHILD_OF"), None)
|
||||
if constraint:
|
||||
child.constraints.remove(constraint)
|
||||
|
||||
@classmethod
|
||||
def get_all_objects(cls, parent_element: ifcopenshell.entity_instance) -> list[bpy.types.Object]:
|
||||
parent_obj = tool.Ifc.get_object(parent_element)
|
||||
assert isinstance(parent_obj, bpy.types.Object)
|
||||
children_objects = list(cls.get_all_children_objects(parent_element))
|
||||
array_objects = [parent_obj] + children_objects # We ensure the parent is at index 0
|
||||
return array_objects
|
||||
|
||||
@classmethod
|
||||
def get_all_children_objects(
|
||||
cls, parent_element: ifcopenshell.entity_instance
|
||||
) -> Generator[bpy.types.Object, None, None]:
|
||||
for array_modifier in cls.get_modifiers_data(parent_element):
|
||||
yield from cls.get_children_objects(array_modifier)
|
||||
|
||||
@classmethod
|
||||
def get_parent_element(cls, element: entity_instance) -> entity_instance | None:
|
||||
"""Inverse of ``get_all_children_objects``: resolve an array element
|
||||
back to its parent entity. Returns ``None`` when the element isn't
|
||||
part of a Bonsai parametric array, or the stored Parent GUID does
|
||||
not resolve in the current file (this is a data-integrity warning
|
||||
and is logged to the console)."""
|
||||
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
|
||||
if not pset:
|
||||
return None
|
||||
parent_guid = pset["Parent"]
|
||||
try:
|
||||
return tool.Ifc.get().by_guid(parent_guid)
|
||||
except RuntimeError:
|
||||
print(
|
||||
f"BBIM_Array.Parent GUID {parent_guid!r} on {element} does not resolve "
|
||||
f"in the current file — array integrity may be broken."
|
||||
)
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_parent_object(cls, element: entity_instance) -> bpy.types.Object | None:
|
||||
parent_element = cls.get_parent_element(element)
|
||||
if parent_element is None:
|
||||
return None
|
||||
return tool.Ifc.get_object(parent_element)
|
||||
|
||||
@classmethod
|
||||
def get_modifiers_data(cls, parent_element: ifcopenshell.entity_instance) -> Generator[dict[str, Any], None, None]:
|
||||
array_pset = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array")
|
||||
yield from json.loads(array_pset["Data"])
|
||||
|
||||
@classmethod
|
||||
def get_children_objects(cls, modifier_data: dict[str, Any]) -> Generator[bpy.types.Object, None, None]:
|
||||
child_guid: str
|
||||
for child_guid in modifier_data["children"]:
|
||||
child_obj = tool.Blender.get_object_from_guid(child_guid)
|
||||
if child_obj:
|
||||
yield child_obj
|
||||
|
||||
@classmethod
|
||||
def get_array_root_guid(cls, element: entity_instance) -> str:
|
||||
"""Walk ``BBIM_Array.Parent`` upwards and return the topmost ancestor's
|
||||
GlobalId. For an element with no ``BBIM_Array`` pset (independent
|
||||
window, never arrayed, or former-child after the apply path), returns
|
||||
the element's own GlobalId — its "family" is just itself."""
|
||||
current = element
|
||||
seen: set[str] = set()
|
||||
while True:
|
||||
pset = ifcopenshell.util.element.get_pset(current, "BBIM_Array")
|
||||
parent_guid = pset.get("Parent") if pset else None
|
||||
if not parent_guid or parent_guid == current.GlobalId or parent_guid in seen:
|
||||
return current.GlobalId
|
||||
seen.add(parent_guid)
|
||||
try:
|
||||
current = tool.Ifc.get().by_guid(parent_guid)
|
||||
except RuntimeError:
|
||||
return current.GlobalId
|
||||
|
||||
@classmethod
|
||||
def get_parametric_propagation_targets(cls, element: entity_instance) -> list[entity_instance]:
|
||||
"""Type-occurrences that should receive parametric updates when
|
||||
``element`` is edited.
|
||||
|
||||
Returns occurrences in ``element``'s Bonsai array family. When
|
||||
``element`` is not part of any array, returns the type-occurrence
|
||||
peers that are likewise free of ``BBIM_Array`` (preserving the
|
||||
bulk-edit-by-type UX for standalone parametric elements). An
|
||||
occurrence whose ``BBIM_Array`` root differs from ``element``'s root
|
||||
is excluded — that is the "independent former child" case the array
|
||||
apply path produces."""
|
||||
occurrences = tool.Ifc.get_all_element_occurrences(element)
|
||||
element_pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
|
||||
if not element_pset:
|
||||
return [o for o in occurrences if not ifcopenshell.util.element.get_pset(o, "BBIM_Array")]
|
||||
element_root = cls.get_array_root_guid(element)
|
||||
return [o for o in occurrences if cls.get_array_root_guid(o) == element_root]
|
||||
|
||||
@classmethod
|
||||
def get_child_layer_index(cls, child_element: entity_instance) -> int | None:
|
||||
"""Index of the layer that produced ``child_element``, or ``None``
|
||||
if the child is unparented, missing from the parent's data, or the
|
||||
parent's pset is unreadable. Total: never raises."""
|
||||
pset = ifcopenshell.util.element.get_pset(child_element, "BBIM_Array")
|
||||
if not pset:
|
||||
return None
|
||||
parent_guid = pset.get("Parent")
|
||||
if not parent_guid or parent_guid == child_element.GlobalId:
|
||||
return None
|
||||
try:
|
||||
parent_element = tool.Ifc.get().by_guid(parent_guid)
|
||||
except RuntimeError:
|
||||
return None
|
||||
data_text = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array", "Data")
|
||||
if not data_text:
|
||||
return None
|
||||
try:
|
||||
layers = json.loads(data_text)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
child_guid = child_element.GlobalId
|
||||
for i, layer in enumerate(layers):
|
||||
if child_guid in layer.get("children", []):
|
||||
return i
|
||||
return None
|
||||
+331
-148
@@ -15,12 +15,13 @@
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was modified with the assistance of an AI coding tool.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
@@ -28,7 +29,7 @@ import sys
|
||||
import tempfile
|
||||
import traceback
|
||||
import types
|
||||
from collections.abc import Callable, Generator, Iterable, Sequence, Sized
|
||||
from collections.abc import Callable, Generator, Iterable, Mapping, Sequence, Sized
|
||||
from datetime import datetime
|
||||
from functools import cache, lru_cache
|
||||
from pathlib import Path
|
||||
@@ -45,7 +46,6 @@ from typing import (
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import ifcopenshell.api
|
||||
import ifcopenshell.util.element
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
@@ -55,12 +55,12 @@ from mathutils import Matrix, Vector
|
||||
import bonsai.bim
|
||||
import bonsai.core.tool
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.ifc import IFC_CONNECTED_TYPE
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import bpy.stub_internal.rna_enums as rna_enums
|
||||
from sun_position.properties import SunPosProperties
|
||||
|
||||
from bonsai.bim.ifc import IFC_CONNECTED_TYPE
|
||||
from bonsai.bim.module.attribute.prop import BIMAttributeProperties
|
||||
from bonsai.bim.module.constraint.prop import (
|
||||
BIMConstraintProperties,
|
||||
@@ -97,6 +97,19 @@ VIEWPORT_ATTRIBUTES = [
|
||||
|
||||
OBJECT_DATA_TYPE = Union[bpy.types.Mesh, bpy.types.Curve, bpy.types.Camera]
|
||||
|
||||
_RAILING_MODIFIER_IFC_CLASSES = ("IfcRailing", "IfcRailingType")
|
||||
_STAIR_MODIFIER_IFC_CLASSES = (
|
||||
"IfcStairFlight",
|
||||
"IfcStairFlightType",
|
||||
"IfcMember",
|
||||
"IfcMemberType",
|
||||
"IfcStair",
|
||||
"IfcStairType",
|
||||
)
|
||||
_WINDOW_MODIFIER_IFC_CLASSES = ("IfcWindow", "IfcWindowType", "IfcWindowStyle")
|
||||
_DOOR_MODIFIER_IFC_CLASSES = ("IfcDoor", "IfcDoorType", "IfcDoorStyle")
|
||||
_ROOF_MODIFIER_IFC_CLASSES = ("IfcRoof", "IfcRoofType")
|
||||
|
||||
|
||||
class Blender(bonsai.core.tool.Blender):
|
||||
OBJECT_TYPES_THAT_SUPPORT_EDIT_MODE = ("MESH", "CURVE", "SURFACE", "META", "FONT", "LATTICE", "ARMATURE")
|
||||
@@ -415,6 +428,189 @@ class Blender(bonsai.core.tool.Blender):
|
||||
with bpy.context.temp_override(**cls.get_viewport_context()):
|
||||
bpy.ops.wm.tool_set_by_id(name=tool_name)
|
||||
|
||||
@classmethod
|
||||
def are_viewport_gizmos_enabled(cls) -> bool:
|
||||
"""Central gate every Bonsai gizmo poll / decorator draw checks before
|
||||
rendering. Centralises the read of
|
||||
``gizmos.draw_gizmos_in_3d_viewport`` from addon preferences."""
|
||||
return cls.get_addon_preferences().gizmos.draw_gizmos_in_3d_viewport
|
||||
|
||||
class DecoratorColors(NamedTuple):
|
||||
selected: tuple
|
||||
unselected: tuple
|
||||
special: tuple
|
||||
error: tuple
|
||||
background: tuple
|
||||
|
||||
@classmethod
|
||||
def get_decorator_colors(cls) -> Blender.DecoratorColors:
|
||||
"""The five ``decorator_color_*`` fields read together so each viewport
|
||||
decorator's draw callback resolves them in one call instead of five."""
|
||||
prefs = cls.get_addon_preferences()
|
||||
return cls.DecoratorColors(
|
||||
selected=prefs.decorator_color_selected,
|
||||
unselected=prefs.decorator_color_unselected,
|
||||
special=prefs.decorator_color_special,
|
||||
error=prefs.decorator_color_error,
|
||||
background=prefs.decorator_color_background,
|
||||
)
|
||||
|
||||
class ViewportDecorator:
|
||||
"""Shared ``SpaceView3D.draw_handler_add`` lifecycle for feature decorators.
|
||||
|
||||
Single-handler subclasses set ``draw_method`` (default ``"draw"``); the
|
||||
handler binds at ``POST_VIEW``. Multi-handler subclasses set
|
||||
``draw_methods`` to a tuple of ``(method_name, phase)`` pairs; when it
|
||||
is non-``None`` it supersedes ``draw_method``.
|
||||
|
||||
Decorators whose ``install`` must accept extra arguments (e.g. a callback
|
||||
or a precomputed bmesh) override ``install`` themselves."""
|
||||
|
||||
draw_method: str = "draw"
|
||||
draw_methods: tuple[tuple[str, str], ...] | None = None
|
||||
|
||||
def __init_subclass__(cls, **kwargs):
|
||||
super().__init_subclass__(**kwargs)
|
||||
cls.handlers = []
|
||||
cls.is_installed = False
|
||||
# Fail loudly at class-definition time if draw_method / draw_methods
|
||||
# names an attribute the class doesn't expose. Without this, a typo
|
||||
# only surfaces on the first redraw — as a silent missing-attribute
|
||||
# handler — which may be far from the offending declaration.
|
||||
method_names = (
|
||||
tuple(name for name, _phase in cls.draw_methods) if cls.draw_methods is not None else (cls.draw_method,)
|
||||
)
|
||||
for name in method_names:
|
||||
if getattr(cls, name, None) is None:
|
||||
raise TypeError(f"{cls.__name__}: draw method {name!r} is declared but not defined on the class")
|
||||
|
||||
@classmethod
|
||||
def install(cls, context: bpy.types.Context) -> None:
|
||||
if cls.is_installed:
|
||||
cls.uninstall()
|
||||
handler = cls()
|
||||
bindings = cls.draw_methods if cls.draw_methods is not None else ((cls.draw_method, "POST_VIEW"),)
|
||||
# Rollback partial registrations on any draw_handler_add failure, so
|
||||
# cls.handlers never ends up holding a half-installed set.
|
||||
added: list = []
|
||||
try:
|
||||
for method_name, phase in bindings:
|
||||
added.append(
|
||||
bpy.types.SpaceView3D.draw_handler_add(
|
||||
getattr(handler, method_name), (context,), "WINDOW", phase
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
for h in added:
|
||||
try:
|
||||
bpy.types.SpaceView3D.draw_handler_remove(h, "WINDOW")
|
||||
except ValueError:
|
||||
pass
|
||||
raise
|
||||
cls.handlers = added
|
||||
cls.is_installed = True
|
||||
|
||||
@classmethod
|
||||
def uninstall(cls) -> None:
|
||||
for h in cls.handlers:
|
||||
try:
|
||||
bpy.types.SpaceView3D.draw_handler_remove(h, "WINDOW")
|
||||
except ValueError:
|
||||
pass
|
||||
cls.handlers.clear()
|
||||
cls.is_installed = False
|
||||
|
||||
@staticmethod
|
||||
def _lookup_active_instance(gizmo_cls: type, context: bpy.types.Context) -> Optional[Any]:
|
||||
"""Return the live ``GizmoGroup`` instance registered under
|
||||
``context.region``, or ``None`` if there isn't one. The per-region
|
||||
weakref dict on the gizmo class is populated by ``setup()``; multi-
|
||||
viewport setups put one entry per region in it so each region's
|
||||
decorator sees only its own region's hover state."""
|
||||
instances = getattr(gizmo_cls, "_active_instances", None)
|
||||
if not instances:
|
||||
return None
|
||||
region = getattr(context, "region", None)
|
||||
if region is None:
|
||||
return None
|
||||
ref = instances.get(region.as_pointer())
|
||||
if ref is None:
|
||||
return None
|
||||
return ref()
|
||||
|
||||
def _cursor_icon_hovered(self, gizmo_cls: type, attr_name: str, context: bpy.types.Context) -> bool:
|
||||
"""True iff the gizmo group instance in the current region exposes a gizmo
|
||||
under ``attr_name`` that reports as highlighted. Any access exception is
|
||||
swallowed so a transient bpy-state hiccup never breaks the draw loop."""
|
||||
inst = self._lookup_active_instance(gizmo_cls, context)
|
||||
if inst is None:
|
||||
return False
|
||||
try:
|
||||
return bool(getattr(inst, attr_name).is_highlight)
|
||||
except (AttributeError, ReferenceError):
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def sync_all(
|
||||
cls,
|
||||
context: bpy.types.Context,
|
||||
enabled: Mapping[type[Blender.ViewportDecorator], bool],
|
||||
) -> None:
|
||||
"""Drive each listed decorator to its desired install state in one call.
|
||||
|
||||
Each entry whose value is ``True`` ends up installed; each entry whose
|
||||
value is ``False`` ends up uninstalled. Pass ``True`` for always-on
|
||||
overlays so they survive subsequent file loads."""
|
||||
for decorator_cls, should_install in enabled.items():
|
||||
if should_install:
|
||||
decorator_cls.install(context)
|
||||
else:
|
||||
decorator_cls.uninstall()
|
||||
|
||||
@classmethod
|
||||
def is_view_top_down(cls, context: bpy.types.Context, threshold: float = 0.9659) -> bool:
|
||||
"""True when the viewport camera is looking ~straight down (or up) the world Z axis.
|
||||
|
||||
Default threshold of 0.9659 = cos(15°) — a 15° tilt cone around ±world Z.
|
||||
Above the threshold the world-Z axis projects to a small fraction of its
|
||||
true length on screen, so callers that lay icons or markers out along
|
||||
world Z should switch to a screen-space offset and any gizmo whose intent
|
||||
is specifically "vertical" loses its visual cue. The cone is kept narrow
|
||||
so vertical-intent gizmos stay visible across the typical orbit range of
|
||||
3D viewport work and drop out only near genuine plan view."""
|
||||
rv3d = context.region_data
|
||||
if rv3d is None:
|
||||
return False
|
||||
view_forward = Vector(rv3d.view_matrix.inverted().col[2][:3]).normalized()
|
||||
return abs(view_forward.z) > threshold
|
||||
|
||||
@classmethod
|
||||
def top_down_factor(cls, context: bpy.types.Context, threshold: float = 0.9659) -> float:
|
||||
"""Continuous 0–1 ramp matching ``is_view_top_down``'s cone: 0 outside the
|
||||
cone, ramping linearly to 1 at strict alignment with world Z. Callers that
|
||||
want a proportional effect (an icon-stack lift growing as the view
|
||||
approaches plan) use this in place of the boolean to avoid a one-frame
|
||||
visual jump as the camera crosses the threshold."""
|
||||
rv3d = context.region_data
|
||||
if rv3d is None:
|
||||
return 0.0
|
||||
view_forward = Vector(rv3d.view_matrix.inverted().col[2][:3]).normalized()
|
||||
alignment = abs(view_forward.z)
|
||||
if alignment <= threshold:
|
||||
return 0.0
|
||||
return (alignment - threshold) / (1.0 - threshold)
|
||||
|
||||
@classmethod
|
||||
def get_screen_up_world(cls, context: bpy.types.Context) -> Vector:
|
||||
"""World-space direction corresponding to the camera's up axis (screen-vertical).
|
||||
|
||||
Returns ``+Y`` when region data is unavailable so callers can compute an
|
||||
offset without a guard branch."""
|
||||
rv3d = context.region_data
|
||||
if rv3d is None:
|
||||
return Vector((0.0, 1.0, 0.0))
|
||||
return Vector(rv3d.view_matrix.inverted().col[1][:3]).normalized()
|
||||
|
||||
@classmethod
|
||||
def get_shader_editor_context(cls) -> Union[dict[str, Any], None]:
|
||||
for screen in bpy.data.screens:
|
||||
@@ -484,9 +680,13 @@ class Blender(bonsai.core.tool.Blender):
|
||||
|
||||
@classmethod
|
||||
def update_all_viewports(cls, context: bpy.types.Context | None = None) -> None:
|
||||
"""Tag every visible 3D viewport for redraw. Silent no-op when no
|
||||
screen attached (background mode, plug-out, mid-load_post)."""
|
||||
context = context or bpy.context
|
||||
assert context.screen
|
||||
for area in context.screen.areas:
|
||||
screen = getattr(context, "screen", None)
|
||||
if screen is None:
|
||||
return
|
||||
for area in screen.areas:
|
||||
if area.type == "VIEW_3D":
|
||||
area.tag_redraw()
|
||||
|
||||
@@ -635,10 +835,11 @@ class Blender(bonsai.core.tool.Blender):
|
||||
op_text = "" if ui_context == "TOOL_HEADER" else text
|
||||
modifier_icon, modifier_str = cls.KEY_MODIFIERS.get(modifier, ("NONE", ""))
|
||||
|
||||
row = layout if ui_context == "TOOL_HEADER" else layout.row(align=True)
|
||||
module = sys.modules[module_name]
|
||||
icon_previews: Union[bpy.utils.previews.ImagePreviewCollection, None]
|
||||
icon_previews = getattr(module, "custom_icon_previews", None)
|
||||
|
||||
row = layout if ui_context == "TOOL_HEADER" else layout.row(align=True)
|
||||
if icon_previews:
|
||||
custom_icon = icon_previews.get(text.upper().replace(" ", "_"), icon_previews["IFC"]).icon_id
|
||||
op = row.operator(operator_to_use, text=op_text, icon_value=custom_icon)
|
||||
@@ -646,6 +847,7 @@ class Blender(bonsai.core.tool.Blender):
|
||||
op = row.operator(operator_to_use, text=op_text)
|
||||
if ui_context != "TOOL_HEADER":
|
||||
row.label(text="", icon=modifier_icon)
|
||||
row.separator(factor=1)
|
||||
row.label(text="", icon=f"EVENT_{key}")
|
||||
|
||||
if operator_to_use == hotkey_operator:
|
||||
@@ -1130,6 +1332,74 @@ class Blender(bonsai.core.tool.Blender):
|
||||
return True
|
||||
|
||||
class Modifier:
|
||||
# ----------------------------------------------------------------------
|
||||
# FIXME(PR5): backward-compat shims for callers still using the
|
||||
# pre-refactor API. The is_<type> predicates now live on tool.Parametric;
|
||||
# the Array helper bag now lives on tool.Array. PR4 migrates each caller;
|
||||
# this whole shim block is removed in PR5's cleanup.
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def is_door(cls, element: entity_instance) -> bool:
|
||||
return tool.Parametric.is_door(element)
|
||||
|
||||
@classmethod
|
||||
def is_railing(cls, element: entity_instance) -> bool:
|
||||
return tool.Parametric.is_railing(element)
|
||||
|
||||
@classmethod
|
||||
def is_roof(cls, element: entity_instance) -> bool:
|
||||
return tool.Parametric.is_roof(element)
|
||||
|
||||
@classmethod
|
||||
def is_stair(cls, element: entity_instance) -> bool:
|
||||
return tool.Parametric.is_stair(element)
|
||||
|
||||
@classmethod
|
||||
def is_wall(cls, element: entity_instance) -> bool:
|
||||
return tool.Parametric.is_wall(element)
|
||||
|
||||
@classmethod
|
||||
def is_window(cls, element: entity_instance) -> bool:
|
||||
return tool.Parametric.is_window(element)
|
||||
|
||||
class Array:
|
||||
@classmethod
|
||||
def bake_children_transform(cls, parent_element: ifcopenshell.entity_instance, item: int) -> None:
|
||||
tool.Array.bake_children_transform(parent_element, item)
|
||||
|
||||
@classmethod
|
||||
def constrain_children_to_parent(cls, parent_element: ifcopenshell.entity_instance) -> None:
|
||||
tool.Array.constrain_children_to_parent(parent_element)
|
||||
|
||||
@classmethod
|
||||
def get_all_children_objects(cls, parent_element: ifcopenshell.entity_instance) -> list:
|
||||
return tool.Array.get_all_children_objects(parent_element)
|
||||
|
||||
@classmethod
|
||||
def get_all_objects(cls, parent_element: ifcopenshell.entity_instance) -> list:
|
||||
return tool.Array.get_all_objects(parent_element)
|
||||
|
||||
@classmethod
|
||||
def get_children_objects(cls, modifier_data: dict) -> list:
|
||||
return tool.Array.get_children_objects(modifier_data)
|
||||
|
||||
@classmethod
|
||||
def get_modifiers_data(cls, parent_element: ifcopenshell.entity_instance):
|
||||
return tool.Array.get_modifiers_data(parent_element)
|
||||
|
||||
@classmethod
|
||||
def remove_constraints(cls, parent_element: ifcopenshell.entity_instance) -> None:
|
||||
tool.Array.remove_constraints(parent_element)
|
||||
|
||||
@classmethod
|
||||
def set_children_lock_state(
|
||||
cls, parent_element: ifcopenshell.entity_instance, item: int, lock: bool
|
||||
) -> None:
|
||||
tool.Array.set_children_lock_state(parent_element, item, lock)
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def try_applying_edit_mode(cls, obj: bpy.types.Object, element: entity_instance) -> bool:
|
||||
"""Tries to validate the current BIM modifier parameters for the active object
|
||||
@@ -1137,20 +1407,18 @@ class Blender(bonsai.core.tool.Blender):
|
||||
|
||||
:return: True if an action was taken, False otherwise
|
||||
"""
|
||||
if cls.is_roof(element):
|
||||
if cls.is_editing_roof_parameters(obj):
|
||||
bpy.ops.bim.finish_editing_roof()
|
||||
# roof and railing both finalize then drop into path-edit mode — handle
|
||||
# them before the generic finish dispatch so the path transition runs.
|
||||
if tool.Parametric.is_roof(element):
|
||||
if tool.Parametric.ROOF.is_editing(obj):
|
||||
tool.Parametric.run_bim_op(tool.Parametric.ROOF.finish_op)
|
||||
bpy.ops.bim.enable_editing_roof_path()
|
||||
elif cls.is_railing(element):
|
||||
if cls.is_editing_railing_parameters(obj):
|
||||
bpy.ops.bim.finish_editing_railing()
|
||||
elif tool.Parametric.is_railing(element):
|
||||
if tool.Parametric.RAILING.is_editing(obj):
|
||||
tool.Parametric.run_bim_op(tool.Parametric.RAILING.finish_op)
|
||||
bpy.ops.bim.enable_editing_railing_path()
|
||||
elif cls.is_editing_stair_parameters(obj):
|
||||
bpy.ops.bim.finish_editing_stair()
|
||||
elif cls.is_editing_door_parameters(obj):
|
||||
bpy.ops.bim.finish_editing_door()
|
||||
elif cls.is_editing_window_parameters(obj):
|
||||
bpy.ops.bim.finish_editing_window()
|
||||
elif feature := tool.Parametric.is_object_editing(obj):
|
||||
tool.Parametric.run_bim_op(feature.finish_op)
|
||||
else:
|
||||
return False
|
||||
return True
|
||||
@@ -1161,68 +1429,80 @@ class Blender(bonsai.core.tool.Blender):
|
||||
|
||||
:return: True if an action was taken, False otherwise
|
||||
"""
|
||||
# Path-edit modes are distinct from parametric draft modes; handle them first.
|
||||
if cls.is_editing_railing_path(obj):
|
||||
bpy.ops.bim.cancel_editing_railing_path()
|
||||
elif cls.is_editing_roof_path(obj):
|
||||
bpy.ops.bim.cancel_editing_roof_path()
|
||||
elif cls.is_editing_railing_parameters(obj):
|
||||
bpy.ops.bim.cancel_editing_railing()
|
||||
elif cls.is_editing_door_parameters(obj):
|
||||
bpy.ops.bim.cancel_editing_door()
|
||||
elif cls.is_editing_window_parameters(obj):
|
||||
bpy.ops.bim.cancel_editing_window()
|
||||
elif cls.is_editing_roof_parameters(obj):
|
||||
bpy.ops.bim.cancel_editing_roof()
|
||||
elif cls.is_editing_stair_parameters(obj):
|
||||
bpy.ops.bim.cancel_editing_stair()
|
||||
elif feature := tool.Parametric.is_object_editing(obj):
|
||||
tool.Parametric.run_bim_op(feature.cancel_op)
|
||||
else:
|
||||
return False
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def is_eligible_for_railing_modifier(cls, obj: bpy.types.Object) -> bool:
|
||||
return tool.Blender.is_object_an_ifc_class(obj, ("IfcRailing", "IfcRailingType"))
|
||||
return tool.Blender.is_object_an_ifc_class(obj, _RAILING_MODIFIER_IFC_CLASSES)
|
||||
|
||||
@classmethod
|
||||
def is_eligible_for_stair_modifier(cls, obj: bpy.types.Object) -> bool:
|
||||
return tool.Blender.is_object_an_ifc_class(
|
||||
obj, ("IfcStairFlight", "IfcStairFlightType", "IfcMember", "IfcMemberType", "IfcStair", "IfcStairType")
|
||||
)
|
||||
return tool.Blender.is_object_an_ifc_class(obj, _STAIR_MODIFIER_IFC_CLASSES)
|
||||
|
||||
@classmethod
|
||||
def is_eligible_for_window_modifier(cls, obj: bpy.types.Object) -> bool:
|
||||
return tool.Blender.is_object_an_ifc_class(obj, ("IfcWindow", "IfcWindowType", "IfcWindowStyle"))
|
||||
return tool.Blender.is_object_an_ifc_class(obj, _WINDOW_MODIFIER_IFC_CLASSES)
|
||||
|
||||
@classmethod
|
||||
def is_eligible_for_door_modifier(cls, obj: bpy.types.Object) -> bool:
|
||||
return tool.Blender.is_object_an_ifc_class(obj, ("IfcDoor", "IfcDoorType", "IfcDoorStyle"))
|
||||
return tool.Blender.is_object_an_ifc_class(obj, _DOOR_MODIFIER_IFC_CLASSES)
|
||||
|
||||
@classmethod
|
||||
def is_eligible_for_roof_modifier(cls, obj: bpy.types.Object) -> bool:
|
||||
return tool.Blender.is_object_an_ifc_class(obj, ("IfcRoof", "IfcRoofType"))
|
||||
return tool.Blender.is_object_an_ifc_class(obj, _ROOF_MODIFIER_IFC_CLASSES)
|
||||
|
||||
@classmethod
|
||||
def is_railing(cls, element: entity_instance) -> bool:
|
||||
return tool.Pset.get_element_pset(element, "BBIM_Railing")
|
||||
def is_array_child(cls, element: entity_instance) -> bool:
|
||||
"""True if element is a CHILD of a Bonsai parametric array.
|
||||
|
||||
Children are managed replicas regenerated from the parent's pset —
|
||||
their parametric attributes (door dimensions, wall lengths, …) are
|
||||
overwritten on the next ``regenerate_array``. Parametric gizmo
|
||||
groups skip children via this predicate in ``poll``.
|
||||
|
||||
This sits on a different axis from ``tool.Parametric.is_array``:
|
||||
cardinality (parent vs child) is orthogonal to feature kind, and
|
||||
an arrayed wall fires both ``is_wall`` and ``is_array`` on the
|
||||
same element."""
|
||||
if element is None:
|
||||
return False
|
||||
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
|
||||
if not pset:
|
||||
return False
|
||||
parent_guid = pset.get("Parent")
|
||||
return parent_guid is not None and parent_guid != element.GlobalId
|
||||
|
||||
@classmethod
|
||||
def is_roof(cls, element: entity_instance) -> bool:
|
||||
return tool.Pset.get_element_pset(element, "BBIM_Roof")
|
||||
def is_slab(cls, element: entity_instance) -> bool:
|
||||
"""A slab is host-eligible for the parametric add-opening gizmo if
|
||||
it is an IfcSlab with LAYER3 usage.
|
||||
|
||||
Slabs carry no proprietary BBIM_Slab pset — their parametric state
|
||||
lives in standard IFC (extrusion depth, IfcMaterialLayerSetUsage
|
||||
with LayerSetDirection AXIS3). Any LAYER3 slab qualifies."""
|
||||
if element is None or not element.is_a("IfcSlab"):
|
||||
return False
|
||||
return tool.Model.get_usage_type(element) == "LAYER3"
|
||||
|
||||
@classmethod
|
||||
def is_window(cls, element: entity_instance) -> bool:
|
||||
return tool.Pset.get_element_pset(element, "BBIM_Window")
|
||||
def is_pipe_segment(cls, element: entity_instance) -> bool:
|
||||
return element is not None and element.is_a("IfcPipeSegment")
|
||||
|
||||
@classmethod
|
||||
def is_door(cls, element: entity_instance) -> bool:
|
||||
return tool.Pset.get_element_pset(element, "BBIM_Door")
|
||||
def is_duct_segment(cls, element: entity_instance) -> bool:
|
||||
return element is not None and element.is_a("IfcDuctSegment")
|
||||
|
||||
@classmethod
|
||||
def is_stair(cls, element: entity_instance) -> bool:
|
||||
return tool.Pset.get_element_pset(element, "BBIM_Stair")
|
||||
|
||||
@classmethod
|
||||
def is_editing_railing_path(cls, obj: bpy.types.Object):
|
||||
def is_editing_railing_path(cls, obj: bpy.types.Object) -> bool:
|
||||
props = tool.Model.get_railing_props(obj)
|
||||
return props.is_editing_path
|
||||
|
||||
@@ -1231,107 +1511,10 @@ class Blender(bonsai.core.tool.Blender):
|
||||
props = tool.Model.get_roof_props(obj)
|
||||
return props.is_editing_path
|
||||
|
||||
@classmethod
|
||||
def is_editing_railing_parameters(cls, obj: bpy.types.Object) -> bool:
|
||||
props = tool.Model.get_railing_props(obj)
|
||||
return props.is_editing
|
||||
|
||||
@classmethod
|
||||
def is_editing_roof_parameters(cls, obj: bpy.types.Object) -> bool:
|
||||
props = tool.Model.get_roof_props(obj)
|
||||
return props.is_editing
|
||||
|
||||
@classmethod
|
||||
def is_editing_window_parameters(cls, obj: bpy.types.Object) -> bool:
|
||||
props = tool.Model.get_window_props(obj)
|
||||
return props.is_editing
|
||||
|
||||
@classmethod
|
||||
def is_editing_door_parameters(cls, obj: bpy.types.Object) -> bool:
|
||||
props = tool.Model.get_door_props(obj)
|
||||
return props.is_editing
|
||||
|
||||
@classmethod
|
||||
def is_editing_stair_parameters(cls, obj: bpy.types.Object) -> bool:
|
||||
props = tool.Model.get_stair_props(obj)
|
||||
return props.is_editing
|
||||
|
||||
@classmethod
|
||||
def is_modifier_with_non_editable_path(cls, element: entity_instance) -> bool:
|
||||
return cls.is_stair(element) or cls.is_door(element) or cls.is_window(element)
|
||||
|
||||
class Array:
|
||||
@classmethod
|
||||
def bake_children_transform(cls, parent_element: entity_instance, item: int) -> None:
|
||||
modifier_data = list(cls.get_modifiers_data(parent_element))[item]
|
||||
children = cls.get_children_objects(modifier_data)
|
||||
for child in children:
|
||||
constraint = next((c for c in child.constraints if c.type == "CHILD_OF"), None)
|
||||
if constraint:
|
||||
with bpy.context.temp_override(object=child):
|
||||
bpy.ops.constraint.apply(constraint=constraint.name, owner="OBJECT")
|
||||
|
||||
@classmethod
|
||||
def constrain_children_to_parent(cls, parent_element: ifcopenshell.entity_instance) -> None:
|
||||
if not (parent_obj := tool.Ifc.get_object(parent_element)):
|
||||
return # Filtered out, arrayed void, etc
|
||||
assert isinstance(parent_obj, bpy.types.Object)
|
||||
children = cls.get_all_children_objects(parent_element)
|
||||
for child in children:
|
||||
constraint = next((c for c in child.constraints if c.type == "CHILD_OF"), None)
|
||||
if constraint:
|
||||
child.constraints.remove(constraint)
|
||||
constraint = child.constraints.new("CHILD_OF")
|
||||
constraint.name = "BBIM_Array_CHILD_OF"
|
||||
assert isinstance(constraint, bpy.types.ChildOfConstraint)
|
||||
constraint.target = parent_obj
|
||||
|
||||
@classmethod
|
||||
def set_children_lock_state(
|
||||
cls, parent_element: ifcopenshell.entity_instance, item: int, lock_state: bool = True
|
||||
) -> None:
|
||||
modifier_data = list(cls.get_modifiers_data(parent_element))[item]
|
||||
children = cls.get_children_objects(modifier_data)
|
||||
for child_obj in children:
|
||||
Blender.lock_transform(child_obj, lock_state)
|
||||
|
||||
@classmethod
|
||||
def remove_constraints(cls, parent_element: ifcopenshell.entity_instance) -> None:
|
||||
children = cls.get_all_children_objects(parent_element)
|
||||
for child in children:
|
||||
constraint = next((c for c in child.constraints if c.type == "CHILD_OF"), None)
|
||||
if constraint:
|
||||
child.constraints.remove(constraint)
|
||||
|
||||
@classmethod
|
||||
def get_all_objects(cls, parent_element: ifcopenshell.entity_instance) -> list[bpy.types.Object]:
|
||||
parent_obj = tool.Ifc.get_object(parent_element)
|
||||
assert isinstance(parent_obj, bpy.types.Object)
|
||||
children_objects = list(cls.get_all_children_objects(parent_element))
|
||||
array_objects = [parent_obj] + children_objects # We ensure the parent is at index 0
|
||||
return array_objects
|
||||
|
||||
@classmethod
|
||||
def get_all_children_objects(
|
||||
cls, parent_element: ifcopenshell.entity_instance
|
||||
) -> Generator[bpy.types.Object, None, None]:
|
||||
for array_modifier in cls.get_modifiers_data(parent_element):
|
||||
yield from cls.get_children_objects(array_modifier)
|
||||
|
||||
@classmethod
|
||||
def get_modifiers_data(
|
||||
cls, parent_element: ifcopenshell.entity_instance
|
||||
) -> Generator[dict[str, Any], None, None]:
|
||||
array_pset = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array")
|
||||
yield from json.loads(array_pset["Data"])
|
||||
|
||||
@classmethod
|
||||
def get_children_objects(cls, modifier_data: dict[str, Any]) -> Generator[bpy.types.Object, None, None]:
|
||||
child_guid: str
|
||||
for child_guid in modifier_data["children"]:
|
||||
child_obj = tool.Blender.get_object_from_guid(child_guid)
|
||||
if child_obj:
|
||||
yield child_obj
|
||||
feature = tool.Parametric.find_for_element(element)
|
||||
return bool(feature and feature.has_non_editable_path)
|
||||
|
||||
class Attribute:
|
||||
@classmethod
|
||||
|
||||
@@ -32,6 +32,7 @@ from __future__ import annotations
|
||||
|
||||
import math
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
from typing import TYPE_CHECKING, Union
|
||||
|
||||
import bmesh
|
||||
@@ -45,6 +46,13 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
VTX_PRECISION = 1.0e-5
|
||||
# Tolerances below are in Blender units (SI metres).
|
||||
# Looser than VTX_PRECISION because regen-time numeric drift exceeds CAD snap precision.
|
||||
WELD_TOLERANCE = 1.0e-4
|
||||
# How close a vertex must be to the cut plane to count as on it.
|
||||
BISECT_TOLERANCE = 1.0e-4
|
||||
# Strict weld for cleaning up exactly-coincident vertices.
|
||||
WELD_EPSILON = 1.0e-6
|
||||
|
||||
|
||||
class Cad:
|
||||
@@ -996,3 +1004,106 @@ class Cad:
|
||||
y = height_half + height_half * (prj[1] / w)
|
||||
return Vector((float(x), float(y)))
|
||||
return default
|
||||
|
||||
@classmethod
|
||||
def sweep_disk_along_polyline(
|
||||
cls,
|
||||
bm: bmesh.types.BMesh,
|
||||
points: Sequence[Vector],
|
||||
radius: float,
|
||||
arc_indices: Sequence[int] = (),
|
||||
profile_segments: int = 8,
|
||||
) -> None:
|
||||
"""Append a tube of ``radius`` along the polyline ``points`` to ``bm``.
|
||||
|
||||
Viewport-quality approximation of an IFC ``IfcSweptDiskSolid``: each
|
||||
consecutive pair of points becomes a capped cylinder. The cylinders
|
||||
overlap at joints rather than being mitered — the visual artifact is
|
||||
negligible at typical handrail radii (~25mm) and acceptable for
|
||||
live parametric-edit preview.
|
||||
|
||||
``arc_indices`` is accepted for API symmetry with the IFC builder
|
||||
(which receives the same data structure), but is currently unused —
|
||||
arcs are visualised as polyline kinks. Tessellating each arc with a
|
||||
Lagrange or circular interpolation would smooth the joints; deferred
|
||||
until profile fidelity becomes a concern.
|
||||
|
||||
:param bm: target bmesh, mutated in place.
|
||||
:param points: polyline vertices.
|
||||
:param radius: tube radius (project units).
|
||||
:param arc_indices: indices of arc midpoints (currently ignored).
|
||||
:param profile_segments: sides on each cylinder cross-section.
|
||||
"""
|
||||
del arc_indices # accepted for forward compatibility; see docstring
|
||||
if len(points) < 2:
|
||||
return
|
||||
for p0, p1 in zip(points, points[1:]):
|
||||
cls._add_capped_cylinder(bm, Vector(p0), Vector(p1), radius, profile_segments)
|
||||
|
||||
@classmethod
|
||||
def add_disk_extrusion(
|
||||
cls,
|
||||
bm: bmesh.types.BMesh,
|
||||
position: Vector,
|
||||
radius: float,
|
||||
depth: float,
|
||||
axis_rotation_z: float,
|
||||
profile_segments: int = 12,
|
||||
) -> None:
|
||||
"""Append a flat cylinder (disk extrusion) to ``bm``.
|
||||
|
||||
A disk of ``radius`` extruded by ``depth`` along the +Y axis rotated
|
||||
by ``axis_rotation_z`` radians around Z. ``position`` is the disk's
|
||||
base, not its centre.
|
||||
|
||||
:param bm: target bmesh, mutated in place.
|
||||
:param position: base of the extrusion in object-local coordinates.
|
||||
:param radius: disk radius.
|
||||
:param depth: extrusion depth along the (rotated) Y axis.
|
||||
:param axis_rotation_z: rotation around Z applied to the +Y axis to
|
||||
obtain the extrusion direction.
|
||||
:param profile_segments: sides on the disk's edge.
|
||||
"""
|
||||
# The +Y axis rotated by axis_rotation_z around Z gives the extrusion
|
||||
# direction: (-sin(θ), cos(θ), 0). The disk axis points along it.
|
||||
axis = Vector((-math.sin(axis_rotation_z), math.cos(axis_rotation_z), 0.0))
|
||||
end = position + axis * depth
|
||||
cls._add_capped_cylinder(bm, position, end, radius, profile_segments)
|
||||
|
||||
@classmethod
|
||||
def _add_capped_cylinder(
|
||||
cls,
|
||||
bm: bmesh.types.BMesh,
|
||||
p0: Vector,
|
||||
p1: Vector,
|
||||
radius: float,
|
||||
segments: int,
|
||||
) -> None:
|
||||
"""Append one capped cylinder of ``radius`` from ``p0`` to ``p1`` to ``bm``."""
|
||||
direction = p1 - p0
|
||||
length = direction.length
|
||||
if length < 1e-9:
|
||||
return
|
||||
direction = direction / length
|
||||
|
||||
z_axis = Vector((0.0, 0.0, 1.0))
|
||||
dot = direction.dot(z_axis)
|
||||
if dot > 1.0 - 1e-6:
|
||||
rotation = Matrix.Identity(4)
|
||||
elif dot < -1.0 + 1e-6:
|
||||
# Anti-parallel: rotate 180° around X so the cone flips bottom-to-top.
|
||||
rotation = Matrix.Rotation(math.pi, 4, "X")
|
||||
else:
|
||||
rotation = z_axis.rotation_difference(direction).to_matrix().to_4x4()
|
||||
|
||||
matrix = Matrix.Translation((p0 + p1) * 0.5) @ rotation
|
||||
bmesh.ops.create_cone(
|
||||
bm,
|
||||
cap_ends=True,
|
||||
cap_tris=False,
|
||||
segments=segments,
|
||||
radius1=radius,
|
||||
radius2=radius,
|
||||
depth=length,
|
||||
matrix=matrix,
|
||||
)
|
||||
|
||||
@@ -135,6 +135,7 @@ class Collector(bonsai.core.tool.Collector):
|
||||
|
||||
if element.is_a("IfcFeatureElementSubtraction"):
|
||||
obj.display_type = "WIRE"
|
||||
obj.display.show_shadows = False
|
||||
|
||||
@classmethod
|
||||
def _create_project_child_collection(cls, name: str) -> bpy.types.Collection:
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2021 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.util.placement
|
||||
import ifcopenshell.util.representation
|
||||
|
||||
import bonsai.core.geometry
|
||||
import bonsai.core.tool
|
||||
import bonsai.tool as tool
|
||||
|
||||
|
||||
@dataclass
|
||||
class DecompositionRecord:
|
||||
type: Literal["fill"]
|
||||
element: ifcopenshell.entity_instance
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConnectionRecord:
|
||||
type: Literal["path"]
|
||||
relating_element: ifcopenshell.entity_instance
|
||||
related_element: ifcopenshell.entity_instance
|
||||
relating_connection_type: str
|
||||
related_connection_type: str
|
||||
relating_priorities: list[int]
|
||||
related_priorities: list[int]
|
||||
|
||||
|
||||
@dataclass
|
||||
class PortConnectionRecord:
|
||||
relating_port_index: int
|
||||
related_element: ifcopenshell.entity_instance
|
||||
related_port_index: int
|
||||
direction: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class PortConnectionSnapshot:
|
||||
"""Port-to-port connections and per-element port counts captured before duplication."""
|
||||
|
||||
by_element: dict[ifcopenshell.entity_instance, list[PortConnectionRecord]] = field(default_factory=dict)
|
||||
port_counts: dict[ifcopenshell.entity_instance, int] = field(default_factory=dict)
|
||||
|
||||
|
||||
class Duplicate(bonsai.core.tool.Duplicate):
|
||||
|
||||
_pending_warnings: list[str] = []
|
||||
|
||||
@classmethod
|
||||
def _emit_warning(cls, message: str) -> None:
|
||||
"""Buffer a warning for later retrieval by an operator. Falling through
|
||||
to a print keeps the message in the Blender console for the headless /
|
||||
no-operator code path."""
|
||||
cls._pending_warnings.append(message)
|
||||
print(f"Bonsai: WARNING — {message}")
|
||||
|
||||
@classmethod
|
||||
def consume_warnings(cls) -> list[str]:
|
||||
"""Return and clear the buffered warnings — operators call this after
|
||||
``tool.Geometry.duplicate_ifc_objects`` to forward each to ``self.report``."""
|
||||
warnings = cls._pending_warnings
|
||||
cls._pending_warnings = []
|
||||
return warnings
|
||||
|
||||
@classmethod
|
||||
def get_decomposition_relationships(
|
||||
cls, objs: list[bpy.types.Object]
|
||||
) -> dict[ifcopenshell.entity_instance, DecompositionRecord]:
|
||||
relationships: dict[ifcopenshell.entity_instance, DecompositionRecord] = {}
|
||||
for obj in objs:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element:
|
||||
continue
|
||||
if building := tool.Spatial.get_host_element(element):
|
||||
relationships[element] = DecompositionRecord(type="fill", element=building)
|
||||
return relationships
|
||||
|
||||
@classmethod
|
||||
def get_connection_relationships(
|
||||
cls, objs: list[bpy.types.Object]
|
||||
) -> dict[ifcopenshell.entity_instance, ConnectionRecord]:
|
||||
relationships: dict[ifcopenshell.entity_instance, ConnectionRecord] = {}
|
||||
for obj in objs:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element:
|
||||
continue
|
||||
if hasattr(element, "ConnectedTo") and element.ConnectedTo:
|
||||
paths = [
|
||||
connection for connection in element.ConnectedTo if connection.is_a("IfcRelConnectsPathElements")
|
||||
]
|
||||
for path in paths:
|
||||
relationships[element] = ConnectionRecord(
|
||||
type="path",
|
||||
relating_element=path.RelatingElement,
|
||||
related_element=path.RelatedElement,
|
||||
relating_connection_type=path.RelatingConnectionType,
|
||||
related_connection_type=path.RelatedConnectionType,
|
||||
relating_priorities=list(path.RelatingPriorities or []),
|
||||
related_priorities=list(path.RelatedPriorities or []),
|
||||
)
|
||||
return relationships
|
||||
|
||||
@classmethod
|
||||
def get_port_connection_relationships(cls, objs: list[bpy.types.Object]) -> PortConnectionSnapshot:
|
||||
"""Snapshot ``IfcRelConnectsPorts`` among MEP elements in ``objs``, indexed for positional-port replay onto duplicates."""
|
||||
# Function-local: top-level import would trigger a partial-init cycle.
|
||||
from bonsai.tool.system import direction_from_port_pair
|
||||
|
||||
snapshot = PortConnectionSnapshot()
|
||||
elements_in_set: set[ifcopenshell.entity_instance] = set()
|
||||
for obj in objs:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if element is not None and tool.System.is_mep_element(element):
|
||||
elements_in_set.add(element)
|
||||
if not elements_in_set:
|
||||
return snapshot
|
||||
|
||||
ordered_elements = sorted(elements_in_set, key=lambda e: e.id())
|
||||
for element in ordered_elements:
|
||||
snapshot.port_counts[element] = len(tool.System.get_ports(element))
|
||||
|
||||
seen: set[tuple[tuple[int, int], tuple[int, int]]] = set()
|
||||
for element in ordered_elements:
|
||||
ports = tool.System.get_ports(element)
|
||||
for port_index, port in enumerate(ports):
|
||||
connected_port = tool.System.get_connected_port(port)
|
||||
if connected_port is None:
|
||||
continue
|
||||
other_element = tool.System.get_port_relating_element(connected_port)
|
||||
if other_element is None or other_element not in elements_in_set:
|
||||
continue
|
||||
other_ports = tool.System.get_ports(other_element)
|
||||
try:
|
||||
other_port_index = other_ports.index(connected_port)
|
||||
except ValueError:
|
||||
continue
|
||||
pair_key = tuple(
|
||||
sorted(
|
||||
[
|
||||
(element.id(), port_index),
|
||||
(other_element.id(), other_port_index),
|
||||
]
|
||||
)
|
||||
)
|
||||
if pair_key in seen:
|
||||
continue
|
||||
seen.add(pair_key)
|
||||
|
||||
snapshot.by_element.setdefault(element, []).append(
|
||||
PortConnectionRecord(
|
||||
relating_port_index=port_index,
|
||||
related_element=other_element,
|
||||
related_port_index=other_port_index,
|
||||
direction=direction_from_port_pair(port, connected_port),
|
||||
)
|
||||
)
|
||||
return snapshot
|
||||
|
||||
@classmethod
|
||||
def recreate_decompositions(
|
||||
cls,
|
||||
relationships: dict[ifcopenshell.entity_instance, DecompositionRecord],
|
||||
old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]],
|
||||
) -> None:
|
||||
for subelement, data in relationships.items():
|
||||
new_subelements = old_to_new.get(subelement)
|
||||
new_elements = old_to_new.get(data.element)
|
||||
if not new_subelements or not new_elements:
|
||||
continue
|
||||
for i, new_subelement in enumerate(new_subelements):
|
||||
new_element = new_elements[i]
|
||||
if data.type == "fill":
|
||||
element = new_element
|
||||
filling = new_subelement
|
||||
voided_obj = tool.Ifc.get_object(new_element)
|
||||
filling_obj = tool.Ifc.get_object(new_subelement)
|
||||
|
||||
existing_opening_occurrence = subelement.FillsVoids[0].RelatingOpeningElement
|
||||
opening = tool.Ifc.run("root.copy_class", product=existing_opening_occurrence)
|
||||
tool.Ifc.run(
|
||||
"geometry.edit_object_placement",
|
||||
product=opening,
|
||||
matrix=ifcopenshell.util.placement.get_local_placement(opening.ObjectPlacement),
|
||||
is_si=False,
|
||||
)
|
||||
|
||||
representation = ifcopenshell.util.representation.get_representation(
|
||||
existing_opening_occurrence, "Model", "Body", "MODEL_VIEW"
|
||||
)
|
||||
representation = ifcopenshell.util.representation.resolve_representation(representation)
|
||||
mapped_representation = tool.Ifc.run("geometry.map_representation", representation=representation)
|
||||
tool.Ifc.run(
|
||||
"geometry.assign_representation",
|
||||
product=opening,
|
||||
representation=mapped_representation,
|
||||
)
|
||||
tool.Ifc.run("feature.add_feature", feature=opening, element=element)
|
||||
tool.Ifc.run("feature.add_filling", opening=opening, element=filling)
|
||||
|
||||
voided_objs = [voided_obj]
|
||||
# Openings affect all subelements of an aggregate
|
||||
for child_subelement in ifcopenshell.util.element.get_decomposition(element):
|
||||
subobj = tool.Ifc.get_object(child_subelement)
|
||||
if subobj:
|
||||
voided_objs.append(subobj)
|
||||
|
||||
for voided_obj in voided_objs:
|
||||
if mesh_data := voided_obj.data:
|
||||
representation = tool.Ifc.get().by_id(
|
||||
tool.Geometry.get_mesh_props(mesh_data).ifc_definition_id
|
||||
)
|
||||
bonsai.core.geometry.switch_representation(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
obj=voided_obj,
|
||||
representation=representation,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def recreate_connections(
|
||||
cls,
|
||||
relationship: dict[ifcopenshell.entity_instance, ConnectionRecord],
|
||||
old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]],
|
||||
) -> None:
|
||||
for element, data in relationship.items():
|
||||
try:
|
||||
new_relating_element = old_to_new.get(data.relating_element)[0]
|
||||
new_related_element = old_to_new.get(data.related_element)[0]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
continue
|
||||
new_rel = tool.Ifc.run(
|
||||
"geometry.connect_path",
|
||||
relating_element=new_relating_element,
|
||||
related_element=new_related_element,
|
||||
relating_connection=data.relating_connection_type,
|
||||
related_connection=data.related_connection_type,
|
||||
)
|
||||
# connect_path hardcodes priorities to []; restore them post-hoc.
|
||||
priority_attrs: dict[str, Any] = {}
|
||||
if data.relating_priorities:
|
||||
priority_attrs["RelatingPriorities"] = data.relating_priorities
|
||||
if data.related_priorities:
|
||||
priority_attrs["RelatedPriorities"] = data.related_priorities
|
||||
if new_rel is not None and priority_attrs:
|
||||
try:
|
||||
tool.Ifc.run("attribute.edit_attributes", product=new_rel, attributes=priority_attrs)
|
||||
except (RuntimeError, ifcopenshell.Error) as e:
|
||||
cls._emit_warning(
|
||||
f"connection priority restore failed for {new_rel}; "
|
||||
f"duplicate has empty RelatingPriorities/RelatedPriorities: {e}"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def recreate_port_connections(
|
||||
cls,
|
||||
snapshot: PortConnectionSnapshot,
|
||||
old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]],
|
||||
) -> None:
|
||||
"""Recreate ``IfcRelConnectsPorts`` between duplicates; skip records whose duplicate's port count diverges from the snapshot."""
|
||||
for relating_element, records in snapshot.by_element.items():
|
||||
for record in records:
|
||||
related_element = record.related_element
|
||||
try:
|
||||
new_relating = old_to_new[relating_element][0]
|
||||
new_related = old_to_new[related_element][0]
|
||||
except (KeyError, IndexError):
|
||||
continue
|
||||
|
||||
new_relating_ports = tool.System.get_ports(new_relating)
|
||||
new_related_ports = tool.System.get_ports(new_related)
|
||||
|
||||
expected_relating = snapshot.port_counts.get(relating_element)
|
||||
if expected_relating is not None and len(new_relating_ports) != expected_relating:
|
||||
cls._emit_warning(
|
||||
f"port reconnect skipped — duplicate has {len(new_relating_ports)} ports, "
|
||||
f"snapshot had {expected_relating}"
|
||||
)
|
||||
continue
|
||||
expected_related = snapshot.port_counts.get(related_element)
|
||||
if expected_related is not None and len(new_related_ports) != expected_related:
|
||||
cls._emit_warning(
|
||||
f"port reconnect skipped — duplicate has {len(new_related_ports)} ports, "
|
||||
f"snapshot had {expected_related}"
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
new_port_a = new_relating_ports[record.relating_port_index]
|
||||
new_port_b = new_related_ports[record.related_port_index]
|
||||
except IndexError:
|
||||
cls._emit_warning(
|
||||
f"port reconnect skipped — record references port index past the duplicate's port list"
|
||||
)
|
||||
continue
|
||||
try:
|
||||
tool.Ifc.run(
|
||||
"system.connect_port",
|
||||
port1=new_port_a,
|
||||
port2=new_port_b,
|
||||
direction=record.direction or "NOTDEFINED",
|
||||
)
|
||||
except (RuntimeError, ifcopenshell.Error) as e:
|
||||
cls._emit_warning(f"port reconnect failed between duplicates: {e}")
|
||||
@@ -73,7 +73,7 @@ import bonsai.core.style
|
||||
import bonsai.core.system
|
||||
import bonsai.core.tool
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
from bonsai.bim.ifc import IfcStore, get_cache_or_detect_lock
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bonsai.bim.module.geometry.prop import (
|
||||
@@ -115,10 +115,42 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
|
||||
@classmethod
|
||||
def clear_cache(cls, element: ifcopenshell.entity_instance) -> None:
|
||||
cache = IfcStore.get_cache()
|
||||
# Cache acquisition can fail if the HDF5 file is locked by another
|
||||
# process — degrade gracefully rather than aborting the caller's
|
||||
# reimport flow. A stale cache entry is harmless; a raised exception
|
||||
# prevents the actual mesh swap. The wrapper sets the project-panel
|
||||
# warning flag on lock so the user sees one prominent notice instead
|
||||
# of per-element log spam.
|
||||
try:
|
||||
cache = get_cache_or_detect_lock()
|
||||
except Exception as exc:
|
||||
print(f"clear_cache: skipping cache invalidation for {element} ({exc})")
|
||||
return
|
||||
if cache and hasattr(element, "GlobalId"):
|
||||
cache.remove(element.GlobalId)
|
||||
|
||||
@classmethod
|
||||
def has_axis_representation(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
"""True if the element carries a shape representation whose
|
||||
RepresentationIdentifier is 'Axis'. Elements without one cannot be
|
||||
projected to an unambiguous 1D path; callers that draw schematic axis
|
||||
overlays must skip them rather than fall back to mesh-derived geometry."""
|
||||
product_rep = getattr(element, "Representation", None)
|
||||
if product_rep is None:
|
||||
return False
|
||||
for rep in product_rep.Representations:
|
||||
if getattr(rep, "RepresentationIdentifier", None) == "Axis":
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def get_body_representation(cls, element: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance | None:
|
||||
"""The element's ``Model/Body/MODEL_VIEW`` representation, or ``None``.
|
||||
Single source for the ``(context, identifier, target_view)`` triple used
|
||||
by every body-geometry reader across walls, slabs, doors, openings, and
|
||||
feature decorators."""
|
||||
return ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
|
||||
|
||||
@classmethod
|
||||
def clear_modifiers(cls, obj: bpy.types.Object) -> None:
|
||||
for modifier in obj.modifiers:
|
||||
@@ -225,7 +257,13 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
break
|
||||
mesh = obj.data
|
||||
assert isinstance(mesh, bpy.types.Mesh)
|
||||
item = tool.Ifc.get().by_id(tool.Geometry.get_mesh_props(mesh).ifc_definition_id)
|
||||
item_id = tool.Geometry.get_mesh_props(mesh).ifc_definition_id
|
||||
try:
|
||||
item = tool.Ifc.get().by_id(item_id)
|
||||
except RuntimeError:
|
||||
# Entity already deleted (e.g. removed as part of a sibling boolean collapse).
|
||||
bpy.data.objects.remove(obj)
|
||||
return
|
||||
rep_obj = props.representation_obj
|
||||
assert (rep_obj := props.representation_obj) and (rep_element := tool.Ifc.get_entity(rep_obj))
|
||||
cls.remove_representation_item(item, rep_element)
|
||||
@@ -389,6 +427,29 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
bm.free()
|
||||
del mesh["ios_edges"]
|
||||
|
||||
@classmethod
|
||||
def get_dissolved_edges(
|
||||
cls,
|
||||
mesh: bpy.types.Mesh,
|
||||
angle_limit: float = radians(1.0),
|
||||
) -> tuple[list[Vector], list[tuple[int, int]]]:
|
||||
# Read-only on `mesh`: builds a throwaway bmesh, dissolves coplanar
|
||||
# edges while preserving material seams, returns wire-overlay data.
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(mesh)
|
||||
bmesh.ops.dissolve_limit(
|
||||
bm,
|
||||
angle_limit=angle_limit,
|
||||
verts=bm.verts,
|
||||
edges=bm.edges,
|
||||
delimit={"MATERIAL"},
|
||||
)
|
||||
bm.verts.index_update()
|
||||
verts = [v.co.copy() for v in bm.verts]
|
||||
edges = [(e.verts[0].index, e.verts[1].index) for e in bm.edges]
|
||||
bm.free()
|
||||
return verts, edges
|
||||
|
||||
@classmethod
|
||||
def apply_item_ids_as_vertex_groups(cls, obj: bpy.types.Object) -> None:
|
||||
"""Save mesh-object item_ids as vertex groups in format 'ios_item_id_xxxx'.
|
||||
@@ -788,6 +849,15 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def has_material_styles(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
"""True when any of ``element``'s materials exposes an
|
||||
``IfcSurfaceStyle``. Gate body-style assignment to avoid double-styling."""
|
||||
return any(
|
||||
tool.Material.get_style(material) is not None
|
||||
for material in ifcopenshell.util.element.get_materials(element)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def reimport_element_representations(
|
||||
cls, obj: bpy.types.Object, representation: ifcopenshell.entity_instance, apply_openings: bool = True
|
||||
@@ -1093,11 +1163,16 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
@classmethod
|
||||
def get_representation_item(cls, obj: bpy.types.Object) -> Union[ifcopenshell.entity_instance, None]:
|
||||
data = obj.data
|
||||
if (
|
||||
isinstance(data, Geometry.TYPES_WITH_MESH_PROPERTIES)
|
||||
and (ifc_id := tool.Geometry.get_mesh_props(data).ifc_definition_id)
|
||||
and ((item := tool.Ifc.get().by_id(ifc_id)).is_a("IfcRepresentationItem"))
|
||||
):
|
||||
if not isinstance(data, Geometry.TYPES_WITH_MESH_PROPERTIES):
|
||||
return None
|
||||
ifc_id = tool.Geometry.get_mesh_props(data).ifc_definition_id
|
||||
if not ifc_id:
|
||||
return None
|
||||
try:
|
||||
item = tool.Ifc.get().by_id(ifc_id)
|
||||
except RuntimeError:
|
||||
return None
|
||||
if item.is_a("IfcRepresentationItem"):
|
||||
return item
|
||||
return None
|
||||
|
||||
@@ -1154,6 +1229,53 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
props.location_checksum = repr(tool.Blender.np_array_legacy(obj.matrix_world.translation).tobytes())
|
||||
props.rotation_checksum = repr(tool.Blender.np_array_legacy(obj.matrix_world.to_3x3()).tobytes())
|
||||
|
||||
@classmethod
|
||||
def commit_placement_if_moved(cls, obj: bpy.types.Object, *, apply_scale: bool = True) -> None:
|
||||
"""Write ``obj.matrix_world`` back to its IFC ``ObjectPlacement`` when the
|
||||
object has drifted since its last placement commit.
|
||||
|
||||
Scope: drop-in only when the gate is exactly ``is_moved(obj)``. Call sites
|
||||
whose gate is wider (e.g. ``is_moved OR is_scaled``) or already enforced
|
||||
upstream (inside an ``if is_moved:`` block) should call
|
||||
``edit_object_placement`` directly to avoid the redundant inner check."""
|
||||
if not tool.Ifc.is_moved(obj):
|
||||
return
|
||||
bonsai.core.geometry.edit_object_placement(
|
||||
tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj, apply_scale=apply_scale
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def restore_placement_from_ifc(cls, obj: bpy.types.Object, element: ifcopenshell.entity_instance) -> None:
|
||||
"""Snap ``obj.matrix_world`` back to ``element``'s committed IFC placement,
|
||||
then re-baseline the drift checksum so ``tool.Ifc.is_moved(obj)`` returns
|
||||
False afterwards.
|
||||
|
||||
Precondition: ``element.ObjectPlacement`` must not be None. Callers in a
|
||||
cancel-style flow that want a "restore-or-clear-drift" semantic must gate
|
||||
on ObjectPlacement themselves and call ``record_object_position`` directly
|
||||
in the no-placement branch."""
|
||||
assert element.ObjectPlacement is not None, (
|
||||
"restore_placement_from_ifc requires ObjectPlacement — gate the caller "
|
||||
"or use restore_or_rebaseline_placement for the restore-or-clear-drift semantic"
|
||||
)
|
||||
matrix_np = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement).copy()
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
matrix_np[:3, 3] *= unit_scale
|
||||
obj.matrix_world = tool.Loader.apply_blender_offset_to_matrix_world(obj, matrix_np)
|
||||
cls.record_object_position(obj)
|
||||
|
||||
@classmethod
|
||||
def restore_or_rebaseline_placement(cls, obj: bpy.types.Object, element: ifcopenshell.entity_instance) -> None:
|
||||
"""Cancel-flow placement restore: revert ``obj.matrix_world`` to the committed
|
||||
IFC placement; when the element has no ObjectPlacement, re-baseline the drift
|
||||
checksum instead so a subsequent edit does not silently commit the discarded drag."""
|
||||
if not tool.Ifc.is_moved(obj):
|
||||
return
|
||||
if element.ObjectPlacement is None:
|
||||
cls.record_object_position(obj)
|
||||
return
|
||||
cls.restore_placement_from_ifc(obj, element)
|
||||
|
||||
@classmethod
|
||||
def remove_connection(cls, connection: ifcopenshell.entity_instance) -> None:
|
||||
tool.Ifc.get().remove(connection)
|
||||
@@ -1205,11 +1327,27 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
bpy.data.objects.remove(obj)
|
||||
return new_obj
|
||||
|
||||
@classmethod
|
||||
def detach_representation(cls, product: ifcopenshell.entity_instance) -> None:
|
||||
"""Replace ``product.Representation`` with a deep copy so the product
|
||||
no longer shares its representation tree (mapped or direct) with any
|
||||
other entity. The ``IfcGeometricRepresentationContext`` is excluded
|
||||
from the copy so contexts stay file-singletons. No-op when the
|
||||
product has no ``Representation`` attribute or it is unset."""
|
||||
rep = getattr(product, "Representation", None)
|
||||
if rep is None:
|
||||
return
|
||||
product.Representation = ifcopenshell.util.element.copy_deep(
|
||||
tool.Ifc.get(), rep, exclude=["IfcGeometricRepresentationContext"]
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def resolve_mapped_representation(
|
||||
cls, representation: ifcopenshell.entity_instance
|
||||
) -> ifcopenshell.entity_instance:
|
||||
if representation.RepresentationType == "MappedRepresentation":
|
||||
if not representation.Items:
|
||||
return representation
|
||||
return cls.resolve_mapped_representation(representation.Items[0].MappingSource.MappedRepresentation)
|
||||
return representation
|
||||
|
||||
@@ -2132,8 +2270,11 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
|
||||
new_active_obj = None
|
||||
# Track decompositions so they can be recreated after the operation
|
||||
decomposition_relationships = tool.Root.get_decomposition_relationships(objects_to_duplicate)
|
||||
connection_relationships = tool.Root.get_connection_relationships(objects_to_duplicate)
|
||||
decomposition_relationships = tool.Duplicate.get_decomposition_relationships(objects_to_duplicate)
|
||||
connection_relationships = tool.Duplicate.get_connection_relationships(objects_to_duplicate)
|
||||
# Snapshot port-to-port connections — copy_class disconnects new ports
|
||||
# by default, leaving Shift+D duplicates unconnected.
|
||||
port_connection_snapshot = tool.Duplicate.get_port_connection_relationships(objects_to_duplicate)
|
||||
old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]] = {}
|
||||
old_obj_name_to_new_obj_name: dict[str, str] = {}
|
||||
|
||||
@@ -2155,10 +2296,7 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
keep_data_linked = linked and not element and not is_tracked_opening
|
||||
|
||||
# Prior to duplicating, sync the object placement to make decomposition recreation more stable.
|
||||
if tool.Ifc.is_moved(obj):
|
||||
bonsai.core.geometry.edit_object_placement(
|
||||
tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj, apply_scale=False
|
||||
)
|
||||
cls.commit_placement_if_moved(obj, apply_scale=False)
|
||||
|
||||
new_obj = obj.copy()
|
||||
temp_data = None
|
||||
@@ -2212,7 +2350,7 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
array_data = arrays_to_duplicate.get(obj, None)
|
||||
tool.Model.handle_array_on_copied_element(new, array_data)
|
||||
if array_data:
|
||||
for child in tool.Blender.Modifier.Array.get_all_children_objects(new):
|
||||
for child in tool.Array.get_all_children_objects(new):
|
||||
child.select_set(True)
|
||||
|
||||
# TODO: add new array children to recreate their decomposition too
|
||||
@@ -2240,10 +2378,11 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
|
||||
# Remove connections with old objects and recreates paths
|
||||
cls.remove_old_connections(old_to_new)
|
||||
tool.Root.recreate_connections(connection_relationships, old_to_new)
|
||||
tool.Duplicate.recreate_connections(connection_relationships, old_to_new)
|
||||
tool.Duplicate.recreate_port_connections(port_connection_snapshot, old_to_new)
|
||||
|
||||
# Recreate decompositions
|
||||
tool.Root.recreate_decompositions(decomposition_relationships, old_to_new)
|
||||
tool.Duplicate.recreate_decompositions(decomposition_relationships, old_to_new)
|
||||
cls.remove_linked_aggregate_data(old_to_new)
|
||||
bonsai.bim.handler.refresh_ui_data()
|
||||
tool.Root.reload_grid_decorator()
|
||||
@@ -2308,8 +2447,8 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
continue
|
||||
|
||||
array_data = []
|
||||
for modifier_data in tool.Blender.Modifier.Array.get_modifiers_data(array_parent):
|
||||
children = set(tool.Blender.Modifier.Array.get_children_objects(modifier_data))
|
||||
for modifier_data in tool.Array.get_modifiers_data(array_parent):
|
||||
children = set(tool.Array.get_children_objects(modifier_data))
|
||||
if children.issubset(selected_objects):
|
||||
modifier_data["children"] = []
|
||||
array_data.append(modifier_data)
|
||||
|
||||
+327
-41
@@ -15,12 +15,14 @@
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was modified with the assistance of an AI coding tool.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import collections.abc
|
||||
import json
|
||||
from collections.abc import Iterable, Sequence
|
||||
from collections.abc import Callable, Iterable, Sequence
|
||||
from copy import deepcopy
|
||||
from math import atan, cos, degrees, pi, radians
|
||||
from typing import (
|
||||
@@ -37,9 +39,11 @@ from typing import (
|
||||
import bmesh
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.feature
|
||||
import ifcopenshell.api.geometry
|
||||
import ifcopenshell.api.grid
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.api.root
|
||||
import ifcopenshell.geom
|
||||
import ifcopenshell.ifcopenshell_wrapper as W
|
||||
import ifcopenshell.util.element
|
||||
@@ -58,6 +62,7 @@ import bonsai.core.geometry
|
||||
import bonsai.core.tool
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim import import_ifc
|
||||
from bonsai.tool.cad import VTX_PRECISION, WELD_TOLERANCE
|
||||
|
||||
T = TypeVar("T")
|
||||
V_ = tool.Blender.V_
|
||||
@@ -77,6 +82,7 @@ if TYPE_CHECKING:
|
||||
BIMRoofProperties,
|
||||
BIMStairProperties,
|
||||
BIMSverchokProperties,
|
||||
BIMWallProperties,
|
||||
BIMWindowProperties,
|
||||
)
|
||||
|
||||
@@ -98,6 +104,10 @@ class Model(bonsai.core.tool.Model):
|
||||
def get_stair_props(cls, obj: bpy.types.Object) -> BIMStairProperties:
|
||||
return obj.BIMStairProperties # pyright: ignore[reportAttributeAccessIssue]
|
||||
|
||||
@classmethod
|
||||
def get_wall_props(cls, obj: bpy.types.Object) -> BIMWallProperties:
|
||||
return obj.BIMWallProperties # pyright: ignore[reportAttributeAccessIssue]
|
||||
|
||||
@classmethod
|
||||
def get_roof_props(cls, obj: bpy.types.Object) -> BIMRoofProperties:
|
||||
return obj.BIMRoofProperties # pyright: ignore[reportAttributeAccessIssue]
|
||||
@@ -123,6 +133,35 @@ class Model(bonsai.core.tool.Model):
|
||||
assert (scene := bpy.context.scene)
|
||||
return scene.BIMPolylineProperties # pyright: ignore[reportAttributeAccessIssue]
|
||||
|
||||
@classmethod
|
||||
def resolve_active_props_for_edit(
|
||||
cls,
|
||||
context: bpy.types.Context,
|
||||
props_getter: Callable[[bpy.types.Object], Any],
|
||||
*,
|
||||
subtype: Optional[tuple[str, Any]] = None,
|
||||
) -> Optional[tuple[bpy.types.Object, Any]]:
|
||||
"""Resolve ``(obj, props)`` for an operator that acts on the active
|
||||
object only while a parametric edit is active.
|
||||
|
||||
Returns ``None`` (the operator should ``return {"CANCELLED"}``) when
|
||||
any of these fail:
|
||||
- no active object,
|
||||
- ``props.is_editing`` is False,
|
||||
- ``subtype`` is given as ``(attr, value)`` and ``props.<attr> != value``.
|
||||
"""
|
||||
obj = context.active_object
|
||||
if not obj:
|
||||
return None
|
||||
props = props_getter(obj)
|
||||
if not getattr(props, "is_editing", False):
|
||||
return None
|
||||
if subtype is not None:
|
||||
attr, value = subtype
|
||||
if getattr(props, attr, None) != value:
|
||||
return None
|
||||
return obj, props
|
||||
|
||||
@classmethod
|
||||
def convert_si_to_unit(cls, value: T) -> T:
|
||||
if isinstance(value, (tuple, list)):
|
||||
@@ -312,6 +351,8 @@ class Model(bonsai.core.tool.Model):
|
||||
@classmethod
|
||||
def get_extrusion(cls, representation: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]:
|
||||
"""Return first found IfcExtrudedAreaSolid"""
|
||||
if not representation.Items:
|
||||
return None
|
||||
item = representation.Items[0]
|
||||
while True:
|
||||
if item.is_a("IfcExtrudedAreaSolid"):
|
||||
@@ -792,7 +833,7 @@ class Model(bonsai.core.tool.Model):
|
||||
assert element or representation, "Either element or representation must be provided."
|
||||
if representation is None:
|
||||
assert element
|
||||
representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
|
||||
representation = tool.Geometry.get_body_representation(element)
|
||||
if not representation:
|
||||
return []
|
||||
booleans = []
|
||||
@@ -804,6 +845,57 @@ class Model(bonsai.core.tool.Model):
|
||||
items.append(item.FirstOperand)
|
||||
return booleans
|
||||
|
||||
@classmethod
|
||||
def get_connected_slab_objs(cls, wall: ifcopenshell.entity_instance) -> list[bpy.types.Object]:
|
||||
"""Return Blender objects for slabs connected to wall via IfcRelConnectsElements(TOP)."""
|
||||
result = []
|
||||
for rel in wall.ConnectedFrom:
|
||||
if rel.is_a("IfcRelConnectsElements") and rel.Description == "TOP":
|
||||
slab_obj = tool.Ifc.get_object(rel.RelatingElement)
|
||||
if slab_obj:
|
||||
result.append(slab_obj)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def get_connected_wall_objs(cls, slab: ifcopenshell.entity_instance) -> list[bpy.types.Object]:
|
||||
"""Return Blender objects for LAYER2 walls connected to slab via IfcRelConnectsElements(TOP)."""
|
||||
result = []
|
||||
for rel in slab.ConnectedTo:
|
||||
if rel.is_a("IfcRelConnectsElements") and rel.Description == "TOP":
|
||||
wall_obj = tool.Ifc.get_object(rel.RelatedElement)
|
||||
if wall_obj:
|
||||
result.append(wall_obj)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def has_underside_connection(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
"""Return True if element has an IfcRelConnectsElements(TOP) relationship."""
|
||||
return any(rel.is_a("IfcRelConnectsElements") and rel.Description == "TOP" for rel in element.ConnectedFrom)
|
||||
|
||||
@classmethod
|
||||
def remove_wall_to_underside_booleans(cls, wall: ifcopenshell.entity_instance) -> None:
|
||||
"""Remove all IfcBooleanResult items previously added by extend_walls_to_underside."""
|
||||
manual_booleans = cls.get_manual_booleans(wall)
|
||||
if not manual_booleans:
|
||||
return
|
||||
ifc_file = tool.Ifc.get()
|
||||
for b in manual_booleans:
|
||||
sec = b.SecondOperand
|
||||
if sec is None:
|
||||
# The IfcPolygonalFaceSet was already deleted externally. Splice the
|
||||
# orphaned IfcBooleanResult out of the chain so the representation stays valid.
|
||||
parents = list(ifc_file.get_inverse(b))
|
||||
for parent in parents:
|
||||
if parent.is_a("IfcBooleanResult") and parent.FirstOperand == b:
|
||||
parent.FirstOperand = b.FirstOperand
|
||||
elif parent.is_a("IfcShapeRepresentation"):
|
||||
new_items = tuple((set(parent.Items) - {b}) | {b.FirstOperand})
|
||||
parent.Items = new_items
|
||||
cls.unmark_manual_booleans(wall, [b.id()])
|
||||
ifc_file.remove(b)
|
||||
elif sec.is_a("IfcTessellatedFaceSet"):
|
||||
tool.Geometry.remove_representation_item(sec, wall)
|
||||
|
||||
@classmethod
|
||||
def get_manual_booleans(
|
||||
cls, element: ifcopenshell.entity_instance, representation: Optional[ifcopenshell.entity_instance] = None
|
||||
@@ -813,10 +905,11 @@ class Model(bonsai.core.tool.Model):
|
||||
return []
|
||||
boolean_ids = json.loads(pset["Data"])
|
||||
if representation is None:
|
||||
representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
|
||||
representation = tool.Geometry.get_body_representation(element)
|
||||
if not representation:
|
||||
return []
|
||||
booleans = [b for b in cls.get_booleans(element, representation) if b.id() in boolean_ids]
|
||||
all_chain_booleans = cls.get_booleans(element, representation)
|
||||
booleans = [b for b in all_chain_booleans if b.id() in boolean_ids]
|
||||
return booleans
|
||||
|
||||
@classmethod
|
||||
@@ -902,7 +995,7 @@ class Model(bonsai.core.tool.Model):
|
||||
# Revolved area check should happen inside bim.enable_editing_extrusion_axis
|
||||
# but keep it here to trigger import_representation_items,
|
||||
# so users will be able to at least move IfcRevolvedAreaSolid, until there will be a full support.
|
||||
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
|
||||
body = tool.Geometry.get_body_representation(element)
|
||||
if body and any(
|
||||
i.is_a("IfcRevolvedAreaSolid") for i in ifcopenshell.util.representation.resolve_base_items(body)
|
||||
):
|
||||
@@ -1015,7 +1108,14 @@ class Model(bonsai.core.tool.Model):
|
||||
def handle_array_on_copied_element(
|
||||
cls, element: ifcopenshell.entity_instance, array_data: Optional[dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""if no `array_data` is provided then an array will be removed from the element"""
|
||||
"""Post-copy hook: decide what to do with the BBIM_Array pset a copy
|
||||
inherits from its source.
|
||||
|
||||
- ``array_data=None`` — detach the copy from any array. Removes the
|
||||
inherited BBIM_Array pset and any CHILD_OF constraint.
|
||||
- ``array_data`` provided — promote the copy to a fresh array parent
|
||||
with an empty children list, using the provided layer config.
|
||||
"""
|
||||
|
||||
if array_data is None:
|
||||
array_pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
|
||||
@@ -1059,8 +1159,8 @@ class Model(bonsai.core.tool.Model):
|
||||
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=array_pset, properties={"Data": json_data})
|
||||
|
||||
for i in range(len(array_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)
|
||||
|
||||
@classmethod
|
||||
def regenerate_array(
|
||||
@@ -1097,12 +1197,17 @@ class Model(bonsai.core.tool.Model):
|
||||
offset = base_offset * i
|
||||
|
||||
for obj in obj_stack:
|
||||
# IndexError when child_i is past the recorded children list
|
||||
# (count grew); RuntimeError when by_guid finds no entity (the
|
||||
# child was deleted outside the array op); AssertionError when
|
||||
# the IFC entity exists but its Blender object was unlinked.
|
||||
# All three fall through to duplication.
|
||||
try:
|
||||
global_id = array["children"][child_i]
|
||||
child_element = tool.Ifc.get().by_guid(global_id)
|
||||
child_obj = tool.Ifc.get_object(child_element)
|
||||
assert child_obj
|
||||
except:
|
||||
except (IndexError, RuntimeError, AssertionError):
|
||||
old_to_new, _ = tool.Geometry.duplicate_ifc_objects([parent_obj])
|
||||
child_element = next(iter(old_to_new.values()))[0]
|
||||
child_obj = tool.Ifc.get_object(child_element)
|
||||
@@ -1139,14 +1244,24 @@ class Model(bonsai.core.tool.Model):
|
||||
removed_children = set(existing_children) - set(array["children"])
|
||||
for removed_child in removed_children:
|
||||
element = tool.Ifc.get().by_guid(removed_child)
|
||||
# Strip any wall/slab opening cut by this child before deletion,
|
||||
# so the host's HasOpenings shrinks symmetrically with count.
|
||||
if getattr(element, "FillsVoids", None):
|
||||
ifcopenshell.api.feature.remove_feature(
|
||||
tool.Ifc.get(), feature=element.FillsVoids[0].RelatingOpeningElement
|
||||
)
|
||||
obj = tool.Ifc.get_object(element)
|
||||
if obj:
|
||||
tool.Geometry.delete_ifc_object(obj)
|
||||
|
||||
if array.get("per_child_opening", array.get("mirror_to_host", True)) and children_elements:
|
||||
cls.mirror_parent_void_fillings_to_children(parent_element, children_elements)
|
||||
|
||||
if array_i in array_layers_to_apply:
|
||||
for child_element in children_elements:
|
||||
pset = tool.Pset.get_element_pset(child_element, "BBIM_Array")
|
||||
ifcopenshell.api.pset.remove_pset(tool.Ifc.get(), product=child_element, pset=pset)
|
||||
cls.unshare_opening_representation(child_element)
|
||||
|
||||
array["children"] = []
|
||||
array["count"] = 1
|
||||
@@ -1159,6 +1274,112 @@ class Model(bonsai.core.tool.Model):
|
||||
tool.Ifc.get(), pset=pset, properties={"Data": json_data, "Parent": parent_element.GlobalId}
|
||||
)
|
||||
|
||||
# Post-condition: parent is selected on return. duplicate_ifc_objects
|
||||
# deselects the source on every call inside the regen loop; without
|
||||
# this restore, callers get a deselected parent for arrays with N >= 2.
|
||||
# TODO: batch the per-child duplicate_ifc_objects([parent]) calls into
|
||||
# a single N-way duplicate — N depsgraph churns + N select/deselect
|
||||
# flips is wasteful, and a batched duplicate would also remove the
|
||||
# need for this restore.
|
||||
parent_obj.select_set(True)
|
||||
|
||||
@classmethod
|
||||
def mirror_parent_void_fillings_to_children(
|
||||
cls,
|
||||
parent_element: ifcopenshell.entity_instance,
|
||||
children_elements: Sequence[ifcopenshell.entity_instance],
|
||||
) -> None:
|
||||
"""Replicate the parent's FillsVoids → host chain onto each array child.
|
||||
|
||||
For each child, tears down any stale opening, creates a new
|
||||
IfcOpeningElement at the child's current placement, reuses the parent's
|
||||
opening representation as a MappedRepresentation, and adds the
|
||||
void + filling pair so the host element is cut once per child.
|
||||
|
||||
No-op when the parent is not a filling, when the host element cannot
|
||||
be resolved, or when the children list is empty. Opt out via the
|
||||
per-layer ``per_child_opening`` flag on ``BBIM_Array.Data`` (legacy
|
||||
key ``mirror_to_host`` still honoured for round-trip with older files).
|
||||
"""
|
||||
host = tool.Spatial.get_host_element(parent_element)
|
||||
if host is None or not children_elements:
|
||||
return
|
||||
|
||||
ifc_file = tool.Ifc.get()
|
||||
parent_opening = parent_element.FillsVoids[0].RelatingOpeningElement
|
||||
parent_opening_rep = ifcopenshell.util.representation.get_representation(
|
||||
parent_opening, "Model", "Body", "MODEL_VIEW"
|
||||
)
|
||||
if parent_opening_rep is None:
|
||||
return
|
||||
parent_opening_rep = ifcopenshell.util.representation.resolve_representation(parent_opening_rep)
|
||||
|
||||
for child in children_elements:
|
||||
if getattr(child, "FillsVoids", None):
|
||||
ifcopenshell.api.feature.remove_feature(ifc_file, feature=child.FillsVoids[0].RelatingOpeningElement)
|
||||
child_obj = tool.Ifc.get_object(child)
|
||||
if child_obj is None:
|
||||
continue
|
||||
|
||||
new_opening = ifcopenshell.api.root.create_entity(
|
||||
ifc_file,
|
||||
ifc_class="IfcOpeningElement",
|
||||
predefined_type="OPENING",
|
||||
name="Opening",
|
||||
)
|
||||
ifcopenshell.api.geometry.edit_object_placement(
|
||||
ifc_file,
|
||||
product=new_opening,
|
||||
matrix=np.array(child_obj.matrix_world),
|
||||
is_si=True,
|
||||
)
|
||||
mapped_representation = ifcopenshell.api.geometry.map_representation(
|
||||
ifc_file, representation=parent_opening_rep
|
||||
)
|
||||
ifcopenshell.api.geometry.assign_representation(
|
||||
ifc_file, product=new_opening, representation=mapped_representation
|
||||
)
|
||||
ifcopenshell.api.feature.add_feature(ifc_file, feature=new_opening, element=host)
|
||||
ifcopenshell.api.feature.add_filling(ifc_file, opening=new_opening, element=child)
|
||||
|
||||
# Openings affect every sub-element of an aggregate, not just the named host.
|
||||
voided_objs: list[bpy.types.Object] = []
|
||||
host_obj = tool.Ifc.get_object(host)
|
||||
if host_obj is not None:
|
||||
voided_objs.append(host_obj)
|
||||
for subelement in tool.Aggregate.get_parts_recursively(host):
|
||||
subobj = tool.Ifc.get_object(subelement)
|
||||
if subobj is not None:
|
||||
voided_objs.append(subobj)
|
||||
|
||||
for voided_obj in voided_objs:
|
||||
if not voided_obj.data:
|
||||
continue
|
||||
voided_element = tool.Ifc.get_entity(voided_obj)
|
||||
if voided_element is None:
|
||||
continue
|
||||
context = tool.Geometry.get_active_representation_context(voided_obj)
|
||||
representation = tool.Geometry.get_representation_by_context(voided_element, context)
|
||||
if representation is None:
|
||||
continue
|
||||
bonsai.core.geometry.switch_representation(
|
||||
tool.Ifc, tool.Geometry, obj=voided_obj, representation=representation
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def unshare_opening_representation(cls, filling: ifcopenshell.entity_instance) -> None:
|
||||
"""Detach a filling's opening representation from any shared mapped body.
|
||||
|
||||
Required when a Bonsai array child is promoted to an independent
|
||||
object: the array's per-child opening mirror builds each child's
|
||||
opening representation as an ``IfcMappedRepresentation`` over the
|
||||
parent opening's body. Without this detach, a later edit replacing
|
||||
the parent body rewrites the shared ``IfcRepresentationMap`` and
|
||||
reshapes the former-child's opening too."""
|
||||
if not getattr(filling, "FillsVoids", None):
|
||||
return
|
||||
tool.Geometry.detach_representation(filling.FillsVoids[0].RelatingOpeningElement)
|
||||
|
||||
@classmethod
|
||||
def replace_object_ifc_representation(
|
||||
cls,
|
||||
@@ -1305,8 +1526,8 @@ class Model(bonsai.core.tool.Model):
|
||||
return [obj for obj in tool.Blender.get_selected_objects() if tool.Ifc.get_entity(obj)]
|
||||
|
||||
@classmethod
|
||||
def has_selected_ifc_objects(cls) -> bool:
|
||||
return any(tool.Ifc.get_entity(obj) for obj in tool.Blender.get_selected_objects())
|
||||
def has_selected_ifc_objects(cls, include_active: bool = True) -> bool:
|
||||
return any(tool.Ifc.get_entity(obj) for obj in tool.Blender.get_selected_objects(include_active=include_active))
|
||||
|
||||
@classmethod
|
||||
def get_selected_mesh_objects(cls) -> list[bpy.types.Object]:
|
||||
@@ -1355,8 +1576,7 @@ class Model(bonsai.core.tool.Model):
|
||||
@classmethod
|
||||
def sync_object_ifc_position(cls, obj: bpy.types.Object) -> None:
|
||||
"""make sure IFC position will be in sync with the Blender object position, if object was moved in Blender"""
|
||||
if tool.Ifc.is_moved(obj):
|
||||
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
|
||||
tool.Geometry.commit_placement_if_moved(obj)
|
||||
|
||||
@classmethod
|
||||
def get_element_matrix(cls, element: ifcopenshell.entity_instance, keep_local: bool = False) -> Matrix:
|
||||
@@ -1388,7 +1608,7 @@ class Model(bonsai.core.tool.Model):
|
||||
if not obj.data:
|
||||
continue
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
|
||||
body = tool.Geometry.get_body_representation(element)
|
||||
bonsai.core.geometry.switch_representation(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
@@ -1505,6 +1725,10 @@ class Model(bonsai.core.tool.Model):
|
||||
"TRIPLE_PANEL_VERTICAL",
|
||||
]
|
||||
|
||||
RoofGenerationMethod = Literal["HEIGHT", "ANGLE"]
|
||||
|
||||
RailingType = Literal["FRAMELESS_PANEL", "WALL_MOUNTED_HANDRAIL"]
|
||||
|
||||
@classmethod
|
||||
def generate_stair_2d_profile(
|
||||
cls,
|
||||
@@ -1756,7 +1980,7 @@ class Model(bonsai.core.tool.Model):
|
||||
from bonsai.bim.module.model.opening import FilledOpeningGenerator
|
||||
|
||||
ifc_file = tool.Ifc.get()
|
||||
fillings = {e: tool.Ifc.get_object(e) for e in tool.Ifc.get_all_element_occurrences(element)}
|
||||
fillings = {e: tool.Ifc.get_object(e) for e in tool.Array.get_parametric_propagation_targets(element)}
|
||||
|
||||
voided_objs = set()
|
||||
has_replaced_opening_representation = False
|
||||
@@ -1898,7 +2122,9 @@ class Model(bonsai.core.tool.Model):
|
||||
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(mesh)
|
||||
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-4)
|
||||
# Looser than auto_detect_curves' VTX_PRECISION: profiles must close into
|
||||
# a single loop, so nearly-coincident endpoints should snap together.
|
||||
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=WELD_TOLERANCE)
|
||||
bmesh.ops.delete(bm, geom=bm.faces, context="FACES_ONLY")
|
||||
|
||||
# https://docs.blender.org/api/blender_python_api_2_63_8/bmesh.html#CustomDataAccess
|
||||
@@ -2126,7 +2352,7 @@ class Model(bonsai.core.tool.Model):
|
||||
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(mesh)
|
||||
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-5)
|
||||
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=VTX_PRECISION)
|
||||
bmesh.ops.delete(bm, geom=bm.faces, context="FACES_ONLY")
|
||||
|
||||
# https://docs.blender.org/api/blender_python_api_2_63_8/bmesh.html#CustomDataAccess
|
||||
@@ -2345,6 +2571,12 @@ class Model(bonsai.core.tool.Model):
|
||||
|
||||
@classmethod
|
||||
def get_existing_x_angle(cls, extrusion: ifcopenshell.entity_instance) -> float:
|
||||
"""Signed slope of the extrusion's direction in the y-z plane (radians).
|
||||
|
||||
Assumes extrusion directions lie in the y-z plane (LAYER2 wall and
|
||||
LAYER3 slab convention). For inverted extrusions (z ≤ 0), adds π to
|
||||
preserve angular continuity for callers consuming the angle via
|
||||
cos/sin."""
|
||||
x, y, z = extrusion.ExtrudedDirection.DirectionRatios
|
||||
vector = Vector((0, 1))
|
||||
x_angle = vector.angle_signed(Vector((y, z)))
|
||||
@@ -2379,12 +2611,15 @@ class Model(bonsai.core.tool.Model):
|
||||
clipping_bm = bmesh.new()
|
||||
vertex_map = {}
|
||||
|
||||
kept = 0
|
||||
for face in bm.faces:
|
||||
face.normal_update()
|
||||
normal = face.normal.to_4d()
|
||||
normal.w = 0
|
||||
if (obj.matrix_world @ normal).z >= -0.5:
|
||||
world_normal_z = (obj.matrix_world @ normal).z
|
||||
if world_normal_z >= -0.5:
|
||||
continue
|
||||
kept += 1
|
||||
new_verts = []
|
||||
for vert in face.verts:
|
||||
if not (new_vert := vertex_map.get(vert.index, None)):
|
||||
@@ -2397,6 +2632,7 @@ class Model(bonsai.core.tool.Model):
|
||||
return
|
||||
|
||||
bmesh.ops.recalc_face_normals(clipping_bm, faces=clipping_bm.faces)
|
||||
clipping_bm.faces.ensure_lookup_table()
|
||||
return clipping_bm # clipping_bm is in project units
|
||||
|
||||
@classmethod
|
||||
@@ -2410,17 +2646,53 @@ class Model(bonsai.core.tool.Model):
|
||||
min_z = min(zs)
|
||||
max_z = max(zs)
|
||||
|
||||
operand = None
|
||||
if (z := max_z - min_z) and not np.isclose(z, 0.0):
|
||||
builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get())
|
||||
ifc_file = tool.Ifc.get()
|
||||
builder = ifcopenshell.util.shape_builder.ShapeBuilder(ifc_file)
|
||||
|
||||
result = bmesh.ops.extrude_face_region(bm, geom=bm.faces)
|
||||
extruded_verts = [elem for elem in result["geom"] if isinstance(elem, bmesh.types.BMVert)]
|
||||
bmesh.ops.translate(bm, verts=extruded_verts, vec=(0, 0, z))
|
||||
# Build one IfcPolygonalFaceSet clip solid per clipping face.
|
||||
# Each solid uses a rectangle on the slope plane rather than the exact face
|
||||
# footprint. The original approach (exact footprint) caused a kissing-solid /
|
||||
# boundary-coincidence bug when the operator is called twice for a ridge roof: the
|
||||
# two slope solids share an exact ridge edge, and OCCT produces spurious extra
|
||||
# vertices. Extending each solid slightly past the ridge (by margin) creates a
|
||||
# volumetric overlap instead of a kissing boundary — OCCT handles overlapping
|
||||
# DIFFERENCE operands correctly.
|
||||
margin = 1.0 # project units past the face edge — enough to ensure overlap at ridge
|
||||
operands = []
|
||||
for face in bm.faces:
|
||||
face.normal_update()
|
||||
normal = Vector(face.normal).normalized()
|
||||
|
||||
verts = [v.co for v in bm.verts]
|
||||
faces = [[v.index for v in p.verts] for p in bm.faces]
|
||||
operand = builder.mesh(verts, faces)
|
||||
# Orthonormal basis spanning the slope plane.
|
||||
ref = Vector((0, 0, 1)) if abs(normal.z) < 0.9 else Vector((1, 0, 0))
|
||||
tangent1 = normal.cross(ref).normalized()
|
||||
tangent2 = normal.cross(tangent1).normalized()
|
||||
|
||||
centroid = sum((v.co for v in face.verts), Vector()) / len(face.verts)
|
||||
|
||||
# Tight bounding rectangle in slope-plane coords, plus a small margin.
|
||||
t1_coords = [(v.co - centroid).dot(tangent1) for v in face.verts]
|
||||
t2_coords = [(v.co - centroid).dot(tangent2) for v in face.verts]
|
||||
half1 = max(abs(c) for c in t1_coords) + margin
|
||||
half2 = max(abs(c) for c in t2_coords) + margin
|
||||
|
||||
# Rectangle on the slope plane, extruded upward in wall-local Z.
|
||||
clip_bm = bmesh.new()
|
||||
v0 = clip_bm.verts.new(centroid + half1 * tangent1 + half2 * tangent2)
|
||||
v1 = clip_bm.verts.new(centroid - half1 * tangent1 + half2 * tangent2)
|
||||
v2 = clip_bm.verts.new(centroid - half1 * tangent1 - half2 * tangent2)
|
||||
v3 = clip_bm.verts.new(centroid + half1 * tangent1 - half2 * tangent2)
|
||||
bottom_face = clip_bm.faces.new([v0, v1, v2, v3])
|
||||
result = bmesh.ops.extrude_face_region(clip_bm, geom=[bottom_face])
|
||||
top_verts = [e for e in result["geom"] if isinstance(e, bmesh.types.BMVert)]
|
||||
bmesh.ops.translate(clip_bm, verts=top_verts, vec=Vector((0, 0, max_z - min_z)))
|
||||
clip_bm.verts.ensure_lookup_table()
|
||||
|
||||
clip_verts = [v.co for v in clip_bm.verts]
|
||||
clip_faces = [[v.index for v in f.verts] for f in clip_bm.faces]
|
||||
operand = builder.mesh(clip_verts, clip_faces)
|
||||
clip_bm.free()
|
||||
operands.append(operand)
|
||||
|
||||
for extrusion in ifcopenshell.util.shape.get_base_extrusions(wall) or []:
|
||||
if extrusion.Position:
|
||||
@@ -2437,10 +2709,9 @@ class Model(bonsai.core.tool.Model):
|
||||
|
||||
extrusion.Depth = max_z / direction[2]
|
||||
|
||||
if operand:
|
||||
booleans = ifcopenshell.api.geometry.add_boolean(
|
||||
tool.Ifc.get(), first_item=extrusion, second_items=[operand]
|
||||
)
|
||||
if operands:
|
||||
body_repr = ifcopenshell.util.representation.get_representation(wall, "Model", "Body", "MODEL_VIEW")
|
||||
booleans = ifcopenshell.api.geometry.add_boolean(ifc_file, first_item=extrusion, second_items=operands)
|
||||
tool.Model.mark_manual_booleans(wall, booleans)
|
||||
|
||||
@classmethod
|
||||
@@ -2693,6 +2964,20 @@ class Model(bonsai.core.tool.Model):
|
||||
|
||||
@classmethod
|
||||
def recreate_wall(cls, element: ifcopenshell.entity_instance, obj: bpy.types.Object) -> None:
|
||||
# Curved fillet-corner walls own a hand-built banana body that
|
||||
# ``regenerate_wall_representation`` would flatten — it reads the axis
|
||||
# as a 2-point reference line and builds a straight extrusion. Rebuild
|
||||
# the curve in place instead: ``regenerate_fillet_corner_wall`` keeps
|
||||
# radius + placement from the pset / current ``ObjectPlacement`` while
|
||||
# picking up new thickness / height from the wall type, which is what
|
||||
# we want when a type-property edit triggered this call.
|
||||
if tool.Parametric.is_fillet_corner_wall(element):
|
||||
# Lazy import: ``tool.Model`` loads before ``bim/module/model`` at
|
||||
# addon enable; a module-level import would cycle.
|
||||
from bonsai.bim.module.model.wall import regenerate_fillet_corner_wall
|
||||
|
||||
regenerate_fillet_corner_wall(element, obj)
|
||||
return
|
||||
rep = ifcopenshell.api.geometry.regenerate_wall_representation(tool.Ifc.get(), element)
|
||||
bonsai.core.geometry.switch_representation(
|
||||
tool.Ifc,
|
||||
@@ -2713,28 +2998,29 @@ class Model(bonsai.core.tool.Model):
|
||||
queue: set[tuple[ifcopenshell.entity_instance, bpy.types.Object]] = set()
|
||||
for wall in walls:
|
||||
element = tool.Ifc.get_entity(wall)
|
||||
if tool.Ifc.is_moved(wall):
|
||||
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=wall)
|
||||
tool.Geometry.commit_placement_if_moved(wall)
|
||||
queue.add((element, wall))
|
||||
for rel in getattr(element, "ConnectedTo", []):
|
||||
obj = tool.Ifc.get_object(rel.RelatedElement)
|
||||
if tool.Ifc.is_moved(obj):
|
||||
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
|
||||
tool.Geometry.commit_placement_if_moved(obj)
|
||||
queue.add((rel.RelatedElement, obj))
|
||||
for rel in getattr(element, "ConnectedFrom", []):
|
||||
obj = tool.Ifc.get_object(rel.RelatingElement)
|
||||
if tool.Ifc.is_moved(obj):
|
||||
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
|
||||
tool.Geometry.commit_placement_if_moved(obj)
|
||||
queue.add((rel.RelatingElement, obj))
|
||||
for element, wall in queue:
|
||||
if tool.Model.get_usage_type(element) == "LAYER2" and wall:
|
||||
# Use layer custom offset
|
||||
if not wall:
|
||||
continue
|
||||
is_layer2_usage = tool.Model.get_usage_type(element) == "LAYER2"
|
||||
is_fillet_corner = tool.Parametric.is_fillet_corner_wall(element)
|
||||
if not (is_layer2_usage or is_fillet_corner):
|
||||
continue
|
||||
if is_layer2_usage:
|
||||
custom_offset = tool.Model.get_material_layer_custom_offset(element, wall)
|
||||
material = ifcopenshell.util.element.get_material(element)
|
||||
if material.is_a("IfcMaterialLayerSetUsage") and custom_offset is not None:
|
||||
material.OffsetFromReferenceLine = custom_offset
|
||||
|
||||
cls.recreate_wall(element, wall)
|
||||
cls.recreate_wall(element, wall)
|
||||
|
||||
@classmethod
|
||||
def regenerate_slab(cls, obj: bpy.types.Object) -> None:
|
||||
|
||||
@@ -0,0 +1,623 @@
|
||||
# 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.
|
||||
|
||||
"""Registry and save-time auto-commit for parametric draft edits.
|
||||
|
||||
The registry is consumed along two orthogonal axes:
|
||||
|
||||
- **Predicate axis**: every entry carries an ``is_<name>`` total predicate. Used
|
||||
by ``find_for_element``, save-flow auto-commit, and per-feature gizmo polls.
|
||||
- **Lifecycle axis**: a subset of entries flagged ``supports_build_edit_lifecycle=True``
|
||||
share the ``Enable/Finish/CancelEditing<Type>`` operator shape and are wired
|
||||
through ``build_edit_lifecycle``. The remainder declare their edit operators
|
||||
directly because their lifecycle (per-attribute diff dispatch, layer-stack
|
||||
editing, mid-spline gizmo drag, …) does not fit the shared mixin contract.
|
||||
|
||||
Adding a new parametric element type is a single entry in ``EDIT_TYPES``;
|
||||
flag ``supports_build_edit_lifecycle`` only if the type's edit lifecycle matches
|
||||
one of the shared mixins in ``bim/parametric_lifecycle.py``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Optional
|
||||
|
||||
import bpy
|
||||
|
||||
import bonsai.core.tool
|
||||
import bonsai.tool as tool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ifcopenshell import entity_instance
|
||||
|
||||
|
||||
# Lowercase ASCII snake_case token; each segment a non-empty letter/digit
|
||||
# sequence starting with a letter. ``"pipe_segment"`` → ``"BIMPipeSegmentProperties"``.
|
||||
_VALID_NAME_RE = re.compile(r"^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$")
|
||||
|
||||
|
||||
def _camel_case(name: str) -> str:
|
||||
return "".join(part.capitalize() for part in name.split("_"))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParametricObject:
|
||||
"""One parametric element type's draft + enable + finish + cancel edit lifecycle.
|
||||
|
||||
The ``name`` token drives every derived identifier: the
|
||||
``BIM<Name>Properties`` attribute on ``bpy.types.Object``, the
|
||||
``bim.enable_editing_<name>`` / ``bim.finish_editing_<name>`` /
|
||||
``bim.cancel_editing_<name>`` operator ``bl_idname``s, and the
|
||||
``tool.Parametric.is_<name>`` runtime predicate.
|
||||
|
||||
The predicate is part of the contract and MUST be total — accept any IFC
|
||||
entity, return a bool, never raise. A raising predicate breaks the save
|
||||
path for every parametric type, not just its own.
|
||||
|
||||
``supports_build_edit_lifecycle`` marks entries whose edit lifecycle fits the
|
||||
shared mixin contract (``_enable_targets`` / ``_finish_targets`` /
|
||||
``_cancel_targets``) and that therefore wire their operators through
|
||||
``build_edit_lifecycle``. Entries with bespoke edit lifecycles (per-attribute
|
||||
diff dispatch, layer-stack editing, mid-spline gizmo drag) leave this
|
||||
False and declare their operator classes directly."""
|
||||
|
||||
name: str
|
||||
has_non_editable_path: bool = False
|
||||
supports_build_edit_lifecycle: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not _VALID_NAME_RE.match(self.name):
|
||||
raise ValueError(
|
||||
f"ParametricObject name {self.name!r} must match "
|
||||
f"{_VALID_NAME_RE.pattern!r} — lowercase letters / digits, "
|
||||
f"optionally split by single underscores (e.g. ``door`` or "
|
||||
f"``pipe_segment``). Leading / trailing underscores and "
|
||||
f"consecutive underscores are rejected because they produce "
|
||||
f"empty CamelCase segments in derived class names."
|
||||
)
|
||||
|
||||
@property
|
||||
def props_attr(self) -> str:
|
||||
return f"BIM{_camel_case(self.name)}Properties"
|
||||
|
||||
@property
|
||||
def enable_op(self) -> str:
|
||||
return f"bim.enable_editing_{self.name}"
|
||||
|
||||
@property
|
||||
def finish_op(self) -> str:
|
||||
return f"bim.finish_editing_{self.name}"
|
||||
|
||||
@property
|
||||
def cancel_op(self) -> str:
|
||||
return f"bim.cancel_editing_{self.name}"
|
||||
|
||||
def is_editing(self, obj: bpy.types.Object) -> bool:
|
||||
props = getattr(obj, self.props_attr, None)
|
||||
return bool(props and getattr(props, "is_editing", False))
|
||||
|
||||
|
||||
class Parametric(bonsai.core.tool.Parametric):
|
||||
class GenerationKeyedCache:
|
||||
"""A dict-keyed cache stamped with the parametric generation counter
|
||||
at fill time. Reads at a later generation drop the whole dict and
|
||||
re-run the loader. Any IFC commit bumps the generation, invalidating
|
||||
all entries en bloc.
|
||||
|
||||
``None`` values are stored verbatim; only "key not in dict" counts as
|
||||
a miss."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._gen: int | None = None
|
||||
self._data: dict = {}
|
||||
|
||||
def get_or_compute(self, key, loader):
|
||||
current = Parametric.get_geom_generation()
|
||||
if self._gen != current:
|
||||
self._data.clear()
|
||||
self._gen = current
|
||||
if key not in self._data:
|
||||
self._data[key] = loader()
|
||||
return self._data[key]
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Explicit drop. Use from ``load_post`` so a fresh file starts clean."""
|
||||
self._data.clear()
|
||||
self._gen = None
|
||||
|
||||
# FIXME(PR4): array / pipe_segment / duct_segment land with their
|
||||
# finish/cancel operators in PR4. Adding them to EDIT_TYPES without those
|
||||
# operators makes auto-commit-on-save dispatch bim.finish_editing_<name>
|
||||
# for objects flagged as in-edit, which then raises because the operator
|
||||
# doesn't exist. PR4 re-adds the three entries together with the operators.
|
||||
EDIT_TYPES: list[ParametricObject] = [
|
||||
ParametricObject("door", has_non_editable_path=True, supports_build_edit_lifecycle=True),
|
||||
ParametricObject("window", has_non_editable_path=True, supports_build_edit_lifecycle=True),
|
||||
ParametricObject("stair", has_non_editable_path=True, supports_build_edit_lifecycle=True),
|
||||
ParametricObject("railing", supports_build_edit_lifecycle=True),
|
||||
ParametricObject("roof", supports_build_edit_lifecycle=True),
|
||||
ParametricObject("wall"),
|
||||
]
|
||||
|
||||
# Annotations for the uppercase constants populated from ``EDIT_TYPES`` by
|
||||
# the binding loop at module bottom. Declared here so IDEs and type
|
||||
# checkers see the attributes without running the loop.
|
||||
DOOR: ClassVar[ParametricObject]
|
||||
WINDOW: ClassVar[ParametricObject]
|
||||
STAIR: ClassVar[ParametricObject]
|
||||
RAILING: ClassVar[ParametricObject]
|
||||
ROOF: ClassVar[ParametricObject]
|
||||
WALL: ClassVar[ParametricObject]
|
||||
|
||||
_geom_generation: int = 0
|
||||
|
||||
@classmethod
|
||||
def get_geom_generation(cls) -> int:
|
||||
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.*
|
||||
|
||||
cls._geom_generation += 1
|
||||
bonsai.bim.handler.update_bim_tool_props()
|
||||
tool.Blender.update_all_viewports()
|
||||
|
||||
@classmethod
|
||||
def find_by_name(cls, name: str) -> Optional[ParametricObject]:
|
||||
return next((f for f in cls.EDIT_TYPES if f.name == name), None)
|
||||
|
||||
@classmethod
|
||||
def _safe_predicate(cls, feature: ParametricObject, element: entity_instance) -> bool:
|
||||
"""Resolve and invoke ``is_<feature.name>`` defensively. The contract is
|
||||
that predicates are total (see ``ParametricObject`` docstring); a
|
||||
regression that turns one predicate raising would otherwise break the
|
||||
save path for every parametric type, not just its own."""
|
||||
predicate = getattr(cls, f"is_{feature.name}", None)
|
||||
if predicate is None:
|
||||
return False
|
||||
try:
|
||||
return bool(predicate(element))
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"parametric predicate is_%s raised on %r",
|
||||
feature.name,
|
||||
element,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def find_for_element(cls, element: entity_instance) -> Optional[ParametricObject]:
|
||||
"""Return the registry entry whose IFC type predicate matches ``element``."""
|
||||
for feature in cls.EDIT_TYPES:
|
||||
if cls._safe_predicate(feature, element):
|
||||
return feature
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def is_object_editing(cls, obj: bpy.types.Object, skip_name: Optional[str] = None) -> Optional[ParametricObject]:
|
||||
"""Return the registry entry whose edit lifecycle is active on ``obj``, or None.
|
||||
|
||||
``skip_name`` excludes one entry from the scan, for callers that want
|
||||
to know if a *different* type is editing."""
|
||||
for feature in cls.EDIT_TYPES:
|
||||
if feature.name == skip_name:
|
||||
continue
|
||||
if feature.is_editing(obj):
|
||||
return feature
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _validated_editing_feature(cls, obj: bpy.types.Object) -> Optional[ParametricObject]:
|
||||
"""Return the active registry entry on ``obj``, validated against the
|
||||
per-type predicate. Returns None when no ``is_editing`` flag is set
|
||||
or when the flag is stale.
|
||||
|
||||
Self-heals: a predicate mismatch clears the flag in place so the
|
||||
finish dispatch never re-picks up a phantom edit."""
|
||||
feature = cls.is_object_editing(obj)
|
||||
if feature is None:
|
||||
return None
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if element is None or not cls._safe_predicate(feature, element):
|
||||
getattr(obj, feature.props_attr).is_editing = False
|
||||
return None
|
||||
return feature
|
||||
|
||||
@classmethod
|
||||
def heal_stale_edit_flags(cls) -> None:
|
||||
"""Validate every scene object's ``is_editing`` flag against the
|
||||
per-type predicate, clearing stale flags in place.
|
||||
|
||||
Run from ``load_post`` so a ``.blend`` saved with phantom flags
|
||||
(e.g. a save that bypassed the auto-commit flush) is consistent the
|
||||
moment it opens."""
|
||||
for obj in bpy.data.objects:
|
||||
cls._validated_editing_feature(obj)
|
||||
|
||||
@classmethod
|
||||
def get_pending_edits(cls) -> list[tuple[bpy.types.Object, str]]:
|
||||
"""``(object, finish_operator_bl_idname)`` pairs for every object
|
||||
with an in-progress parametric draft. Stale flags are cleared in
|
||||
place and excluded."""
|
||||
pending: list[tuple[bpy.types.Object, str]] = []
|
||||
for obj in bpy.data.objects:
|
||||
feature = cls._validated_editing_feature(obj)
|
||||
if feature is not None:
|
||||
pending.append((obj, feature.finish_op))
|
||||
return pending
|
||||
|
||||
@classmethod
|
||||
def run_bim_op(cls, bl_idname: str) -> None:
|
||||
"""Invoke a ``bim.*`` operator by ``bl_idname``.
|
||||
|
||||
Asserts the operator is a ``tool.Ifc.Operator`` subclass — bypassing
|
||||
that wrap would mutate IFC outside Bonsai's transaction system."""
|
||||
verb = bl_idname.removeprefix("bim.")
|
||||
op_cls = getattr(bpy.types, f"BIM_OT_{verb}", None)
|
||||
if op_cls is None or not issubclass(op_cls, tool.Ifc.Operator):
|
||||
raise RuntimeError(
|
||||
f"{bl_idname!r} must be a registered tool.Ifc.Operator subclass for undo-safe IFC mutation"
|
||||
)
|
||||
getattr(bpy.ops.bim, verb)()
|
||||
|
||||
@classmethod
|
||||
def commit_object_draft(cls, obj: bpy.types.Object, finish_op: str) -> bool:
|
||||
"""Run ``finish_op`` scoped to ``obj`` alone. Returns False (with
|
||||
traceback printed) if the operator raised.
|
||||
|
||||
Both ``temp_override`` and ``view_layer.objects.active`` are set:
|
||||
``temp_override`` does not rebind ``objects.active``, and some finish
|
||||
operators read it directly."""
|
||||
view_layer = bpy.context.view_layer
|
||||
original_active = view_layer.objects.active
|
||||
try:
|
||||
with bpy.context.temp_override(active_object=obj, selected_objects=[obj]):
|
||||
view_layer.objects.active = obj
|
||||
try:
|
||||
cls.run_bim_op(finish_op)
|
||||
return True
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"commit of %r via %s failed",
|
||||
obj.name,
|
||||
finish_op,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
finally:
|
||||
view_layer.objects.active = original_active
|
||||
|
||||
@classmethod
|
||||
def commit_pending_edits(cls) -> tuple[int, list[bpy.types.Object]]:
|
||||
"""Run each pending draft's finish operator scoped to its object.
|
||||
|
||||
A per-object failure does not abort the loop — remaining drafts
|
||||
still flush, otherwise the auto-commit would ship the exact silent
|
||||
desync it exists to prevent."""
|
||||
committed = 0
|
||||
failed: list[bpy.types.Object] = []
|
||||
for obj, finish_op in cls.get_pending_edits():
|
||||
if cls.commit_object_draft(obj, finish_op):
|
||||
committed += 1
|
||||
else:
|
||||
failed.append(obj)
|
||||
return committed, failed
|
||||
|
||||
@classmethod
|
||||
def commit_pending_edits_for_selection(
|
||||
cls, names: Optional[tuple[str, ...]] = None
|
||||
) -> tuple[int, list[bpy.types.Object]]:
|
||||
"""Selection-scoped variant. ``names`` filters which registry entries
|
||||
to consider; ``None`` considers every type."""
|
||||
committed = 0
|
||||
failed: list[bpy.types.Object] = []
|
||||
for obj in tool.Blender.get_selected_objects():
|
||||
feature = cls._validated_editing_feature(obj)
|
||||
if feature is None:
|
||||
continue
|
||||
if names is not None and feature.name not in names:
|
||||
continue
|
||||
if cls.commit_object_draft(obj, feature.finish_op):
|
||||
committed += 1
|
||||
else:
|
||||
failed.append(obj)
|
||||
return committed, failed
|
||||
|
||||
@classmethod
|
||||
def _assert_predicates_registered(cls) -> None:
|
||||
"""Loud at addon-enable if any ``EDIT_TYPES`` entry has no matching
|
||||
``is_<name>`` classmethod. Without this, a typo in the registry entry
|
||||
produces a silent-False predicate that never matches — every
|
||||
parametric draft of that type bypasses save-flow auto-commit."""
|
||||
missing = [feature.name for feature in cls.EDIT_TYPES if not callable(getattr(cls, f"is_{feature.name}", None))]
|
||||
if missing:
|
||||
raise RuntimeError(
|
||||
f"tool.Parametric.EDIT_TYPES has entries with no is_<name> predicate: {missing}. "
|
||||
f"Add `is_<name>(cls, element) -> bool` classmethods on tool.Parametric, "
|
||||
f"or remove the entries from EDIT_TYPES."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def register_object_properties(cls, prop_module) -> None:
|
||||
"""Attach ``bpy.types.Object.BIM<Name>Properties`` for every registered
|
||||
parametric type. Skips entries whose ``PropertyGroup`` is absent."""
|
||||
cls._assert_predicates_registered()
|
||||
for feature in cls.EDIT_TYPES:
|
||||
prop_cls = getattr(prop_module, feature.props_attr, None)
|
||||
if prop_cls is None:
|
||||
continue
|
||||
setattr(bpy.types.Object, feature.props_attr, bpy.props.PointerProperty(type=prop_cls))
|
||||
|
||||
@classmethod
|
||||
def unregister_object_properties(cls) -> None:
|
||||
for feature in cls.EDIT_TYPES:
|
||||
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
|
||||
# the registry rather than ``tool.Blender.Modifier`` because they ARE the
|
||||
# registry contract — ``find_for_element`` and ``_validated_editing_feature``
|
||||
# resolve them by name. Coupling them on the same class makes a typo at
|
||||
# registration time an immediate AttributeError instead of a silent None
|
||||
# predicate that never matches.
|
||||
|
||||
@classmethod
|
||||
def is_array(cls, element: entity_instance) -> bool:
|
||||
"""True if element is the PARENT of a Bonsai parametric array.
|
||||
|
||||
Array children also carry a ``BBIM_Array`` pset (their ``Parent``
|
||||
field points back to the original), so checking pset presence alone
|
||||
would falsely match them. The parent is distinguished by
|
||||
``pset.Parent == element.GlobalId``."""
|
||||
import ifcopenshell.util.element
|
||||
|
||||
if element is None:
|
||||
return False
|
||||
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
|
||||
if not pset:
|
||||
return False
|
||||
return pset.get("Parent") == element.GlobalId
|
||||
|
||||
@classmethod
|
||||
def is_railing(cls, element: entity_instance) -> bool:
|
||||
if element is None:
|
||||
return False
|
||||
return tool.Pset.get_element_pset(element, "BBIM_Railing") is not None
|
||||
|
||||
@classmethod
|
||||
def is_roof(cls, element: entity_instance) -> bool:
|
||||
if element is None:
|
||||
return False
|
||||
return tool.Pset.get_element_pset(element, "BBIM_Roof") is not None
|
||||
|
||||
@classmethod
|
||||
def is_window(cls, element: entity_instance) -> bool:
|
||||
if element is None:
|
||||
return False
|
||||
return tool.Pset.get_element_pset(element, "BBIM_Window") is not None
|
||||
|
||||
@classmethod
|
||||
def is_door(cls, element: entity_instance) -> bool:
|
||||
if element is None:
|
||||
return False
|
||||
return tool.Pset.get_element_pset(element, "BBIM_Door") is not None
|
||||
|
||||
@classmethod
|
||||
def is_stair(cls, element: entity_instance) -> bool:
|
||||
if element is None:
|
||||
return False
|
||||
return tool.Pset.get_element_pset(element, "BBIM_Stair") is not None
|
||||
|
||||
@classmethod
|
||||
def is_wall(cls, element: entity_instance) -> bool:
|
||||
"""A wall is editable by the parametric gizmo if it is an IfcWall with LAYER2 usage.
|
||||
|
||||
Unlike doors/windows/stairs, walls do not carry a proprietary BBIM_Wall pset —
|
||||
their parametric state lives in standard IFC (axis polyline, IfcMaterialLayerSetUsage,
|
||||
IfcExtrudedAreaSolid). Any LAYER2 wall qualifies."""
|
||||
if element is None or not element.is_a("IfcWall"):
|
||||
return False
|
||||
return tool.Model.get_usage_type(element) == "LAYER2"
|
||||
|
||||
@classmethod
|
||||
def is_path_connectable_wall(cls, element: entity_instance) -> bool:
|
||||
"""An IfcWall that may participate in IfcRelConnectsPathElements joins —
|
||||
either a LAYER2 parametric wall, or a fillet-corner wall whose body is
|
||||
hand-built but whose axis still drives path connections.
|
||||
|
||||
Distinct from ``is_wall``: that predicate gates parametric edits that
|
||||
would regenerate the body and flatten a curved fillet. Unjoin / join
|
||||
gizmo polls and path-connection partner enumeration use this looser
|
||||
predicate so fillet corners (which have no LAYER2 usage by spec) still
|
||||
surface their join icons."""
|
||||
if element is None or not element.is_a("IfcWall"):
|
||||
return False
|
||||
if tool.Model.get_usage_type(element) == "LAYER2":
|
||||
return True
|
||||
return cls.is_fillet_corner_wall(element)
|
||||
|
||||
@classmethod
|
||||
def is_fillet_corner_wall(cls, element: entity_instance) -> bool:
|
||||
"""``True`` if the wall carries the ``BBIM_Wall.IsFilletCorner`` flag,
|
||||
marking it as a curved corner whose banana body is hand-built rather
|
||||
than regenerated from the wall's axis + layer set."""
|
||||
import ifcopenshell.util.element
|
||||
|
||||
return bool(ifcopenshell.util.element.get_pset(element, "BBIM_Wall", "IsFilletCorner"))
|
||||
|
||||
@classmethod
|
||||
def is_pipe_segment(cls, element: entity_instance) -> bool:
|
||||
return element is not None and element.is_a("IfcPipeSegment")
|
||||
|
||||
@classmethod
|
||||
def is_duct_segment(cls, element: entity_instance) -> bool:
|
||||
return element is not None and element.is_a("IfcDuctSegment")
|
||||
|
||||
@classmethod
|
||||
def build_edit_lifecycle(
|
||||
cls,
|
||||
feature_name: str,
|
||||
mixin: type,
|
||||
labels: tuple[tuple[str, str], tuple[str, str], tuple[str, str]],
|
||||
bl_options: Optional[set[str]] = None,
|
||||
enable_extra_props: Optional[dict[str, Any]] = None,
|
||||
enable_extra_kwargs: Optional[Callable[[Any], dict[str, Any]]] = None,
|
||||
module_name: Optional[str] = None,
|
||||
) -> tuple[type, type, type]:
|
||||
"""Generate (Enable, Finish, Cancel) operator classes for a parametric type.
|
||||
|
||||
``mixin`` provides ``_enable_targets`` / ``_finish_targets`` /
|
||||
``_cancel_targets`` (i.e. inherits from ``ParametricEditMixinBase`` or
|
||||
a sibling). ``labels`` is ``((enable_label, enable_desc), …)`` in
|
||||
Enable / Finish / Cancel order.
|
||||
|
||||
``bl_idname`` and the Python class name come from the registry entry —
|
||||
``feature_name`` MUST already be in ``EDIT_TYPES``, otherwise a typo
|
||||
produces an unregistered operator. Anchoring bl_idnames to the registry
|
||||
eliminates the silent-mismatch failure mode where a hand-typed
|
||||
``bl_idname = "bim.enable_editing_dor"`` produces a class that
|
||||
``find_for_element`` never resolves to.
|
||||
|
||||
``enable_extra_props`` declares extra ``bpy.props.*`` descriptors to
|
||||
attach to the Enable class only (e.g. array's ``item: IntProperty``
|
||||
carrying the target layer index across redo). When set,
|
||||
``enable_extra_kwargs`` must also be supplied: it receives the Enable
|
||||
operator instance and returns a kwargs dict forwarded to
|
||||
``_enable_targets`` so the mixin's enable phase sees the extras.
|
||||
|
||||
``module_name`` sets ``__module__`` on the generated classes — pass
|
||||
``__name__`` from the calling feature module so Blender's right-click
|
||||
→ Edit Source resolves to the feature module rather than the factory
|
||||
site. Defaults to the factory's module, which is sub-optimal for
|
||||
debugging but harmless."""
|
||||
import bonsai.tool as _tool # late import: tool/__init__.py wires this module last
|
||||
|
||||
feature = cls.find_by_name(feature_name)
|
||||
if feature is None:
|
||||
raise RuntimeError(
|
||||
f"build_edit_lifecycle: {feature_name!r} not in EDIT_TYPES — add a "
|
||||
f"ParametricObject entry before declaring its operators"
|
||||
)
|
||||
if not feature.supports_build_edit_lifecycle:
|
||||
raise RuntimeError(
|
||||
f"build_edit_lifecycle: {feature_name!r} has supports_build_edit_lifecycle=False — "
|
||||
f"its edit lifecycle is bespoke. Either declare "
|
||||
f"Enable/Finish/CancelEditing{_camel_case(feature_name)} as direct Operator "
|
||||
f"subclasses, or flip the flag on the EDIT_TYPES entry if the type does fit "
|
||||
f"the shared mixin contract."
|
||||
)
|
||||
if (enable_extra_props is None) != (enable_extra_kwargs is None):
|
||||
raise RuntimeError(
|
||||
f"build_edit_lifecycle({feature_name!r}): enable_extra_props and "
|
||||
f"enable_extra_kwargs must be supplied together — extras with no "
|
||||
f"kwargs builder are unreachable, kwargs with no extras have nothing to forward"
|
||||
)
|
||||
options = bl_options if bl_options is not None else {"REGISTER", "UNDO"}
|
||||
base_classes = (mixin, bpy.types.Operator, _tool.Ifc.Operator)
|
||||
capitalised = _camel_case(feature_name)
|
||||
|
||||
def _build(
|
||||
action: str, bl_idname: str, label: str, desc: str, target_method: str, extras: Optional[dict]
|
||||
) -> type:
|
||||
if extras and target_method == "_enable_targets":
|
||||
assert enable_extra_kwargs is not None
|
||||
kwargs_builder = enable_extra_kwargs
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
return getattr(self, target_method)(context, **kwargs_builder(self))
|
||||
|
||||
else:
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
return getattr(self, target_method)(context)
|
||||
|
||||
attrs: dict[str, Any] = {
|
||||
"bl_idname": bl_idname,
|
||||
"bl_label": label,
|
||||
"bl_description": desc,
|
||||
"bl_options": options,
|
||||
"_execute": _execute,
|
||||
}
|
||||
if module_name is not None:
|
||||
attrs["__module__"] = module_name
|
||||
if extras:
|
||||
# Blender's PropertyGroup machinery reads __annotations__ for bpy.props descriptors.
|
||||
attrs["__annotations__"] = dict(extras)
|
||||
return type(f"{action}Editing{capitalised}", base_classes, attrs)
|
||||
|
||||
return (
|
||||
_build("Enable", feature.enable_op, labels[0][0], labels[0][1], "_enable_targets", enable_extra_props),
|
||||
_build("Finish", feature.finish_op, labels[1][0], labels[1][1], "_finish_targets", None),
|
||||
_build("Cancel", feature.cancel_op, labels[2][0], labels[2][1], "_cancel_targets", None),
|
||||
)
|
||||
|
||||
|
||||
_edit_type_names = [entry.name for entry in Parametric.EDIT_TYPES]
|
||||
if len(set(_edit_type_names)) != len(_edit_type_names):
|
||||
raise RuntimeError(
|
||||
f"EDIT_TYPES name collision: {_edit_type_names}. Each name is the primary key "
|
||||
f"for derived bl_idnames, BIM<Name>Properties attributes, is_<name> predicates, "
|
||||
f"and the uppercase constant — a duplicate silently shadows the first entry."
|
||||
)
|
||||
del _edit_type_names
|
||||
|
||||
# Bind every registered ParametricObject as an uppercase class attribute so
|
||||
# call sites can reference ``tool.Parametric.ROOF`` directly. Renaming a
|
||||
# registry entry renames the constant; a typo at the call site surfaces as
|
||||
# AttributeError at module load.
|
||||
for _entry in Parametric.EDIT_TYPES:
|
||||
setattr(Parametric, _entry.name.upper(), _entry)
|
||||
del _entry
|
||||
@@ -18,10 +18,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Any, Literal, Union, assert_never
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.util.attribute
|
||||
import ifcopenshell.util.element
|
||||
|
||||
@@ -74,6 +76,34 @@ class Pset(bonsai.core.tool.Pset):
|
||||
if pset:
|
||||
return tool.Ifc.get().by_id(pset["id"])
|
||||
|
||||
@classmethod
|
||||
def upsert_pset(
|
||||
cls,
|
||||
element: ifcopenshell.entity_instance,
|
||||
pset_name: str,
|
||||
properties: dict[str, Any],
|
||||
) -> ifcopenshell.entity_instance:
|
||||
"""Get or create ``pset_name`` on ``element``, write ``properties``, return the pset.
|
||||
Centralises the get-element-pset → add-pset-if-missing → edit-pset idiom."""
|
||||
ifc_file = tool.Ifc.get()
|
||||
pset = cls.get_element_pset(element, pset_name)
|
||||
if not pset:
|
||||
pset = ifcopenshell.api.pset.add_pset(ifc_file, product=element, name=pset_name)
|
||||
ifcopenshell.api.pset.edit_pset(ifc_file, pset=pset, properties=properties)
|
||||
return pset
|
||||
|
||||
@classmethod
|
||||
def write_bbim_data(
|
||||
cls,
|
||||
element: ifcopenshell.entity_instance,
|
||||
pset_name: str,
|
||||
data: dict[str, Any],
|
||||
) -> ifcopenshell.entity_instance:
|
||||
"""Get or create the BBIM_<Type> pset and write ``data`` as the IfcText-serialised
|
||||
JSON ``Data`` property. Canonical writer for parametric-modifier pset state."""
|
||||
data_text = tool.Ifc.get().createIfcText(json.dumps(data, default=list))
|
||||
return cls.upsert_pset(element, pset_name, {"Data": data_text})
|
||||
|
||||
@classmethod
|
||||
def get_pset_props(cls, obj: str, obj_type: tool.Ifc.OBJECT_TYPE) -> PsetProperties:
|
||||
if obj_type == "Object":
|
||||
|
||||
@@ -373,26 +373,42 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
except:
|
||||
loc = Vector((0, 0, 0))
|
||||
|
||||
verts_2d = [
|
||||
view3d_utils.location_3d_to_region_2d(region, rv3d, v) for v in snap_obj.verts_3d
|
||||
] # Numpy version is worst in performance
|
||||
|
||||
snap_obj._ensure_bvh()
|
||||
intersected = snap_obj.raycast_boxes(
|
||||
context, event, snap_obj.root, intersected=[], rays=(ray_origin, ray_direction)
|
||||
)
|
||||
|
||||
# Collect edges from intersected BVH boxes
|
||||
edges = []
|
||||
for it in intersected:
|
||||
edges.extend(it.edges)
|
||||
edges = set(edges)
|
||||
|
||||
# Build only the vertices indices that belong to these edges
|
||||
verts_idx: set[int] = set()
|
||||
for e in edges:
|
||||
ev = snap_obj.obj.data.edges[e].vertices
|
||||
verts_idx.add(ev[0])
|
||||
verts_idx.add(ev[1])
|
||||
|
||||
# Lazily project only the needed vertices to 2D screen space
|
||||
verts_2d: dict[int, Vector] = {}
|
||||
for idx in verts_idx:
|
||||
v2d = view3d_utils.location_3d_to_region_2d(
|
||||
region, rv3d, snap_obj.verts_3d[idx]
|
||||
)
|
||||
if v2d is not None:
|
||||
verts_2d[idx] = v2d
|
||||
|
||||
|
||||
edge_verts = {}
|
||||
for e in edges:
|
||||
verts_idx = tuple(snap_obj.obj.data.edges[e].vertices)
|
||||
verts = snap_obj.obj.data.vertices
|
||||
v1 = snap_obj.obj.matrix_world @ verts[verts_idx[0]].co
|
||||
v1_2d = verts_2d[verts_idx[0]]
|
||||
v2 = snap_obj.obj.matrix_world @ verts[verts_idx[1]].co
|
||||
v2_2d = verts_2d[verts_idx[1]]
|
||||
verts_idx = snap_obj.obj.data.edges[e].vertices
|
||||
v1 = snap_obj.verts_3d[verts_idx[0]]
|
||||
v2 = snap_obj.verts_3d[verts_idx[1]]
|
||||
v1_2d = verts_2d.get(verts_idx[0])
|
||||
v2_2d = verts_2d.get(verts_idx[1])
|
||||
if (v1_2d is None) ^ (v2_2d is None):
|
||||
point, _ = cls.intersect_edge_region_border(region, context.space_data, rv3d, v1, v2)
|
||||
if v1_2d is None:
|
||||
@@ -404,10 +420,16 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
|
||||
snap_threshold = 10.0
|
||||
|
||||
for i, point in enumerate(verts_2d):
|
||||
if not point:
|
||||
continue
|
||||
distance = (Vector(mouse_pos) - point).length
|
||||
# Check all vertices for proximity to mouse position.
|
||||
# Re-use the 2D projections already computed for edge endpoints.
|
||||
for i, v3d in enumerate(snap_obj.verts_3d):
|
||||
if i in verts_2d:
|
||||
v2d = verts_2d[i]
|
||||
else:
|
||||
v2d = view3d_utils.location_3d_to_region_2d(region, rv3d, v3d)
|
||||
if v2d is None:
|
||||
continue
|
||||
distance = (Vector(mouse_pos) - v2d).length
|
||||
if distance <= snap_threshold:
|
||||
snap_point = {
|
||||
"object": snap_obj.obj,
|
||||
@@ -799,6 +821,30 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
else:
|
||||
return None, None, None
|
||||
|
||||
@classmethod
|
||||
def process_wireframe_snap_obj(
|
||||
cls,
|
||||
context: bpy.types.Context,
|
||||
event: bpy.types.Event,
|
||||
snap_obj,
|
||||
ray_origin: Vector,
|
||||
closest_snaps: list,
|
||||
):
|
||||
snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
|
||||
hit_obj = None
|
||||
hit = None
|
||||
if snap_points:
|
||||
closest_length_squared = float("inf")
|
||||
for point in snap_points:
|
||||
point["group"] = "Wireframe"
|
||||
closest_snaps.append(point)
|
||||
length = (point["point"] - ray_origin).length_squared
|
||||
if length < closest_length_squared:
|
||||
closest_length_squared = length
|
||||
hit = point["point"]
|
||||
hit_obj = point["object"]
|
||||
return hit_obj, hit
|
||||
|
||||
@classmethod
|
||||
def ray_cast_and_get_closest_to_camera_snaps(
|
||||
cls,
|
||||
@@ -813,35 +859,45 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
|
||||
ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event)
|
||||
|
||||
space = context.space_data
|
||||
xray_mode = (space.shading.type == "SOLID" and space.shading.show_xray) or (
|
||||
space.shading.type == "WIREFRAME" and space.shading.show_xray_wireframe
|
||||
)
|
||||
|
||||
closest_snaps = []
|
||||
hit = None
|
||||
|
||||
for snap_obj in objs_to_raycast:
|
||||
if snap_obj.obj.type in {"EMPTY", "CURVE"} or (
|
||||
hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0
|
||||
):
|
||||
# For wireframe objects we have to test all the snaps to see which is closer
|
||||
snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
|
||||
closest_wf_hit = None
|
||||
closest_wf_length_squared = 1.0
|
||||
closest_wf_point = None
|
||||
if snap_points:
|
||||
for point in snap_points:
|
||||
point["group"] = "Wireframe"
|
||||
closest_snaps.append(point)
|
||||
length = (point["point"] - ray_origin).length_squared
|
||||
if closest_wf_hit is None or length < closest_wf_length_squared:
|
||||
closest_wf_length_squared = length
|
||||
closest_wf_hit = point["point"]
|
||||
closest_wf_point = point
|
||||
if not xray_mode and objs_to_raycast:
|
||||
# Non-xray - only the closest solid object's Face snap is kept by
|
||||
# the caller (detect_snapping_points). Process solids in distance
|
||||
# order and stop at the first hit to minimise raycasts.
|
||||
wireframe_objs = []
|
||||
solid_objs = []
|
||||
for snap_obj in objs_to_raycast:
|
||||
if snap_obj.obj.type in {"EMPTY", "CURVE"} or (
|
||||
hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0
|
||||
):
|
||||
wireframe_objs.append(snap_obj)
|
||||
else:
|
||||
solid_objs.append(snap_obj)
|
||||
|
||||
if closest_wf_point:
|
||||
hit_obj = closest_wf_point["object"]
|
||||
hit = closest_wf_point["point"]
|
||||
face_index = None
|
||||
# Rough distance - object origin to ray origin
|
||||
solid_objs.sort(key=lambda so: (so.obj.matrix_world.translation - ray_origin).length_squared)
|
||||
|
||||
else:
|
||||
# Solid objects
|
||||
# Process wireframe objects first (all of them, always collected)
|
||||
for snap_obj in wireframe_objs:
|
||||
hit_obj, hit = cls.process_wireframe_snap_obj(
|
||||
context, event, snap_obj, ray_origin, closest_snaps
|
||||
)
|
||||
if hit is not None:
|
||||
length_squared = (hit - ray_origin).length_squared
|
||||
if closest_obj is None or length_squared < closest_length_squared:
|
||||
closest_length_squared = length_squared
|
||||
closest_obj = hit_obj
|
||||
closest_hit = hit
|
||||
closest_face_index = None
|
||||
|
||||
# Process solid objects in distance order, stop at first hit
|
||||
for snap_obj in solid_objs:
|
||||
hit_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, snap_obj.obj)
|
||||
|
||||
if hit:
|
||||
@@ -855,14 +911,47 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
}
|
||||
closest_snaps.append(snap_point)
|
||||
|
||||
# Here we test which is closer, including wireframe and solid objects
|
||||
if hit is not None:
|
||||
length_squared = (hit - ray_origin).length_squared
|
||||
if closest_obj is None or length_squared < closest_length_squared:
|
||||
closest_length_squared = length_squared
|
||||
closest_obj = hit_obj
|
||||
closest_hit = hit
|
||||
closest_face_index = face_index
|
||||
length_squared = (hit - ray_origin).length_squared
|
||||
if closest_obj is None or length_squared < closest_length_squared:
|
||||
closest_length_squared = length_squared
|
||||
closest_obj = hit_obj
|
||||
closest_hit = hit
|
||||
closest_face_index = face_index
|
||||
|
||||
break
|
||||
|
||||
else:
|
||||
# Xray mode - process all objects (all snaps are kept by the caller)
|
||||
for snap_obj in objs_to_raycast:
|
||||
if snap_obj.obj.type in {"EMPTY", "CURVE"} or (
|
||||
hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0
|
||||
):
|
||||
hit_obj, hit = cls.process_wireframe_snap_obj(
|
||||
context, event, snap_obj, ray_origin, closest_snaps
|
||||
)
|
||||
face_index = None
|
||||
else:
|
||||
# Solid objects
|
||||
hit_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, snap_obj.obj)
|
||||
|
||||
if hit:
|
||||
snap_point = {
|
||||
"point": hit,
|
||||
"type": "Face",
|
||||
"group": "Object",
|
||||
"object": hit_obj,
|
||||
"face_index": face_index,
|
||||
"distance": 9, # High value so it has low priority
|
||||
}
|
||||
closest_snaps.append(snap_point)
|
||||
|
||||
if hit is not None:
|
||||
length_squared = (hit - ray_origin).length_squared
|
||||
if closest_obj is None or length_squared < closest_length_squared:
|
||||
closest_length_squared = length_squared
|
||||
closest_obj = hit_obj
|
||||
closest_hit = hit
|
||||
closest_face_index = face_index
|
||||
|
||||
# Label snaps from the closest object
|
||||
if closest_obj is not None:
|
||||
@@ -936,12 +1025,19 @@ class SnapObj:
|
||||
def __init__(self, obj: bpy.types.Object):
|
||||
self.__class__.all.append(self)
|
||||
self.obj = obj
|
||||
self.root = self._create_root_node()
|
||||
self.root.edges = [e.index for e in obj.data.edges]
|
||||
self.split_box(self.root, 0)
|
||||
self.root = None
|
||||
self._bvh_built = False
|
||||
self.verts_3d = [obj.matrix_world @ v.co for v in obj.data.vertices]
|
||||
self.snap_points = []
|
||||
|
||||
def _ensure_bvh(self):
|
||||
if self._bvh_built:
|
||||
return
|
||||
self.root = self._create_root_node()
|
||||
self.root.edges = [e.index for e in self.obj.data.edges]
|
||||
self.split_box(self.root, 0)
|
||||
self._bvh_built = True
|
||||
|
||||
def __clear_all__():
|
||||
for instance in SnapObj.all:
|
||||
del instance
|
||||
|
||||
@@ -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.
|
||||
|
||||
"""Side-effect-free slab helpers — IFC reads for LAYER3 extrusions.
|
||||
|
||||
Exposes ``read_geometry``: a single live read of the parametric attributes
|
||||
(extrusion depth and slope) that drive icon placement and dimension display
|
||||
on a LAYER3 slab. Lives in ``tool/`` so bim-layer callers can stay
|
||||
declarative — they get a dict, not an IFC walk."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, TypedDict
|
||||
|
||||
import ifcopenshell.util.unit
|
||||
|
||||
import bonsai.core.tool
|
||||
import bonsai.tool as tool
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import bpy
|
||||
|
||||
|
||||
class SlabGeometry(TypedDict):
|
||||
depth: float
|
||||
x_angle: float
|
||||
|
||||
|
||||
class Slab(bonsai.core.tool.Slab):
|
||||
@classmethod
|
||||
def read_geometry(cls, obj: bpy.types.Object) -> SlabGeometry | None:
|
||||
"""Live-read slab parametric geometry as a dict, or ``None`` if the
|
||||
object is not a LAYER3 extruded slab.
|
||||
|
||||
Returned keys (all SI units): ``depth`` (extrusion thickness along the
|
||||
slab's local Z), ``x_angle`` (slope in radians; zero for level slabs).
|
||||
|
||||
The slope is encoded in ``obj.matrix_world`` as a post-rotation, so
|
||||
callers projecting world points into slab-local space via
|
||||
``mw.inverted()`` will see a level frame whose Z runs along the slab
|
||||
thickness — ``x_angle`` is reported for callers that need the slope
|
||||
as a scalar but is already applied by the placement."""
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element or not tool.Blender.Modifier.is_slab(element):
|
||||
return None
|
||||
representation = tool.Geometry.get_body_representation(element)
|
||||
if not representation:
|
||||
return None
|
||||
extrusion = tool.Model.get_extrusion(representation)
|
||||
if not extrusion:
|
||||
return None
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
x_angle = tool.Model.get_existing_x_angle(extrusion)
|
||||
return {
|
||||
"depth": extrusion.Depth * unit_scale,
|
||||
"x_angle": x_angle,
|
||||
}
|
||||
@@ -90,6 +90,32 @@ class Spatial(bonsai.core.tool.Spatial):
|
||||
break
|
||||
return element
|
||||
|
||||
@classmethod
|
||||
def get_host_element(cls, filling: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance | None:
|
||||
"""The building element that hosts a filling (door/window) via the
|
||||
standard ``FillsVoids → RelatingOpeningElement → VoidsElements →
|
||||
RelatingBuildingElement`` chain, with safety guards at each hop.
|
||||
Returns ``None`` if any link is missing, or if the given entity is
|
||||
not a fillable type (no ``FillsVoids`` inverse).
|
||||
|
||||
For the wall-only case (gizmos that only make sense on walls), use
|
||||
`get_host_wall` which adds an ``IfcWall`` type filter on top of this."""
|
||||
if not getattr(filling, "FillsVoids", None):
|
||||
return None
|
||||
opening = filling.FillsVoids[0].RelatingOpeningElement
|
||||
if not opening.VoidsElements:
|
||||
return None
|
||||
return opening.VoidsElements[0].RelatingBuildingElement
|
||||
|
||||
@classmethod
|
||||
def get_host_wall(cls, filling: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance | None:
|
||||
"""The ``IfcWall`` that hosts a filling (door/window), or ``None``.
|
||||
|
||||
Walls only — fillings hosted in slabs / roofs / arbitrary elements
|
||||
produce ``None`` so wall-offset callers stay opted out cleanly."""
|
||||
host = cls.get_host_element(filling)
|
||||
return host if host and host.is_a("IfcWall") else None
|
||||
|
||||
@classmethod
|
||||
def can_contain(cls, container: ifcopenshell.entity_instance, element: ifcopenshell.entity_instance) -> bool:
|
||||
if tool.Ifc.get_schema() == "IFC2X3":
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections import deque
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Any, Optional, Union
|
||||
|
||||
@@ -26,6 +27,7 @@ import bpy
|
||||
import ifcopenshell.api.geometry
|
||||
import ifcopenshell.api.system
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.util.placement
|
||||
import ifcopenshell.util.system
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
@@ -35,12 +37,29 @@ import bonsai.core.root
|
||||
import bonsai.core.tool
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim import import_ifc
|
||||
from bonsai.bim.module.system.data import ObjectSystemData, SystemDecorationData
|
||||
|
||||
# Data-class imports from ``bonsai.bim.module.system.data`` are function-local:
|
||||
# a top-level import would trigger a partial-init cycle through tool.Ifc.Operator.
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bonsai.bim.module.system.prop import BIMSystemProperties, BIMZoneProperties
|
||||
|
||||
|
||||
_DIRECTION_FROM_FLOW_PAIR: dict[tuple[str, str], str] = {
|
||||
("SOURCE", "SINK"): "SOURCE",
|
||||
("SINK", "SOURCE"): "SINK",
|
||||
("SOURCEANDSINK", "SOURCEANDSINK"): "SOURCEANDSINK",
|
||||
}
|
||||
|
||||
|
||||
def direction_from_port_pair(port_a: ifcopenshell.entity_instance, port_b: ifcopenshell.entity_instance) -> str:
|
||||
"""Derive the ``direction`` arg for ``ifcopenshell.api.system.connect_port``
|
||||
from each port's ``FlowDirection``. Returns ``NOTDEFINED`` for non-canonical pairs."""
|
||||
a = getattr(port_a, "FlowDirection", None) or "NOTDEFINED"
|
||||
b = getattr(port_b, "FlowDirection", None) or "NOTDEFINED"
|
||||
return _DIRECTION_FROM_FLOW_PAIR.get((a, b), "NOTDEFINED")
|
||||
|
||||
|
||||
class System(bonsai.core.tool.System):
|
||||
@classmethod
|
||||
def get_system_props(cls) -> BIMSystemProperties:
|
||||
@@ -81,7 +100,7 @@ class System(bonsai.core.tool.System):
|
||||
# make sure obj.dimensions and .matrix_world has valid data
|
||||
bpy.context.view_layer.update()
|
||||
# need to make sure .ObjectPlacement is also updated when we're going to add ports
|
||||
tool.Model.sync_object_ifc_position(obj)
|
||||
tool.Geometry.commit_placement_if_moved(obj)
|
||||
|
||||
mep_element = tool.Ifc.get_entity(obj)
|
||||
bbox = tool.Blender.get_object_bounding_box(obj)
|
||||
@@ -162,12 +181,12 @@ class System(bonsai.core.tool.System):
|
||||
return ifcopenshell.util.system.get_ports(element)
|
||||
|
||||
@classmethod
|
||||
def get_port_relating_element(cls, port: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance:
|
||||
def get_port_relating_element(cls, port: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]:
|
||||
if tool.Ifc.get_schema() == "IFC2X3":
|
||||
element = port.ContainedIn[0].RelatedElement
|
||||
else:
|
||||
element = port.Nests[0].RelatingObject
|
||||
return element
|
||||
rel = port.ContainedIn[0] if port.ContainedIn else None
|
||||
return rel.RelatedElement if rel else None
|
||||
rel = port.Nests[0] if port.Nests else None
|
||||
return rel.RelatingObject if rel else None
|
||||
|
||||
@classmethod
|
||||
def get_port_predefined_type(cls, mep_element: ifcopenshell.entity_instance) -> str:
|
||||
@@ -280,31 +299,42 @@ class System(bonsai.core.tool.System):
|
||||
system_props = cls.get_system_props()
|
||||
return tool.Ifc.get_entity_by_id(system_props.active_system_id)
|
||||
|
||||
# Decoration-data cache, keyed on (decorator_cache_token, id(decorated_elements_set)).
|
||||
_decoration_data_cache_key: tuple | None = None
|
||||
_decoration_data_cache: dict[str, Any] | None = None
|
||||
|
||||
@classmethod
|
||||
def get_decoration_data(cls) -> dict[str, Any]:
|
||||
from bonsai.bim.decorator_cache import get_decorator_cache_token
|
||||
from bonsai.bim.module.system.data import ObjectSystemData, SystemDecorationData
|
||||
|
||||
if not ObjectSystemData.is_loaded:
|
||||
ObjectSystemData.load()
|
||||
if not SystemDecorationData.is_loaded:
|
||||
SystemDecorationData.load()
|
||||
|
||||
token = get_decorator_cache_token()
|
||||
key = (token, id(SystemDecorationData.data["decorated_elements"]))
|
||||
if key == cls._decoration_data_cache_key and cls._decoration_data_cache is not None:
|
||||
return cls._decoration_data_cache
|
||||
|
||||
result = cls._build_decoration_data()
|
||||
cls._decoration_data_cache_key = key
|
||||
cls._decoration_data_cache = result
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def _build_decoration_data(cls) -> dict[str, Any]:
|
||||
from bonsai.bim.module.system.data import ObjectSystemData, SystemDecorationData
|
||||
|
||||
all_vertices = []
|
||||
preview_edges = []
|
||||
special_vertices = []
|
||||
selected_edges = []
|
||||
selected_vertices = []
|
||||
|
||||
view3d_space = tool.Blender.get_viewport_context()["space_data"].region_3d
|
||||
viewport_matrix = view3d_space.view_matrix.inverted()
|
||||
viewport_y_axis = viewport_matrix.col[1].to_3d().normalized()
|
||||
camera_pos = viewport_matrix.translation
|
||||
dir_to_camera = lambda x: (camera_pos - x).normalized()
|
||||
|
||||
def most_aligned_vector(a, vectors):
|
||||
return max(vectors, key=lambda v: abs(a.dot(v)))
|
||||
|
||||
start_vert_i = 0
|
||||
|
||||
if not ObjectSystemData.is_loaded:
|
||||
ObjectSystemData.load()
|
||||
|
||||
if not SystemDecorationData.is_loaded:
|
||||
SystemDecorationData.load()
|
||||
|
||||
class FlowDirection(Enum):
|
||||
BACKWARD = -1
|
||||
FORWARD = 1
|
||||
@@ -458,6 +488,72 @@ class System(bonsai.core.tool.System):
|
||||
def is_mep_element(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
return element.is_a("IfcFlowSegment") or element.is_a("IfcFlowFitting")
|
||||
|
||||
@classmethod
|
||||
def walk_connected_mep_elements(
|
||||
cls, start_element: ifcopenshell.entity_instance
|
||||
) -> list[ifcopenshell.entity_instance]:
|
||||
"""Return all MEP elements reachable from ``start_element`` via
|
||||
``IfcRelConnectsPorts`` in either direction, in BFS order with
|
||||
``start_element`` first.
|
||||
|
||||
Only ``IfcFlowSegment`` and ``IfcFlowFitting`` instances are
|
||||
returned; non-MEP neighbours reached via a fitting's port are
|
||||
traversed but not collected.
|
||||
"""
|
||||
if not cls.is_mep_element(start_element):
|
||||
return []
|
||||
result: list[ifcopenshell.entity_instance] = []
|
||||
visited: set[int] = set()
|
||||
queue: deque[ifcopenshell.entity_instance] = deque([start_element])
|
||||
while queue:
|
||||
element = queue.popleft()
|
||||
if element.id() in visited:
|
||||
continue
|
||||
visited.add(element.id())
|
||||
if not cls.is_mep_element(element):
|
||||
continue
|
||||
result.append(element)
|
||||
for port in cls.get_ports(element):
|
||||
connected_port = cls.get_connected_port(port)
|
||||
if connected_port is None:
|
||||
continue
|
||||
neighbor = cls.get_port_relating_element(connected_port)
|
||||
if neighbor is None or neighbor.id() in visited:
|
||||
continue
|
||||
queue.append(neighbor)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def get_port_world_position(cls, port: ifcopenshell.entity_instance) -> Vector:
|
||||
"""World-space position of an ``IfcDistributionPort``.
|
||||
|
||||
Follows the parent element's live ``matrix_world`` when available so
|
||||
an uncommitted rotation doesn't drift from its ports; falls back to
|
||||
the raw IFC placement otherwise."""
|
||||
placement = getattr(port, "ObjectPlacement", None)
|
||||
if placement is None:
|
||||
return Vector((0.0, 0.0, 0.0))
|
||||
port_ifc_matrix = Matrix(ifcopenshell.util.placement.get_local_placement(placement).tolist())
|
||||
|
||||
parent_element = cls.get_port_relating_element(port)
|
||||
if parent_element is None:
|
||||
return Vector(port_ifc_matrix.translation)
|
||||
|
||||
parent_obj = tool.Ifc.get_object(parent_element)
|
||||
if parent_obj is None:
|
||||
return Vector(port_ifc_matrix.translation)
|
||||
|
||||
parent_placement = getattr(parent_element, "ObjectPlacement", None)
|
||||
if parent_placement is None:
|
||||
return Vector(port_ifc_matrix.translation)
|
||||
parent_ifc_matrix = Matrix(ifcopenshell.util.placement.get_local_placement(parent_placement).tolist())
|
||||
|
||||
try:
|
||||
port_local_to_parent = parent_ifc_matrix.inverted() @ port_ifc_matrix
|
||||
except ValueError:
|
||||
return Vector(port_ifc_matrix.translation)
|
||||
return (parent_obj.matrix_world @ port_local_to_parent).translation
|
||||
|
||||
@classmethod
|
||||
def get_flow_element_controls(cls, element: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]:
|
||||
if not element.HasControlElements:
|
||||
|
||||
@@ -199,8 +199,8 @@ class Unit(bonsai.core.tool.Unit):
|
||||
if inches is None:
|
||||
inches = 0
|
||||
|
||||
# If feet is negative, inches should also be negative (subtractive)
|
||||
if feet < 0:
|
||||
# If feet is negative (including -0), inches should also be negative (subtractive)
|
||||
if math.copysign(1, feet) < 0:
|
||||
inches = -inches
|
||||
|
||||
# Convert to meters
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
# 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.
|
||||
|
||||
"""Side-effect-free wall helpers — IFC reads and wall-axis geometry, callable from
|
||||
gizmo lambdas without loading the wall's draft props. The world-space geometry helpers
|
||||
are pure-math wrappers over ``bonsai.core.model``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from typing import TYPE_CHECKING, TypedDict
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.util.representation
|
||||
import ifcopenshell.util.unit
|
||||
from mathutils import Vector
|
||||
|
||||
import bonsai.core.model
|
||||
import bonsai.core.tool
|
||||
import bonsai.tool as tool
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import bpy
|
||||
|
||||
|
||||
class WallGeometry(TypedDict):
|
||||
anchor_x: float
|
||||
length: float
|
||||
height: float
|
||||
x_angle: float
|
||||
thickness: float
|
||||
offset: float
|
||||
|
||||
|
||||
class Wall(bonsai.core.tool.Wall):
|
||||
@classmethod
|
||||
def get_length_and_height(cls, wall: ifcopenshell.entity_instance) -> tuple[float, float] | None:
|
||||
"""SI length and vertical height of a LAYER2 extruded wall, or ``None`` for
|
||||
non-parametric bodies (sweeps, brep, non-extrusion booleans)."""
|
||||
representation = tool.Geometry.get_body_representation(wall)
|
||||
if not representation:
|
||||
return None
|
||||
extrusion = tool.Model.get_extrusion(representation)
|
||||
if not extrusion:
|
||||
return None
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
p1, p2 = ifcopenshell.util.representation.get_reference_line(wall)
|
||||
x_angle = tool.Model.get_existing_x_angle(extrusion)
|
||||
return bonsai.core.model.length_and_height_from_extrusion(
|
||||
extrusion_depth=extrusion.Depth,
|
||||
x_angle=x_angle,
|
||||
reference_line_x_extent=p2[0] - p1[0],
|
||||
unit_scale=unit_scale,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_axis_local_extent(cls, wall: ifcopenshell.entity_instance) -> tuple[float, float] | None:
|
||||
"""``(min_x, max_x)`` of the wall's IFC reference line in wall-local SI metres,
|
||||
or ``None``. Anchors wall-edge gizmos at IFC-authoritative ends — ``obj.bound_box``
|
||||
would drift on trimmed walls or walls with end openings."""
|
||||
representation = tool.Geometry.get_body_representation(wall)
|
||||
if not representation:
|
||||
return None
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
p1, p2 = ifcopenshell.util.representation.get_reference_line(wall)
|
||||
x1, x2 = p1[0] * unit_scale, p2[0] * unit_scale
|
||||
return (min(x1, x2), max(x1, x2))
|
||||
|
||||
@classmethod
|
||||
def get_x_angle(cls, wall: ifcopenshell.entity_instance) -> float | None:
|
||||
"""Slanted-extrusion angle (radians) of a LAYER2 wall, zero for vertical walls,
|
||||
``None`` for non-parametric bodies. Callers that assume wall-local Z == world Z
|
||||
must gate on this being zero."""
|
||||
representation = tool.Geometry.get_body_representation(wall)
|
||||
if not representation:
|
||||
return None
|
||||
extrusion = tool.Model.get_extrusion(representation)
|
||||
if not extrusion:
|
||||
return None
|
||||
return tool.Model.get_existing_x_angle(extrusion)
|
||||
|
||||
@classmethod
|
||||
def read_geometry(cls, obj: bpy.types.Object) -> WallGeometry | None:
|
||||
"""Live wall geometry from IFC in SI metres/radians, or ``None`` for
|
||||
non-path-connectable walls. Shared by gizmo positioning and draft
|
||||
initialisation. Fillet-corner walls carry their chord axis as the
|
||||
reference line and report zero thickness / offset (material was
|
||||
unassigned at construction); callers that need a layer-driven thickness
|
||||
must gate on ``tool.Parametric.is_wall`` upstream."""
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element or not tool.Parametric.is_path_connectable_wall(element):
|
||||
return None
|
||||
representation = tool.Geometry.get_body_representation(element)
|
||||
if not representation:
|
||||
return None
|
||||
extrusion = tool.Model.get_extrusion(representation)
|
||||
if not extrusion:
|
||||
return None
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
p1, p2 = ifcopenshell.util.representation.get_reference_line(element)
|
||||
layer_params = tool.Model.get_material_layer_parameters(element)
|
||||
x_angle = tool.Model.get_existing_x_angle(extrusion)
|
||||
return {
|
||||
"anchor_x": p1[0] * unit_scale,
|
||||
"length": (p2[0] - p1[0]) * unit_scale,
|
||||
"height": bonsai.core.model.vertical_height_from_extrusion_depth(extrusion.Depth * unit_scale, x_angle),
|
||||
"x_angle": x_angle,
|
||||
"thickness": layer_params["thickness"],
|
||||
"offset": layer_params["offset"],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def collinear_boundary_world(cls, seg_a: tuple[Vector, Vector], seg_b: tuple[Vector, Vector]) -> Vector:
|
||||
"""World-space midpoint of the closest endpoint pair across two wall axis segments —
|
||||
the anchor for Merge/Unjoin gizmos on collinear or already-joined walls."""
|
||||
return Vector(
|
||||
bonsai.core.model.closest_endpoint_midpoint(
|
||||
(tuple(seg_a[0]), tuple(seg_a[1])),
|
||||
(tuple(seg_b[0]), tuple(seg_b[1])),
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def path_connection_location_world(
|
||||
cls,
|
||||
seg_self: tuple[Vector, Vector],
|
||||
self_conn_type: str,
|
||||
seg_other: tuple[Vector, Vector],
|
||||
other_conn_type: str,
|
||||
parallel_threshold: float = bonsai.core.model.PARALLEL_DOT_THRESHOLD,
|
||||
) -> Vector:
|
||||
"""World-space physical join point of an ``IfcRelConnectsPathElements`` — an
|
||||
endpoint for end-connected walls, the axis intersection for ATPATH junctions."""
|
||||
return Vector(
|
||||
bonsai.core.model.compute_path_connection_location(
|
||||
(tuple(seg_self[0]), tuple(seg_self[1])),
|
||||
self_conn_type,
|
||||
(tuple(seg_other[0]), tuple(seg_other[1])),
|
||||
other_conn_type,
|
||||
parallel_threshold,
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def validate_for_parametric_edit(cls, obj: bpy.types.Object) -> str | None:
|
||||
"""``None`` if the wall is parametrically editable, else a user-facing string naming
|
||||
the specific gap so the user can fix the precise blocker."""
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element:
|
||||
return "Object is not an IFC element."
|
||||
if not element.is_a("IfcWall"):
|
||||
return f"Object is an {element.is_a()}, not an IfcWall."
|
||||
if tool.Model.get_usage_type(element) != "LAYER2":
|
||||
return (
|
||||
"Wall has no IfcMaterialLayerSetUsage with LayerSetDirection AXIS2 (required for parametric editing)."
|
||||
)
|
||||
representation = tool.Geometry.get_body_representation(element)
|
||||
if not representation:
|
||||
return "Wall has no Model/Body/MODEL_VIEW representation to drive parametric dimensions."
|
||||
if not tool.Model.get_extrusion(representation):
|
||||
return (
|
||||
"Wall body is not an IfcExtrudedAreaSolid "
|
||||
"(e.g. a brep mesh or boolean result without a base extrusion)."
|
||||
)
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def has_layer2_usage(cls, wall: ifcopenshell.entity_instance) -> bool:
|
||||
"""True iff ``wall`` is a LAYER2 parametric wall (has ``IfcMaterialLayerSetUsage``
|
||||
with ``LayerSetDirection == AXIS2``). Required by every parametric wall edit —
|
||||
non-LAYER2 walls (brep / freeform bodies) cannot be driven by axis + thickness."""
|
||||
return tool.Model.get_usage_type(wall) == "LAYER2"
|
||||
|
||||
@classmethod
|
||||
def is_straight_axis(cls, wall: ifcopenshell.entity_instance) -> bool:
|
||||
"""True iff the wall's Axis representation is a single straight line segment.
|
||||
|
||||
Curved-axis walls (e.g. a fillet corner inserted between two straight walls)
|
||||
report ``False`` so callers gate them out of operations that assume a straight
|
||||
reference line. The check inspects the ``Plan/Axis/GRAPH_VIEW`` representation
|
||||
when present; falls back to True when no Axis representation exists (the
|
||||
``Body`` extrusion alone is implicitly straight)."""
|
||||
axis_rep = ifcopenshell.util.representation.get_representation(wall, "Plan", "Axis", "GRAPH_VIEW")
|
||||
if axis_rep is None or not axis_rep.Items:
|
||||
return True
|
||||
for item in axis_rep.Items:
|
||||
if item.is_a("IfcPolyline"):
|
||||
if len(item.Points) != 2:
|
||||
return False
|
||||
elif item.is_a("IfcIndexedPolyCurve"):
|
||||
# An ``IfcIndexedPolyCurve`` is straight only when (a) its
|
||||
# ``Points`` list holds exactly two points and (b) it has no
|
||||
# ``Segments`` or only ``IfcLineIndex`` segments. Any ``IfcArcIndex``
|
||||
# makes it curved.
|
||||
segments = getattr(item, "Segments", None)
|
||||
if segments:
|
||||
for seg in segments:
|
||||
if seg.is_a("IfcArcIndex"):
|
||||
return False
|
||||
point_list = item.Points
|
||||
point_coords = getattr(point_list, "CoordList", None) if point_list else None
|
||||
if point_coords and len(point_coords) > 2:
|
||||
return False
|
||||
else:
|
||||
# Trimmed curve, composite curve, B-spline — definitely curved.
|
||||
return False
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def get_world_reference_line(cls, obj: bpy.types.Object) -> tuple[Vector, Vector] | None:
|
||||
"""World-space endpoints of the wall's IFC reference line, in Blender units.
|
||||
|
||||
Returns ``(p1, p2)`` as 3D vectors with the wall's local Z preserved.
|
||||
Returns ``None`` when the wall has no IFC element or no IFC Axis
|
||||
representation. Anchors to the IFC reference line, not the mesh bound
|
||||
box, so it stays correct when the mesh is stale or trimmed past the
|
||||
IFC axis endpoints."""
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if element is None or not tool.Geometry.has_axis_representation(element):
|
||||
return None
|
||||
p1, p2 = ifcopenshell.util.representation.get_reference_line(element)
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
local_p1 = Vector((p1[0] * unit_scale, p1[1] * unit_scale, 0.0))
|
||||
local_p2 = Vector((p2[0] * unit_scale, p2[1] * unit_scale, 0.0))
|
||||
return obj.matrix_world @ local_p1, obj.matrix_world @ local_p2
|
||||
|
||||
@classmethod
|
||||
def walk_connected_walls(
|
||||
cls,
|
||||
start_element: ifcopenshell.entity_instance,
|
||||
node_cap: int = 5000,
|
||||
) -> list[ifcopenshell.entity_instance]:
|
||||
"""BFS over ``IfcRelConnectsPathElements`` from ``start_element``.
|
||||
|
||||
Returns every ``IfcWall`` reachable in either direction (relating /
|
||||
related side of the relation) in BFS order with ``start_element``
|
||||
first. Stops when ``node_cap`` walls have been visited so a corrupt
|
||||
or massive network can't lock up a draw callback. Non-wall path
|
||||
elements (e.g. ``IfcRoof``, ``IfcSlab``) are traversed but not
|
||||
collected — they may bridge two disjoint wall runs.
|
||||
|
||||
Mirror of ``tool.System.walk_connected_mep_elements``."""
|
||||
if not start_element.is_a("IfcWall"):
|
||||
return []
|
||||
result: list[ifcopenshell.entity_instance] = []
|
||||
visited: set[int] = set()
|
||||
queue: deque[ifcopenshell.entity_instance] = deque([start_element])
|
||||
while queue and len(visited) < node_cap:
|
||||
element = queue.popleft()
|
||||
if element.id() in visited:
|
||||
continue
|
||||
visited.add(element.id())
|
||||
if element.is_a("IfcWall"):
|
||||
result.append(element)
|
||||
# ``ConnectedTo`` / ``ConnectedFrom`` are the IFC inverse
|
||||
# attributes that expose the relations where this element
|
||||
# is the relating / related side respectively.
|
||||
for rel in getattr(element, "ConnectedTo", []) or ():
|
||||
if rel.is_a("IfcRelConnectsPathElements"):
|
||||
neighbor = rel.RelatedElement
|
||||
if neighbor is not None and neighbor.id() not in visited:
|
||||
queue.append(neighbor)
|
||||
for rel in getattr(element, "ConnectedFrom", []) or ():
|
||||
if rel.is_a("IfcRelConnectsPathElements"):
|
||||
neighbor = rel.RelatingElement
|
||||
if neighbor is not None and neighbor.id() not in visited:
|
||||
queue.append(neighbor)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def compute_wall_fillet_geometry(
|
||||
cls,
|
||||
wall_a_obj: bpy.types.Object,
|
||||
wall_b_obj: bpy.types.Object,
|
||||
radius: float,
|
||||
arc_resolution: int = bonsai.core.model.FILLET_DEFAULT_ARC_RESOLUTION,
|
||||
) -> dict | None:
|
||||
"""Compute fillet geometry between two walls in world space.
|
||||
|
||||
Returns a dict augmented with ``profile_thickness`` and ``height`` from
|
||||
the active (A) wall's LAYER2 parameters, plus ``wall_type_id`` and
|
||||
``x_angle``. Returns ``None`` when either wall lacks a reference line
|
||||
or LAYER2 usage."""
|
||||
axis_a = cls.get_world_reference_line(wall_a_obj)
|
||||
axis_b = cls.get_world_reference_line(wall_b_obj)
|
||||
if axis_a is None or axis_b is None:
|
||||
return None
|
||||
|
||||
wall_a = tool.Ifc.get_entity(wall_a_obj)
|
||||
if wall_a is None or not cls.has_layer2_usage(wall_a):
|
||||
return None
|
||||
|
||||
seg_a = ((axis_a[0].x, axis_a[0].y, axis_a[0].z), (axis_a[1].x, axis_a[1].y, axis_a[1].z))
|
||||
seg_b = ((axis_b[0].x, axis_b[0].y, axis_b[0].z), (axis_b[1].x, axis_b[1].y, axis_b[1].z))
|
||||
result = bonsai.core.model.compute_fillet_polylines(seg_a, seg_b, radius, arc_resolution)
|
||||
|
||||
layers = tool.Model.get_material_layer_parameters(wall_a)
|
||||
length_height = cls.get_length_and_height(wall_a)
|
||||
wall_type = ifcopenshell.util.element.get_type(wall_a)
|
||||
result.update(
|
||||
{
|
||||
"profile_thickness": layers["thickness"],
|
||||
"profile_offset": layers["offset"],
|
||||
"height": length_height[1] if length_height else None,
|
||||
"x_angle": cls.get_x_angle(wall_a) or 0.0,
|
||||
"wall_type_id": wall_type.id() if wall_type else None,
|
||||
}
|
||||
)
|
||||
return result
|
||||
@@ -51,6 +51,62 @@ To use these tools:
|
||||
2. Use the appropriate shortcut or select the tool from the top bar.
|
||||
3. Follow the on-screen prompts or adjust parameters as needed.
|
||||
|
||||
Interactive Parametric Editing
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
Selected walls expose an in-viewport parametric edit mode that mirrors the door /
|
||||
window / stair pen-icon UI:
|
||||
|
||||
1. Select a single wall. A pen (Edit Wall) icon appears next to the wall in the
|
||||
3D viewport, and a matching ``Edit Wall`` button is available in the
|
||||
``Parametric Geometry`` tab of the N panel.
|
||||
2. Click the pen icon (or the panel button) to enter edit mode. Dimension
|
||||
gizmos for length, height, slope (x-angle) and the layer offset baseline
|
||||
appear around the wall.
|
||||
3. Drag any handle to update the value. Dragging only modifies the in-progress
|
||||
draft — the IFC file is not touched until you commit, so dragging a length
|
||||
handle through many intermediate values produces zero extra IFC entities.
|
||||
4. Click the green ✓ icon to commit; click the red ✗ to discard. Pressing the
|
||||
✓ icon on a wall that hasn't been dragged is a true byte-identical no-op —
|
||||
the IFC file is unchanged.
|
||||
|
||||
While editing, additional gizmos surface based on context:
|
||||
|
||||
- **Cycle Baseline**: cycles the layer offset baseline (Exterior → Centreline →
|
||||
Interior). Shift+click cycles in reverse.
|
||||
- **3D-cursor scissors**: appears when the 3D cursor sits on the wall axis;
|
||||
clicking splits the wall at the cursor's projected X.
|
||||
- **3D-cursor extend (horizontal)**: appears when the 3D cursor sits beyond the
|
||||
wall axis; clicking extends the wall to the cursor's projected X.
|
||||
- **3D-cursor extend (vertical)**: appears when the 3D cursor sits above /
|
||||
below the wall; clicking extends the wall's height to the cursor's Z.
|
||||
- **Rotate 90°**: rotates the wall around its Z axis.
|
||||
- **Show / hide openings**: toggles opening fill visibility (doors and windows).
|
||||
|
||||
When two walls are selected, the gizmo switches to a state-aware icon at their
|
||||
common point:
|
||||
|
||||
- Already joined → an Unjoin icon at the shared corner.
|
||||
- Collinear (same axis line) → a Merge icon at the boundary midpoint.
|
||||
- Joinable corner → a Join icon at the floor + an Extend-To-Wall icon at the
|
||||
active wall's top.
|
||||
|
||||
When a wall and a slab (LAYER3 element) are selected, an Extend-Vertically icon
|
||||
appears at the wall's origin / slab elevation; clicking dispatches
|
||||
``bim.extend_walls_to_underside``.
|
||||
|
||||
When a wall and a non-wall, non-slab object are selected, an Add-Opening icon
|
||||
appears above the wall at the other object's projected X.
|
||||
|
||||
Auto-commit on save
|
||||
~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Pressing Ctrl+S (or running ``bim.save_project``) while any wall is mid-edit
|
||||
flushes every pending parametric draft first — the same Apply-Wall-Edits the ✓
|
||||
icon performs, scoped per wall. The IFC saved on disk reflects the values the
|
||||
user dragged, not the snapshot taken when edit mode was entered. Each commit
|
||||
produces its own undo entry, so Ctrl+Z walks back through commits individually.
|
||||
|
||||
Aligning Walls
|
||||
^^^^^^^^^^^^^^
|
||||
|
||||
|
||||
+31
-3
@@ -17,18 +17,46 @@
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
Requires pytest installed under blender
|
||||
Requires pytest installed under blender.
|
||||
|
||||
Usage: `blender -b -P runpytest.py -- ARGS`
|
||||
Usage:
|
||||
blender -b -P runpytest.py -- ARGS
|
||||
|
||||
Alternative (when the calling shell strips or reorders the ``--`` separator
|
||||
before it reaches Blender — observed with some PowerShell / wrapper-script
|
||||
invocations on Windows): pass the same pytest args via the
|
||||
``BONSAI_TEST_ARGS`` environment variable as a single shell-quoted string
|
||||
and invoke without ``--``::
|
||||
|
||||
$env:BONSAI_TEST_ARGS = "test/bim/ -x -q"
|
||||
blender -b -P runpytest.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
argv = [__file__]
|
||||
|
||||
if "--" in sys.argv:
|
||||
env_args = os.environ.get("BONSAI_TEST_ARGS", "")
|
||||
if env_args:
|
||||
# POSIX-style quoting works on all three OSes — env var values are
|
||||
# literal strings (no shell evaluation when Python reads them), and
|
||||
# POSIX quoting (``'foo "bar baz" qux'`` → three tokens, quotes stripped)
|
||||
# matches what most docs and examples use.
|
||||
argv += shlex.split(env_args)
|
||||
# On the env-var path the args never appear in Blender's argv at all,
|
||||
# so any pytest plugin that reads ``sys.argv`` directly (instead of
|
||||
# going through pytest's API) would otherwise see only Blender's own
|
||||
# ``-b -P runpytest.py`` and miss the test args entirely. Shadow argv
|
||||
# so those plugins see the pytest-shaped view they expect.
|
||||
sys.argv = list(argv)
|
||||
elif "--" in sys.argv:
|
||||
# The traditional path: Blender forwards everything after ``--`` to the
|
||||
# script via ``sys.argv``. ``sys.argv`` is deliberately left as Blender
|
||||
# set it — pre-existing behavior, preserved.
|
||||
i = sys.argv.index("--")
|
||||
argv += sys.argv[i + 1 :]
|
||||
|
||||
|
||||
@@ -285,6 +285,32 @@ Scenario: Override duplicate move - without active IFC data
|
||||
Then the object "Cube" exists
|
||||
And the object "Cube.001" exists
|
||||
|
||||
Scenario: Override duplicate move - non-IFC objects inside an IFC project
|
||||
Given an empty IFC project
|
||||
And I add a cube
|
||||
And the object "Cube" is selected
|
||||
When I duplicate the selected objects
|
||||
Then the object "Cube" exists
|
||||
And the object "Cube.001" exists
|
||||
And the object "Cube.001" is selected
|
||||
|
||||
Scenario: Override duplicate move - mixed IFC and non-IFC selection
|
||||
Given an empty IFC project
|
||||
And I add a cube
|
||||
And the object "Cube" is selected
|
||||
And I look at the "Class" panel
|
||||
And I set the "Products" property to "IfcElement"
|
||||
And I set the "Class" property to "IfcWall"
|
||||
And I click "Assign IFC Class"
|
||||
And I add a cube
|
||||
And the object "IfcWall/Cube" is selected
|
||||
And additionally the object "Cube" is selected
|
||||
When I duplicate the selected objects
|
||||
Then the object "IfcWall/Cube.001" exists
|
||||
And the object "IfcWall/Cube.001" is selected
|
||||
And the object "Cube.001" exists
|
||||
And the object "Cube.001" is selected
|
||||
|
||||
Scenario: Override duplicate move - with active IFC data
|
||||
Given an empty IFC project
|
||||
And I add a cube
|
||||
|
||||
@@ -285,6 +285,7 @@ Scenario: Split a wall which has a flipped door
|
||||
And the object "IfcWall/Wall" is selected
|
||||
And I press "bim.hotkey(hotkey='S_K')"
|
||||
Then the object "IfcDoor/Door" is at "8.01,0.1,0"
|
||||
And the object "IfcWall/Wall.001" is filled by "IfcDoor/Door"
|
||||
|
||||
Scenario: Offset walls
|
||||
Given an empty IFC project
|
||||
@@ -673,6 +674,129 @@ Scenario: Create door type based on door modifier, add an occurrence of it and e
|
||||
And I press "bim.finish_editing_door()"
|
||||
Then nothing happens
|
||||
|
||||
Scenario: Saving with a door mid-edit auto-commits the draft value to the IFC pset
|
||||
Given an empty IFC project
|
||||
And I trigger "Add Element"
|
||||
And I set the "Class" property to "IfcDoorType"
|
||||
And I set the "Predefined Type" property to "DOOR"
|
||||
And I set the "Representation" property to "Door"
|
||||
When I click "OK"
|
||||
And I press "bim.add_occurrence"
|
||||
And I press "bim.enable_editing_door()"
|
||||
And I set "active_object.BIMDoorProperties.overall_height" to "2.5"
|
||||
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"
|
||||
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"
|
||||
|
||||
Scenario: Saving with no parametric edits in progress leaves the door pset unchanged
|
||||
Given an empty IFC project
|
||||
And I trigger "Add Element"
|
||||
And I set the "Class" property to "IfcDoorType"
|
||||
And I set the "Predefined Type" property to "DOOR"
|
||||
And I set the "Representation" property to "Door"
|
||||
When I click "OK"
|
||||
And I press "bim.add_occurrence"
|
||||
And the variable "pre_save_height" is "__import__('json').loads(ifcopenshell.util.element.get_pset({ifc}.by_type('IfcDoor')[0], 'BBIM_Door', 'Data'))['overall_height']"
|
||||
When I press "bim.save_project(filepath='{temp_project_path}', should_save_as=True)"
|
||||
Then the variable "post_save_height" is "__import__('json').loads(ifcopenshell.util.element.get_pset({ifc}.by_type('IfcDoor')[0], 'BBIM_Door', 'Data'))['overall_height']"
|
||||
And the variable "post_save_height" equals "{pre_save_height}"
|
||||
|
||||
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 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 I press "bim.add_occurrence"
|
||||
And the object "IfcWall/Wall" is selected
|
||||
And I press "bim.enable_editing_wall()"
|
||||
Then "active_object.BIMWallProperties.is_editing" is "True"
|
||||
When I press "bim.save_project(filepath='{temp_project_path}', should_save_as=True)"
|
||||
Then "active_object.BIMWallProperties.is_editing" is "False"
|
||||
|
||||
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 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 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}"
|
||||
|
||||
Scenario: Cancelling a wall edit clears is_editing
|
||||
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 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 I press "bim.add_occurrence"
|
||||
And the object "IfcWall/Wall" is selected
|
||||
And I press "bim.enable_editing_wall()"
|
||||
When I press "bim.cancel_editing_wall()"
|
||||
Then "active_object.BIMWallProperties.is_editing" is "False"
|
||||
|
||||
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 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 I press "bim.add_occurrence"
|
||||
And the object "IfcWall/Wall" is selected
|
||||
When I press "bim.enable_editing_wall()"
|
||||
Then "active_object.BIMWallProperties.is_editing" is "True"
|
||||
When I press "bim.finish_editing_wall()"
|
||||
Then "active_object.BIMWallProperties.is_editing" is "False"
|
||||
|
||||
Scenario: Rotate a wall 90° via bim.rotate_wall_90
|
||||
Given an empty IFC project
|
||||
And I load the demo construction library
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
|
||||
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
|
||||
And I press "bim.add_occurrence"
|
||||
And the object "IfcWall/Wall" is selected
|
||||
When I press "bim.rotate_wall_90()"
|
||||
Then the object "IfcWall/Wall" dimensions are "1,0.1,3"
|
||||
And the object "IfcWall/Wall" bottom left corner is at "0,0,0"
|
||||
And the object "IfcWall/Wall" top right corner is at "-0.1,1,3"
|
||||
|
||||
Scenario: Splitting a wall with another wall mid-edit commits the pending edit first
|
||||
Given an empty IFC project
|
||||
And I load the demo construction library
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
|
||||
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
|
||||
And I press "bim.add_occurrence"
|
||||
And the object "IfcWall/Wall" is selected
|
||||
And I press "bim.enable_editing_wall()"
|
||||
Then "active_object.BIMWallProperties.is_editing" is "True"
|
||||
When I press "bim.split_wall()"
|
||||
Then "active_object.BIMWallProperties.is_editing" is "False"
|
||||
|
||||
Scenario: Create a door, undo and create a new door
|
||||
Given an empty IFC project
|
||||
And I prepare to undo
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# 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.
|
||||
|
||||
"""Regression guard: overlapping distance gizmos must let the smaller one win.
|
||||
|
||||
When two ``GizmoDimension`` instances overlap on screen (e.g. a short dimension
|
||||
nested inside a longer one along the same axis), the longer one's hit box fully
|
||||
contains the shorter one's. Without a depth bias the longer one wins the GPU
|
||||
select tie-break and the shorter one becomes unreachable.
|
||||
|
||||
``GizmoDimension.set_dimension_length`` writes ``select_bias = -dimension_length``
|
||||
so the smaller one writes a higher (less-negative) bias and wins. The longer one
|
||||
stays clickable at its exposed ends regardless of bias.
|
||||
|
||||
We call ``set_dimension_length`` as an unbound method on a ``SimpleNamespace``
|
||||
fake ``self``. Its body only *writes* attributes (``_display_value``,
|
||||
``_dimension_length``, ``select_bias``), so it doesn't need a real
|
||||
``bpy.types.Gizmo`` instance — those only exist inside a registered
|
||||
``GizmoGroup`` and aren't constructible in a headless test."""
|
||||
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
from bonsai.bim.module.drawing.gizmos import GizmoDimension
|
||||
|
||||
pytestmark = pytest.mark.drawing
|
||||
|
||||
|
||||
@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_smaller_dimension_wins_select_bias():
|
||||
small = SimpleNamespace()
|
||||
large = SimpleNamespace()
|
||||
GizmoDimension.set_dimension_length(small, 0.077)
|
||||
GizmoDimension.set_dimension_length(large, 0.109)
|
||||
assert small.select_bias > large.select_bias
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"lengths",
|
||||
[
|
||||
[0.0, 0.05, 0.077, 0.109, 1.0, 5.0, 10.0],
|
||||
[0.001, 0.5, 2.5, 100.0, 9999.0],
|
||||
],
|
||||
)
|
||||
def test_select_bias_is_non_increasing_in_length(lengths):
|
||||
"""A monotonic mapping is all Blender's GPU select needs to break the tie."""
|
||||
biases = []
|
||||
for length in lengths:
|
||||
gizmo = SimpleNamespace()
|
||||
GizmoDimension.set_dimension_length(gizmo, length)
|
||||
biases.append(gizmo.select_bias)
|
||||
for prev, curr in zip(biases, biases[1:]):
|
||||
assert prev >= curr, f"select_bias must be non-increasing in length, got {biases}"
|
||||
|
||||
|
||||
def test_negative_length_uses_absolute_value_for_bias():
|
||||
"""Negative dimension values (e.g. inverted angles) clamp to abs() for hit-box scaling;
|
||||
select_bias follows the same clamped magnitude so signed-direction gizmos still
|
||||
obey the smaller-wins rule against their positive-sided peers."""
|
||||
positive = SimpleNamespace()
|
||||
negative = SimpleNamespace()
|
||||
GizmoDimension.set_dimension_length(positive, 0.5)
|
||||
GizmoDimension.set_dimension_length(negative, -0.5)
|
||||
assert positive.select_bias == negative.select_bias
|
||||
|
||||
|
||||
def test_nan_and_inf_length_falls_back_to_zero_bias():
|
||||
"""Invalid inputs are coerced to 0.0 before the bias is written, so a malformed
|
||||
update can't push a gizmo arbitrarily far forward or backward in the select buffer."""
|
||||
import math
|
||||
|
||||
for bad in (math.nan, math.inf, -math.inf, "not a number"):
|
||||
gizmo = SimpleNamespace()
|
||||
GizmoDimension.set_dimension_length(gizmo, bad)
|
||||
assert gizmo.select_bias == 0.0
|
||||
@@ -0,0 +1,54 @@
|
||||
# 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.
|
||||
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
|
||||
|
||||
pytestmark = pytest.mark.drawing
|
||||
|
||||
|
||||
@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_text_formatter_defaults_to_none():
|
||||
config = DimensionGizmoConfig(attr_name="length", axis=(1, 0, 0))
|
||||
assert config.text_formatter is None
|
||||
|
||||
|
||||
def test_text_formatter_field_stores_callable():
|
||||
formatter = lambda props, value: f"{value:.2f}m" # noqa: E731
|
||||
config = DimensionGizmoConfig(attr_name="length", axis=(1, 0, 0), text_formatter=formatter)
|
||||
assert config.text_formatter is not None
|
||||
assert callable(config.text_formatter)
|
||||
|
||||
|
||||
def test_text_formatter_receives_props_and_value():
|
||||
formatter = lambda props, value: f"{props.label}={value}" # noqa: E731
|
||||
config = DimensionGizmoConfig(attr_name="length", axis=(1, 0, 0), text_formatter=formatter)
|
||||
props = SimpleNamespace(label="L")
|
||||
assert config.text_formatter(props, 3.14) == "L=3.14"
|
||||
@@ -0,0 +1,19 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,176 @@
|
||||
# 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 shared decorator cache module.
|
||||
|
||||
The cache token + persistent handler are the only thing protecting cached
|
||||
``bpy.types.Object`` refs in dependent decorators from being dereferenced
|
||||
after the underlying object is freed. These tests pin that contract:
|
||||
|
||||
- The 4-hook invalidation list (depsgraph/undo/redo/load) is symmetrically
|
||||
managed by install/uninstall. A future edit that drops a hook from one
|
||||
side without the other lands as a Blender segfault — the regression must
|
||||
surface as a test failure first.
|
||||
- The handler increments the token and accepts Blender's variadic args."""
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
from bonsai.bim import decorator_cache
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_cache_token():
|
||||
"""Fresh token between tests so the bump-count assertions are stable."""
|
||||
decorator_cache.reset_for_test()
|
||||
yield
|
||||
|
||||
|
||||
def test_install_and_uninstall_manage_all_invalidation_hooks():
|
||||
"""install_decorator_cache_handlers() must register the bump handler in
|
||||
every hook the dependent decorators rely on; uninstall must remove it
|
||||
from every hook install touched. Catches the regression class where
|
||||
a hook is dropped from one side and not the other."""
|
||||
expected_hooks = (
|
||||
bpy.app.handlers.depsgraph_update_post,
|
||||
bpy.app.handlers.undo_post,
|
||||
bpy.app.handlers.redo_post,
|
||||
bpy.app.handlers.load_post,
|
||||
)
|
||||
|
||||
# Defensive cleanup in case a previous addon-init run left the handler
|
||||
# registered — the test must observe a clean slate before install().
|
||||
for hook in expected_hooks:
|
||||
while decorator_cache._bump_decorator_cache_token in hook:
|
||||
hook.remove(decorator_cache._bump_decorator_cache_token)
|
||||
|
||||
try:
|
||||
decorator_cache.install_decorator_cache_handlers()
|
||||
for hook in expected_hooks:
|
||||
assert decorator_cache._bump_decorator_cache_token in hook, (
|
||||
"install_decorator_cache_handlers() must register the bump "
|
||||
"handler in every hook a dependent cache relies on"
|
||||
)
|
||||
decorator_cache.uninstall_decorator_cache_handlers()
|
||||
for hook in expected_hooks:
|
||||
assert decorator_cache._bump_decorator_cache_token not in hook, (
|
||||
"uninstall_decorator_cache_handlers() must remove the bump " "handler from every hook install touched"
|
||||
)
|
||||
finally:
|
||||
# Make sure the test never leaves the handler dangling.
|
||||
for hook in expected_hooks:
|
||||
while decorator_cache._bump_decorator_cache_token in hook:
|
||||
hook.remove(decorator_cache._bump_decorator_cache_token)
|
||||
|
||||
|
||||
def test_install_is_idempotent():
|
||||
"""Calling install twice must not double-register the bump handler —
|
||||
the addon-init path may run on script reload and we don't want to
|
||||
invalidate the cache twice per event."""
|
||||
hook = bpy.app.handlers.depsgraph_update_post
|
||||
|
||||
while decorator_cache._bump_decorator_cache_token in hook:
|
||||
hook.remove(decorator_cache._bump_decorator_cache_token)
|
||||
|
||||
try:
|
||||
decorator_cache.install_decorator_cache_handlers()
|
||||
decorator_cache.install_decorator_cache_handlers()
|
||||
appearances = sum(1 for h in hook if h is decorator_cache._bump_decorator_cache_token)
|
||||
assert appearances == 1, "install must not double-register"
|
||||
finally:
|
||||
decorator_cache.uninstall_decorator_cache_handlers()
|
||||
|
||||
|
||||
def test_bump_handler_increments_token():
|
||||
"""undo / redo / load_post invoke the handler with at most one positional
|
||||
argument (the scene or filepath). Every such call must bump the token —
|
||||
those events legitimately invalidate every cached Object reference."""
|
||||
decorator_cache._bump_decorator_cache_token()
|
||||
assert decorator_cache.get_decorator_cache_token() == 1
|
||||
decorator_cache._bump_decorator_cache_token("scene")
|
||||
assert decorator_cache.get_decorator_cache_token() == 2
|
||||
|
||||
|
||||
def test_get_decorator_cache_token_reads_current_value():
|
||||
"""``get_decorator_cache_token()`` is the public read interface — it must
|
||||
reflect the current token, not a captured-at-import-time value."""
|
||||
initial = decorator_cache.get_decorator_cache_token()
|
||||
decorator_cache._bump_decorator_cache_token()
|
||||
assert decorator_cache.get_decorator_cache_token() == initial + 1
|
||||
|
||||
|
||||
def test_depsgraph_update_with_no_object_changes_does_not_bump():
|
||||
"""depsgraph_update_post fires every animation frame, every driver
|
||||
evaluation, and every UI-only state shift. None of those invalidate a
|
||||
decorator's cached IFC-derived geometry — gating the bump is what makes
|
||||
the ``TokenCache`` worth more than a per-frame recompute."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
initial = decorator_cache.get_decorator_cache_token()
|
||||
depsgraph = MagicMock(spec=bpy.types.Depsgraph, name="depsgraph")
|
||||
depsgraph.updates = [] # empty updates list — animation tick with no real changes
|
||||
decorator_cache._bump_decorator_cache_token("scene", depsgraph)
|
||||
assert (
|
||||
decorator_cache.get_decorator_cache_token() == initial
|
||||
), "depsgraph_update_post with no Object changes must not bump the token"
|
||||
|
||||
|
||||
def test_depsgraph_update_with_object_geometry_change_bumps():
|
||||
"""When the depsgraph reports an Object geometry or transform change,
|
||||
cached references may now point at a renamed / freed ID block. The token
|
||||
must advance so dependent caches re-fetch on the next read."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
initial = decorator_cache.get_decorator_cache_token()
|
||||
update = MagicMock(spec=bpy.types.DepsgraphUpdate, name="update")
|
||||
update.is_updated_geometry = True
|
||||
update.is_updated_transform = False
|
||||
update.id = bpy.data.objects.new("dep_cache_probe", None)
|
||||
try:
|
||||
depsgraph = MagicMock(spec=bpy.types.Depsgraph, name="depsgraph")
|
||||
depsgraph.updates = [update]
|
||||
decorator_cache._bump_decorator_cache_token("scene", depsgraph)
|
||||
assert decorator_cache.get_decorator_cache_token() == initial + 1
|
||||
finally:
|
||||
bpy.data.objects.remove(update.id, do_unlink=True)
|
||||
|
||||
|
||||
def test_depsgraph_update_with_non_object_change_does_not_bump():
|
||||
"""Material / NodeTree / Image updates fire depsgraph_update_post too
|
||||
but never invalidate the decorator's Object-keyed caches. Filter them
|
||||
out so a node-graph edit doesn't trigger a global cache rebuild."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
initial = decorator_cache.get_decorator_cache_token()
|
||||
update = MagicMock(spec=bpy.types.DepsgraphUpdate, name="update")
|
||||
update.is_updated_geometry = True
|
||||
update.is_updated_transform = True
|
||||
update.id = bpy.data.materials.new("dep_cache_probe_mat")
|
||||
try:
|
||||
depsgraph = MagicMock(spec=bpy.types.Depsgraph, name="depsgraph")
|
||||
depsgraph.updates = [update]
|
||||
decorator_cache._bump_decorator_cache_token("scene", depsgraph)
|
||||
assert (
|
||||
decorator_cache.get_decorator_cache_token() == initial
|
||||
), "Non-Object ID updates must not bump the decorator cache token"
|
||||
finally:
|
||||
bpy.data.materials.remove(update.id, do_unlink=True)
|
||||
@@ -0,0 +1,96 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Behaviour contracts for the wall-fillet operator chain.
|
||||
|
||||
Each fillet operator's geometry path requires real Blender + IFC fixtures
|
||||
(walls with IfcMaterialLayerSetUsage, neighbour rels, etc.). End-to-end
|
||||
fillet round-trips belong in the bim feature suite (model.feature) where
|
||||
that scaffolding already exists. This file pins the surface-level invariants
|
||||
that don't depend on the geometry path:
|
||||
|
||||
* the lifecycle operators are registered under their conventional bl_idnames,
|
||||
* the enable poll rejects ineligible selections.
|
||||
|
||||
State-clearing tests via ``bpy.ops.bim.cancel_wall_fillet_preview()`` were
|
||||
removed because the dispatch is flaky in full-suite ordering — the operator
|
||||
early-returns when ``context.screen`` is unattached and prior tests can leave
|
||||
the screen in that state. The behaviour is covered by the user-visible live
|
||||
test loop instead."""
|
||||
|
||||
import types
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _require_real_bpy():
|
||||
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
|
||||
pytest.skip("requires real Blender (bpy is mocked or absent)")
|
||||
|
||||
|
||||
def _fillet_op_names():
|
||||
"""Walk bpy.ops.bim for operators whose name contains ``wall_fillet`` —
|
||||
avoids hard-coding the five lifecycle bl_idnames so adding / renaming
|
||||
one updates discovery automatically. Each name maps to a callable
|
||||
operator."""
|
||||
return sorted(name for name in dir(bpy.ops.bim) if "wall_fillet" in name)
|
||||
|
||||
|
||||
class TestFilletOperatorsRegistered:
|
||||
"""Catches accidental deregistration of any fillet lifecycle operator —
|
||||
drops in the classes tuple of bim/module/model/__init__.py would otherwise
|
||||
leave the gizmo group's target_set_operator binding pointing at a missing
|
||||
op and crash the first time a user clicked the icon."""
|
||||
|
||||
def test_at_least_the_expected_lifecycle_set_is_registered(self):
|
||||
names = _fillet_op_names()
|
||||
# The lifecycle has enable + finish + cancel as a minimum; a healthy
|
||||
# build also includes the from-corner re-edit entry and the create
|
||||
# operator the finish dispatches to. The test asserts at least four —
|
||||
# below that the feature can't function — without enumerating each
|
||||
# by name, so the test stays meaningful if one is renamed or merged.
|
||||
assert len(names) >= 4, (
|
||||
f"Only {len(names)} fillet operators found on bpy.ops.bim: {names}. "
|
||||
"The fillet lifecycle needs enable + finish + cancel + create at "
|
||||
"minimum; check bim/module/model/__init__.py classes tuple."
|
||||
)
|
||||
|
||||
def test_every_discovered_fillet_op_is_callable(self):
|
||||
for name in _fillet_op_names():
|
||||
op = getattr(bpy.ops.bim, name)
|
||||
assert callable(op), f"bpy.ops.bim.{name} is not callable — registration broke?"
|
||||
|
||||
|
||||
class TestEnableRejectsIneligibleSelection:
|
||||
"""The preview enable operator requires a specific 2-wall selection
|
||||
(LAYER2 walls with straight axes). With no selection at all, poll
|
||||
must return False so the operator is greyed-out in menus instead of
|
||||
crashing on dispatch."""
|
||||
|
||||
def test_enable_poll_returns_false_with_no_selection(self):
|
||||
# Deselect everything in the default scene; no IfcWall is present
|
||||
# in a fresh bpy_extras context anyway, so poll() must short-circuit.
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
bpy.context.view_layer.update()
|
||||
assert bpy.ops.bim.enable_wall_fillet_preview.poll() is False
|
||||
@@ -0,0 +1,521 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Tests for tool.Geometry.get_dissolved_edges and the opening-decoration cache
|
||||
layers. The dissolve helper's contract:
|
||||
|
||||
- Read-only on the input mesh.
|
||||
- Returns (verts_local, edge_indices) indexed into the dissolved bmesh.
|
||||
- Material seams survive (delimit=MATERIAL).
|
||||
- Default angle threshold is 1°."""
|
||||
|
||||
from math import radians
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import pytest
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim import decorator_cache
|
||||
from bonsai.bim.module.model import opening as opening_module
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_decoration_caches():
|
||||
# Tests share module-global state (dissolve cache + token, world-draw-data
|
||||
# cache, batch cache, per-object epochs). Reset every layer so a previous
|
||||
# test can't poison hit/miss assertions.
|
||||
decorator_cache.reset_for_test()
|
||||
opening_module._dissolved_edges_cache.clear()
|
||||
opening_module._dissolved_edges_cache_token = -1
|
||||
opening_module._world_draw_data_cache.clear()
|
||||
opening_module._batch_cache.clear()
|
||||
opening_module._object_epochs.clear()
|
||||
yield
|
||||
decorator_cache.reset_for_test()
|
||||
opening_module._dissolved_edges_cache.clear()
|
||||
opening_module._world_draw_data_cache.clear()
|
||||
opening_module._batch_cache.clear()
|
||||
opening_module._object_epochs.clear()
|
||||
|
||||
|
||||
def _make_mesh(name: str, verts: list[tuple[float, float, float]], faces: list[tuple[int, ...]]) -> bpy.types.Mesh:
|
||||
mesh = bpy.data.meshes.new(name)
|
||||
mesh.from_pydata(verts, [], faces)
|
||||
mesh.update()
|
||||
return mesh
|
||||
|
||||
|
||||
def _edge_count(mesh: bpy.types.Mesh) -> int:
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(mesh)
|
||||
n = len(bm.edges)
|
||||
bm.free()
|
||||
return n
|
||||
|
||||
|
||||
def test_collapses_coplanar_diagonal_on_triangulated_quad():
|
||||
# Triangulated unit quad in the XY plane: 4 verts, 2 tris share a diagonal.
|
||||
# Raw bmesh has 5 edges (4 quad sides + 1 diagonal). Dissolve must drop the
|
||||
# diagonal because both triangles are perfectly coplanar.
|
||||
mesh = _make_mesh(
|
||||
"quad_tri",
|
||||
verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)],
|
||||
faces=[(0, 1, 2), (0, 2, 3)],
|
||||
)
|
||||
assert _edge_count(mesh) == 5
|
||||
|
||||
verts, edges = tool.Geometry.get_dissolved_edges(mesh)
|
||||
|
||||
assert len(verts) == 4
|
||||
assert len(edges) == 4
|
||||
# Every returned edge index must point into the returned verts list.
|
||||
for a, b in edges:
|
||||
assert 0 <= a < len(verts)
|
||||
assert 0 <= b < len(verts)
|
||||
assert a != b
|
||||
|
||||
|
||||
def test_preserves_real_edges_on_cube():
|
||||
# Default cube has 8 verts / 12 edges / 6 quad faces. There are no coplanar
|
||||
# internal splits to dissolve, so the helper must return the cube intact.
|
||||
mesh = bpy.data.meshes.new("cube")
|
||||
bm = bmesh.new()
|
||||
bmesh.ops.create_cube(bm, size=1.0)
|
||||
bm.to_mesh(mesh)
|
||||
bm.free()
|
||||
|
||||
verts, edges = tool.Geometry.get_dissolved_edges(mesh)
|
||||
|
||||
assert len(verts) == 8
|
||||
assert len(edges) == 12
|
||||
|
||||
|
||||
def test_preserves_material_seam_on_coplanar_split():
|
||||
# Two coplanar triangles sharing an edge but each with a different
|
||||
# material_index. delimit=MATERIAL must keep the shared edge alive.
|
||||
mesh = _make_mesh(
|
||||
"split_mat",
|
||||
verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)],
|
||||
faces=[(0, 1, 2), (0, 2, 3)],
|
||||
)
|
||||
mat_a = bpy.data.materials.new("mat_a")
|
||||
mat_b = bpy.data.materials.new("mat_b")
|
||||
mesh.materials.append(mat_a)
|
||||
mesh.materials.append(mat_b)
|
||||
mesh.polygons[0].material_index = 0
|
||||
mesh.polygons[1].material_index = 1
|
||||
mesh.update()
|
||||
|
||||
verts, edges = tool.Geometry.get_dissolved_edges(mesh)
|
||||
|
||||
# The 4 perimeter edges plus the shared diagonal: 5 total survive.
|
||||
assert len(verts) == 4
|
||||
assert len(edges) == 5
|
||||
|
||||
bpy.data.materials.remove(mat_a)
|
||||
bpy.data.materials.remove(mat_b)
|
||||
|
||||
|
||||
def test_does_not_mutate_input_mesh():
|
||||
# The helper must be read-only: viewport draw handlers call it every frame
|
||||
# and any obj.data mutation would race the depsgraph and trigger redraws.
|
||||
mesh = _make_mesh(
|
||||
"ro_quad",
|
||||
verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)],
|
||||
faces=[(0, 1, 2), (0, 2, 3)],
|
||||
)
|
||||
edges_before = _edge_count(mesh)
|
||||
verts_before = len(mesh.vertices)
|
||||
|
||||
tool.Geometry.get_dissolved_edges(mesh)
|
||||
|
||||
assert _edge_count(mesh) == edges_before
|
||||
assert len(mesh.vertices) == verts_before
|
||||
|
||||
|
||||
def test_accepts_explicit_angle_limit():
|
||||
# Smoke: the angle_limit kwarg must be honored end-to-end (not silently
|
||||
# ignored). With a near-zero threshold, even sub-degree coplanar splits
|
||||
# survive; with a generous threshold, they collapse.
|
||||
mesh = _make_mesh(
|
||||
"quad_tri",
|
||||
verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)],
|
||||
faces=[(0, 1, 2), (0, 2, 3)],
|
||||
)
|
||||
|
||||
_, edges_zero = tool.Geometry.get_dissolved_edges(mesh, angle_limit=0.0)
|
||||
_, edges_default = tool.Geometry.get_dissolved_edges(mesh)
|
||||
|
||||
assert len(edges_zero) > len(edges_default), "angle_limit=0 must preserve more edges than the default 1° dissolve"
|
||||
|
||||
|
||||
def test_cache_serves_identical_object_on_repeat_call():
|
||||
# Without caching, the helper rebuilds verts/edges every viewport redraw.
|
||||
# Identity (`is`) — not equality — proves the second call hit the cache
|
||||
# rather than recomputing identical content.
|
||||
mesh = _make_mesh(
|
||||
"cached",
|
||||
verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)],
|
||||
faces=[(0, 1, 2), (0, 2, 3)],
|
||||
)
|
||||
first = opening_module._get_cached_dissolved_edges(mesh)
|
||||
second = opening_module._get_cached_dissolved_edges(mesh)
|
||||
|
||||
assert first is second
|
||||
|
||||
|
||||
def test_cache_invalidates_on_decorator_token_bump():
|
||||
# depsgraph_update_post / undo / redo / load all bump the shared decorator
|
||||
# token; this cache must clear when the token changes so a downstream
|
||||
# depsgraph edit (mesh content changed) is reflected on the next call.
|
||||
mesh = _make_mesh(
|
||||
"bumped",
|
||||
verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)],
|
||||
faces=[(0, 1, 2), (0, 2, 3)],
|
||||
)
|
||||
first = opening_module._get_cached_dissolved_edges(mesh)
|
||||
decorator_cache._DECORATOR_CACHE_TOKEN += 1
|
||||
second = opening_module._get_cached_dissolved_edges(mesh)
|
||||
|
||||
assert first is not second, "token bump must invalidate the cache entry"
|
||||
assert len(first[0]) == len(second[0])
|
||||
assert len(first[1]) == len(second[1])
|
||||
|
||||
|
||||
def test_cache_partitions_entries_by_mesh_identity():
|
||||
# Two distinct meshes share the same epoch; both must coexist in the cache
|
||||
# so multi-opening frames don't thrash.
|
||||
mesh_a = _make_mesh(
|
||||
"a",
|
||||
verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)],
|
||||
faces=[(0, 1, 2), (0, 2, 3)],
|
||||
)
|
||||
mesh_b = _make_mesh(
|
||||
"b",
|
||||
verts=[(0, 0, 0), (2, 0, 0), (2, 2, 0), (0, 2, 0)],
|
||||
faces=[(0, 1, 2), (0, 2, 3)],
|
||||
)
|
||||
|
||||
a_first = opening_module._get_cached_dissolved_edges(mesh_a)
|
||||
b_first = opening_module._get_cached_dissolved_edges(mesh_b)
|
||||
a_second = opening_module._get_cached_dissolved_edges(mesh_a)
|
||||
|
||||
assert a_first is a_second, "mesh_a entry must survive an interleaved mesh_b call"
|
||||
assert a_first is not b_first
|
||||
|
||||
|
||||
def test_cache_partitions_entries_by_angle_limit():
|
||||
# Same mesh, different angle_limit → different cached results. Hardens
|
||||
# against a future caller introducing a per-opening threshold override.
|
||||
mesh = _make_mesh(
|
||||
"partitioned",
|
||||
verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)],
|
||||
faces=[(0, 1, 2), (0, 2, 3)],
|
||||
)
|
||||
tight = opening_module._get_cached_dissolved_edges(mesh, angle_limit=0.0)
|
||||
loose = opening_module._get_cached_dissolved_edges(mesh, angle_limit=radians(1.0))
|
||||
tight_again = opening_module._get_cached_dissolved_edges(mesh, angle_limit=0.0)
|
||||
|
||||
assert tight is tight_again
|
||||
assert tight is not loose
|
||||
|
||||
|
||||
# --- world-data cache (_get_cached_world_draw_data) ---------------------------
|
||||
|
||||
|
||||
def _make_object(name: str, mesh: bpy.types.Mesh) -> bpy.types.Object:
|
||||
obj = bpy.data.objects.new(name, mesh)
|
||||
bpy.context.scene.collection.objects.link(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def _make_triangulated_quad_obj(name: str) -> bpy.types.Object:
|
||||
mesh = _make_mesh(
|
||||
name,
|
||||
verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)],
|
||||
faces=[(0, 1, 2), (0, 2, 3)],
|
||||
)
|
||||
return _make_object(name, mesh)
|
||||
|
||||
|
||||
def test_world_data_cache_returns_four_tuple_with_expected_shapes():
|
||||
obj = _make_triangulated_quad_obj("shape")
|
||||
line_verts, verts, edges_indices, tris = opening_module._get_cached_world_draw_data(obj)
|
||||
|
||||
assert len(verts) == 4 # full mesh vert count
|
||||
assert len(line_verts) == 4 # dissolved (diagonal collapsed → 4 surviving verts)
|
||||
assert len(edges_indices) == 4 # quad outline, no diagonal
|
||||
assert len(tris) == 2 # two triangles
|
||||
assert all(len(t) == 3 for t in tris)
|
||||
|
||||
|
||||
def test_world_data_cache_hit_returns_identical_tuple_on_repeat_call():
|
||||
obj = _make_triangulated_quad_obj("hit")
|
||||
first = opening_module._get_cached_world_draw_data(obj)
|
||||
second = opening_module._get_cached_world_draw_data(obj)
|
||||
|
||||
assert first is second
|
||||
|
||||
|
||||
def test_world_data_cache_invalidates_on_object_epoch_bump():
|
||||
# depsgraph_update_post bumps per-object epochs (one per Object whose
|
||||
# transform or geometry changed). After bumping this object's epoch the
|
||||
# next lookup must miss and recompute.
|
||||
obj = _make_triangulated_quad_obj("bumped")
|
||||
first = opening_module._get_cached_world_draw_data(obj)
|
||||
opening_module._object_epochs[obj.session_uid] = opening_module._object_epochs.get(obj.session_uid, 0) + 1
|
||||
second = opening_module._get_cached_world_draw_data(obj)
|
||||
|
||||
assert first is not second
|
||||
|
||||
|
||||
def test_world_data_cache_partitions_entries_by_object_identity():
|
||||
a = _make_triangulated_quad_obj("a")
|
||||
b = _make_triangulated_quad_obj("b")
|
||||
|
||||
a_first = opening_module._get_cached_world_draw_data(a)
|
||||
b_first = opening_module._get_cached_world_draw_data(b)
|
||||
a_second = opening_module._get_cached_world_draw_data(a)
|
||||
|
||||
assert a_first is a_second
|
||||
assert a_first is not b_first
|
||||
|
||||
|
||||
def test_world_data_cache_reflects_new_matrix_after_epoch_bump():
|
||||
# The cache stores world-space verts. A transform without an epoch bump
|
||||
# would serve stale coordinates — but transform updates bump the object's
|
||||
# epoch via the depsgraph handler, so after bump + recompute the new
|
||||
# matrix must be reflected.
|
||||
obj = _make_triangulated_quad_obj("moved")
|
||||
before = opening_module._get_cached_world_draw_data(obj)
|
||||
obj.matrix_world = obj.matrix_world @ Matrix.Translation((5.0, 0.0, 0.0))
|
||||
opening_module._object_epochs[obj.session_uid] = opening_module._object_epochs.get(obj.session_uid, 0) + 1
|
||||
after = opening_module._get_cached_world_draw_data(obj)
|
||||
|
||||
# Each vert in `after` is 5 units shifted on X relative to `before`.
|
||||
for a_co, b_co in zip(after[1], before[1]):
|
||||
assert a_co[0] - b_co[0] == pytest.approx(5.0)
|
||||
assert a_co[1] == pytest.approx(b_co[1])
|
||||
assert a_co[2] == pytest.approx(b_co[2])
|
||||
|
||||
|
||||
def test_world_data_cache_ios_edges_path_returns_curated_edges():
|
||||
# When the mesh has an ios_edges attribute, line_verts must equal the full
|
||||
# verts (no dissolve), and edges_indices must include only entries where
|
||||
# the attribute is True.
|
||||
obj = _make_triangulated_quad_obj("curated")
|
||||
attr = obj.data.attributes.new(name="ios_edges", type="BOOLEAN", domain="EDGE")
|
||||
# 5 edges total (quad + diagonal). Mark only the 4 quad sides as real.
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(obj.data)
|
||||
real_edges_count = 0
|
||||
for i, edge in enumerate(bm.edges):
|
||||
is_diagonal = (
|
||||
abs(edge.verts[0].co[0] - edge.verts[1].co[0]) > 0 and abs(edge.verts[0].co[1] - edge.verts[1].co[1]) > 0
|
||||
)
|
||||
attr.data[i].value = not is_diagonal
|
||||
if not is_diagonal:
|
||||
real_edges_count += 1
|
||||
bm.free()
|
||||
obj.data.update()
|
||||
|
||||
line_verts, verts, edges_indices, _ = opening_module._get_cached_world_draw_data(obj)
|
||||
|
||||
assert line_verts is verts, "ios_edges path must reuse the full-verts list as line_verts"
|
||||
assert len(edges_indices) == real_edges_count
|
||||
|
||||
|
||||
def test_world_data_cache_dissolve_path_drops_diagonal():
|
||||
# Without ios_edges, the cache falls through to dissolve. The 5th edge
|
||||
# (diagonal) must be gone from edges_indices.
|
||||
obj = _make_triangulated_quad_obj("dissolved")
|
||||
line_verts, verts, edges_indices, _ = opening_module._get_cached_world_draw_data(obj)
|
||||
|
||||
assert len(edges_indices) == 4
|
||||
assert len(line_verts) == 4
|
||||
assert len(verts) == 4
|
||||
|
||||
|
||||
# --- batch cache (_get_cached_batch_or_none / _store_batch_in_cache) ---------
|
||||
|
||||
|
||||
def test_batch_cache_returns_none_on_cold_lookup():
|
||||
assert opening_module._get_cached_batch_or_none((123, "lines")) is None
|
||||
|
||||
|
||||
def test_batch_cache_returns_stored_batch_on_hit():
|
||||
# Sentinel stands in for a GPUBatch — the cache treats it opaquely, so
|
||||
# this test pins lookup/store correctness without needing a real shader.
|
||||
sentinel = object()
|
||||
opening_module._store_batch_in_cache((42, "lines"), sentinel)
|
||||
|
||||
assert opening_module._get_cached_batch_or_none((42, "lines")) is sentinel
|
||||
|
||||
|
||||
def test_batch_cache_invalidates_on_object_epoch_bump():
|
||||
sentinel = object()
|
||||
opening_module._store_batch_in_cache((42, "lines"), sentinel)
|
||||
opening_module._object_epochs[42] = opening_module._object_epochs.get(42, 0) + 1
|
||||
|
||||
assert opening_module._get_cached_batch_or_none((42, "lines")) is None
|
||||
|
||||
|
||||
def test_batch_cache_partitions_entries_by_kind():
|
||||
# Same object, different batch kinds (LINES vs TRIS vs arrow) coexist —
|
||||
# required so the same opening's three batches don't evict each other.
|
||||
lines_batch = object()
|
||||
tris_batch = object()
|
||||
opening_module._store_batch_in_cache((42, "lines"), lines_batch)
|
||||
opening_module._store_batch_in_cache((42, "tris"), tris_batch)
|
||||
|
||||
assert opening_module._get_cached_batch_or_none((42, "lines")) is lines_batch
|
||||
assert opening_module._get_cached_batch_or_none((42, "tris")) is tris_batch
|
||||
|
||||
|
||||
def test_batch_cache_partitions_entries_by_object_uid():
|
||||
a_batch = object()
|
||||
b_batch = object()
|
||||
opening_module._store_batch_in_cache((1, "lines"), a_batch)
|
||||
opening_module._store_batch_in_cache((2, "lines"), b_batch)
|
||||
|
||||
assert opening_module._get_cached_batch_or_none((1, "lines")) is a_batch
|
||||
assert opening_module._get_cached_batch_or_none((2, "lines")) is b_batch
|
||||
|
||||
|
||||
# --- per-object epoch invalidation (granularity contract) --------------------
|
||||
|
||||
|
||||
def test_world_data_cache_per_object_epoch_invalidates_only_target():
|
||||
# Core contract for the granular-invalidation feature: bumping one object's
|
||||
# epoch must not evict another object's cached payload. This is what makes
|
||||
# dragging a single object in a 50-opening scene affordable.
|
||||
a = _make_triangulated_quad_obj("granular_a")
|
||||
b = _make_triangulated_quad_obj("granular_b")
|
||||
|
||||
a_first = opening_module._get_cached_world_draw_data(a)
|
||||
b_first = opening_module._get_cached_world_draw_data(b)
|
||||
|
||||
opening_module._object_epochs[a.session_uid] = opening_module._object_epochs.get(a.session_uid, 0) + 1
|
||||
|
||||
a_second = opening_module._get_cached_world_draw_data(a)
|
||||
b_second = opening_module._get_cached_world_draw_data(b)
|
||||
|
||||
assert a_first is not a_second, "a's epoch bump must invalidate a's entry"
|
||||
assert b_first is b_second, "a's epoch bump must NOT touch b's entry"
|
||||
|
||||
|
||||
def test_batch_cache_per_object_epoch_invalidates_only_target():
|
||||
a_lines = object()
|
||||
b_lines = object()
|
||||
opening_module._store_batch_in_cache((1, "lines"), a_lines)
|
||||
opening_module._store_batch_in_cache((2, "lines"), b_lines)
|
||||
|
||||
opening_module._object_epochs[1] = opening_module._object_epochs.get(1, 0) + 1
|
||||
|
||||
assert opening_module._get_cached_batch_or_none((1, "lines")) is None
|
||||
assert opening_module._get_cached_batch_or_none((2, "lines")) is b_lines
|
||||
|
||||
|
||||
def test_global_clear_handler_wipes_everything():
|
||||
# undo/redo/load can't be modeled as per-object deltas — the global handler
|
||||
# must wipe every layer (epochs + both caches) so we can never serve state
|
||||
# that pre-dates the undo/load.
|
||||
a = _make_triangulated_quad_obj("wipe_a")
|
||||
opening_module._get_cached_world_draw_data(a)
|
||||
opening_module._store_batch_in_cache((a.session_uid, "lines"), object())
|
||||
assert a.session_uid in opening_module._world_draw_data_cache
|
||||
assert (a.session_uid, "lines") in opening_module._batch_cache
|
||||
|
||||
opening_module._clear_decoration_caches_globally()
|
||||
|
||||
assert opening_module._world_draw_data_cache == {}
|
||||
assert opening_module._batch_cache == {}
|
||||
assert opening_module._object_epochs == {}
|
||||
|
||||
|
||||
class _FakeDepsgraphUpdate:
|
||||
def __init__(self, id_, transform: bool = False, geometry: bool = False):
|
||||
self.id = id_
|
||||
self.is_updated_transform = transform
|
||||
self.is_updated_geometry = geometry
|
||||
|
||||
|
||||
class _FakeDepsgraph:
|
||||
def __init__(self, updates):
|
||||
self.updates = updates
|
||||
|
||||
|
||||
def test_depsgraph_handler_bumps_epoch_for_updated_object():
|
||||
# Synthesised depsgraph delta: one Object with a transform update. The
|
||||
# handler must increment that object's epoch.
|
||||
obj = _make_triangulated_quad_obj("bumped_via_handler")
|
||||
before = opening_module._object_epochs.get(obj.session_uid, 0)
|
||||
|
||||
deps = _FakeDepsgraph([_FakeDepsgraphUpdate(obj, transform=True)])
|
||||
opening_module._bump_object_epochs_for_decoration(None, deps)
|
||||
|
||||
assert opening_module._object_epochs[obj.session_uid] == before + 1
|
||||
|
||||
|
||||
def test_depsgraph_handler_ignores_non_object_updates():
|
||||
# Updates whose .id isn't a bpy.types.Object (Mesh, Material, NodeTree…)
|
||||
# must not affect any object's epoch.
|
||||
obj = _make_triangulated_quad_obj("untouched")
|
||||
deps = _FakeDepsgraph([_FakeDepsgraphUpdate(obj.data, geometry=True)])
|
||||
opening_module._bump_object_epochs_for_decoration(None, deps)
|
||||
|
||||
assert obj.session_uid not in opening_module._object_epochs
|
||||
|
||||
|
||||
def test_depsgraph_handler_ignores_updates_without_transform_or_geometry():
|
||||
# An Object update flagged only for shading must not bump the epoch —
|
||||
# shading changes don't move the wire overlay.
|
||||
obj = _make_triangulated_quad_obj("shading_only")
|
||||
deps = _FakeDepsgraph([_FakeDepsgraphUpdate(obj)])
|
||||
opening_module._bump_object_epochs_for_decoration(None, deps)
|
||||
|
||||
assert obj.session_uid not in opening_module._object_epochs
|
||||
|
||||
|
||||
def test_depsgraph_handler_resolves_cow_original():
|
||||
# For non-evaluated Blender objects, obj.original returns obj itself, so
|
||||
# the .original-resolution path keys the SAME uid the draw handler reads.
|
||||
# Pinning this prevents a future refactor that drops the .original lookup
|
||||
# from silently regressing the COW-boundary case (the decorator failing to
|
||||
# follow a moved object).
|
||||
obj = _make_triangulated_quad_obj("cow")
|
||||
deps = _FakeDepsgraph([_FakeDepsgraphUpdate(obj, transform=True)])
|
||||
opening_module._bump_object_epochs_for_decoration(None, deps)
|
||||
|
||||
assert obj.original.session_uid in opening_module._object_epochs
|
||||
|
||||
|
||||
def test_depsgraph_handler_tolerates_missing_depsgraph():
|
||||
# Some Blender event paths may call the handler without a depsgraph; the
|
||||
# handler must short-circuit instead of raising AttributeError.
|
||||
opening_module._bump_object_epochs_for_decoration()
|
||||
opening_module._bump_object_epochs_for_decoration(None)
|
||||
opening_module._bump_object_epochs_for_decoration(None, None)
|
||||
|
||||
assert opening_module._object_epochs == {}
|
||||
@@ -0,0 +1,178 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Tests for the parametric-edit preview registry contract.
|
||||
|
||||
Every test reads the live ``PREVIEW_CANCEL_OPS`` registry rather than hard-
|
||||
coding preview keys or cancel-operator names, so adding a new preview to the
|
||||
registry automatically exercises the same invariants without test changes."""
|
||||
|
||||
import types
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _require_real_bpy():
|
||||
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
|
||||
pytest.skip("requires real Blender (bpy is mocked or absent)")
|
||||
|
||||
|
||||
def _registry():
|
||||
from bonsai.bim.module.model.preview_base import PREVIEW_CANCEL_OPS
|
||||
|
||||
return PREVIEW_CANCEL_OPS
|
||||
|
||||
|
||||
def _preview_umbrella():
|
||||
return getattr(bpy.context.scene, "BIMPreviewProperties", None)
|
||||
|
||||
|
||||
def _registered_previews():
|
||||
"""``[(attr, op_name, props)]`` for every registry entry that has a real
|
||||
child PropertyGroup on the umbrella in the current addon build."""
|
||||
umbrella = _preview_umbrella()
|
||||
if umbrella is None:
|
||||
return []
|
||||
out = []
|
||||
for attr, op_name in _registry():
|
||||
props = getattr(umbrella, attr, None)
|
||||
if props is not None:
|
||||
out.append((attr, op_name, props))
|
||||
return out
|
||||
|
||||
|
||||
class TestRegistryContract:
|
||||
"""Pins the invariant that every entry in PREVIEW_CANCEL_OPS resolves to
|
||||
a real cancel operator the addon registers. A new preview added to the
|
||||
registry without its matching cancel operator would otherwise crash
|
||||
``try_cancel_active_preview`` on the first Esc."""
|
||||
|
||||
def test_every_registered_cancel_op_is_callable(self):
|
||||
for attr, op_name in _registry():
|
||||
op = getattr(bpy.ops.bim, op_name, None)
|
||||
assert op is not None and callable(op), (
|
||||
f"Preview '{attr}' in PREVIEW_CANCEL_OPS points to bim.{op_name} "
|
||||
f"but no such operator is registered."
|
||||
)
|
||||
|
||||
|
||||
class TestGetPreviewPropsTolerance:
|
||||
"""The bug-class fixed in commit ee63137c6: ``get_preview_props`` is called
|
||||
from gizmo polls during addon init and from test mocks built on
|
||||
``SimpleNamespace`` — neither has a fully-formed Blender context. The
|
||||
helper must return None rather than raise."""
|
||||
|
||||
def test_returns_none_when_context_has_no_scene(self):
|
||||
from bonsai.bim.module.model.preview_base import get_preview_props
|
||||
|
||||
# Pass an arbitrary attr name — the contract is the same for every
|
||||
# preview key, so picking one literally would be a maintenance trap.
|
||||
for attr, _ in _registry():
|
||||
assert get_preview_props(types.SimpleNamespace(), attr) is None
|
||||
break
|
||||
|
||||
def test_returns_none_when_scene_lacks_umbrella(self):
|
||||
from bonsai.bim.module.model.preview_base import get_preview_props
|
||||
|
||||
ctx = types.SimpleNamespace(scene=types.SimpleNamespace())
|
||||
for attr, _ in _registry():
|
||||
assert get_preview_props(ctx, attr) is None
|
||||
break
|
||||
|
||||
|
||||
class TestActivationCycle:
|
||||
"""End-to-end contract on the real addon: each registered preview can be
|
||||
activated and then cancelled to inactive. Runs for every preview that
|
||||
has a wired PropertyGroup, so a new preview added to the registry +
|
||||
umbrella is covered without test edits."""
|
||||
|
||||
def test_any_preview_active_reflects_each_preview_state(self):
|
||||
from bonsai.bim.module.model.preview_base import any_preview_active
|
||||
|
||||
registered = _registered_previews()
|
||||
if not registered:
|
||||
pytest.skip("No previews wired in this build — registry-only entries")
|
||||
|
||||
# All inactive baseline.
|
||||
for _, _, props in registered:
|
||||
props.is_active = False
|
||||
assert any_preview_active(bpy.context) is False
|
||||
|
||||
# Flip each one independently — the helper must report True.
|
||||
for _, _, props in registered:
|
||||
props.is_active = True
|
||||
assert any_preview_active(bpy.context) is True
|
||||
props.is_active = False
|
||||
|
||||
def test_discard_pending_previews_clears_every_active_flag(self):
|
||||
from bonsai.bim.module.model.preview_base import discard_pending_previews
|
||||
|
||||
registered = _registered_previews()
|
||||
if not registered:
|
||||
pytest.skip("No previews wired in this build — registry-only entries")
|
||||
|
||||
for _, _, props in registered:
|
||||
props.is_active = True
|
||||
discard_pending_previews(bpy.context.scene)
|
||||
for attr, _, props in registered:
|
||||
assert props.is_active is False, f"discard_pending_previews left '{attr}' active"
|
||||
|
||||
|
||||
class TestSaveOnDiscardWired:
|
||||
"""Pins that the SaveProject operator clears preview state before writing
|
||||
the IFC file — a stuck is_active flag persisted through the save would
|
||||
silently hide sister gizmos on the next file load.
|
||||
|
||||
Structural check: the SaveProject operator class must reference the
|
||||
discard helper somewhere in its execute path. Behavioural integration
|
||||
(actually saving a .blend with an active preview and reloading) belongs
|
||||
in the bim feature suite; this is the small guard against accidental
|
||||
removal of the call site."""
|
||||
|
||||
def test_save_project_dispatches_discard_pending_previews(self):
|
||||
import inspect
|
||||
|
||||
from bonsai.bim.module.model import preview_base
|
||||
from bonsai.bim.module.project import operator as project_operator
|
||||
|
||||
# Find the project save operator dynamically — looking for any
|
||||
# Operator class whose bl_idname is "bim.save_project". Avoids
|
||||
# hard-coding the class identifier.
|
||||
save_op = None
|
||||
for name in dir(project_operator):
|
||||
obj = getattr(project_operator, name)
|
||||
if isinstance(obj, type) and getattr(obj, "bl_idname", None) == "bim.save_project":
|
||||
save_op = obj
|
||||
break
|
||||
assert save_op is not None, "Expected an operator with bl_idname='bim.save_project' in project/operator.py"
|
||||
|
||||
# Walk the class's methods for the discard call. Avoids pinning a
|
||||
# specific method name (_execute vs execute vs an inner helper) so
|
||||
# the test survives operator refactors.
|
||||
source = inspect.getsource(save_op)
|
||||
assert preview_base.discard_pending_previews.__name__ in source, (
|
||||
f"{save_op.__name__} does not reference discard_pending_previews. "
|
||||
"Saving with a preview open would persist its is_active flag to the "
|
||||
".blend file and silently hide sister gizmos on reopen."
|
||||
)
|
||||
@@ -0,0 +1,135 @@
|
||||
# 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.
|
||||
|
||||
"""Regression guard for the stair icon billboard fix.
|
||||
|
||||
Before the fix, ``set_icon_gizmo_position`` in ``bim.module.drawing.gizmos``
|
||||
composed ``mw @ (Translation @ billboard_rot @ Scale)``, which applied the
|
||||
stair's world rotation on top of the billboard rotation. The result was
|
||||
icons (validate / cancel / lock / +/- / cycle / tread_lock) drawn edge-on
|
||||
to the camera for any stair rotated in plan — effectively unclickable.
|
||||
|
||||
The fix routes through ``billboarded_at(world_pos, billboard_rot, scale)``,
|
||||
which computes ``Translation(world_pos) @ billboard_rot @ Scale`` — the
|
||||
object's rotation is folded into the translation only, never the rotation."""
|
||||
|
||||
import math
|
||||
import types
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _require_real_bpy():
|
||||
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
|
||||
pytest.skip("requires real Blender (bpy is mocked or absent)")
|
||||
|
||||
|
||||
def _rotation_close(a, b, tol: float = 1e-6) -> bool:
|
||||
for row_a, row_b in zip(a, b):
|
||||
for va, vb in zip(row_a, row_b):
|
||||
if abs(va - vb) > tol:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("angle_deg", [0, 30, 45, 90, 135, 217])
|
||||
def test_billboarded_at_rotation_is_pure_billboard(angle_deg):
|
||||
"""Object rotation must not leak into the gizmo's rotation part."""
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
from bonsai.bim.module.drawing.gizmos import billboarded_at
|
||||
|
||||
mw = Matrix.Rotation(math.radians(angle_deg), 4, "Z") @ Matrix.Translation((3, 4, 5))
|
||||
billboard_rot = Matrix.Rotation(math.radians(30), 4, "X")
|
||||
|
||||
world_pos = mw @ Vector((1, 0, 2))
|
||||
result = billboarded_at(world_pos, billboard_rot, scale=0.5)
|
||||
|
||||
# The rotation part of result, after stripping the 0.5 uniform scale,
|
||||
# must equal billboard_rot — no contribution from mw's rotation.
|
||||
rotation_part = result.to_3x3() * 2.0
|
||||
assert _rotation_close(rotation_part.to_4x4(), billboard_rot)
|
||||
|
||||
|
||||
def test_billboarded_at_translation_is_world_pos():
|
||||
"""Translation lands exactly at the world-space target."""
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
from bonsai.bim.module.drawing.gizmos import billboarded_at
|
||||
|
||||
world_pos = Vector((1.23, 4.56, 7.89))
|
||||
result = billboarded_at(world_pos, Matrix.Identity(4), scale=0.5)
|
||||
assert (result.translation - world_pos).length < 1e-6
|
||||
|
||||
|
||||
def test_set_icon_gizmo_position_does_not_apply_object_rotation():
|
||||
"""End-to-end: the helper used by every stair icon (and shared with all
|
||||
parametric gizmo groups) must produce a matrix whose rotation part is
|
||||
billboard_rot, not mw_rotation @ billboard_rot. This is the exact bug
|
||||
that left stair icons edge-on to the camera."""
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
from bonsai.bim.module.drawing.gizmos import (
|
||||
BaseParametricGizmoGroup,
|
||||
billboarded_at,
|
||||
)
|
||||
|
||||
# Same inputs as the real call site (stair.py:747-765), but we drive the
|
||||
# helper directly so we don't need a registered GizmoGroup. We bind a
|
||||
# stand-in `get_gizmo_if_visible` that returns a tiny mock; the helper's
|
||||
# observable output is the matrix_basis it assigns.
|
||||
captured = {}
|
||||
|
||||
class _GizmoStub:
|
||||
matrix_basis = Matrix.Identity(4)
|
||||
|
||||
stub = _GizmoStub()
|
||||
|
||||
def _fake_get(name):
|
||||
captured["name"] = name
|
||||
return stub
|
||||
|
||||
# Bind the helper to a throwaway instance so `self.get_gizmo_if_visible`
|
||||
# resolves to our stub without registering a real GizmoGroup with Blender.
|
||||
fake_self = types.SimpleNamespace(get_gizmo_if_visible=_fake_get)
|
||||
mw = Matrix.Rotation(math.radians(45), 4, "Z") @ Matrix.Translation((3, 4, 5))
|
||||
billboard_rot = Matrix.Rotation(math.radians(30), 4, "X")
|
||||
local_pos = Vector((1, 0, 2))
|
||||
BaseParametricGizmoGroup.set_icon_gizmo_position(
|
||||
fake_self,
|
||||
"validate_gizmo",
|
||||
mw=mw,
|
||||
x=local_pos.x,
|
||||
y=local_pos.y,
|
||||
z=local_pos.z,
|
||||
billboard_rot=billboard_rot,
|
||||
scale=0.5,
|
||||
)
|
||||
|
||||
expected = billboarded_at(mw @ local_pos, billboard_rot, 0.5)
|
||||
|
||||
assert captured["name"] == "validate_gizmo"
|
||||
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
|
||||
@@ -0,0 +1,106 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Tests for ``parametric_lifecycle.resync_parametric_drafts_after_undo``.
|
||||
|
||||
Blender's undo restores PropertyGroup field values but does not refire
|
||||
their ``update`` callbacks, so the preview mesh of an in-progress
|
||||
parametric draft desyncs from the gizmo dimension widget after Ctrl+Z.
|
||||
The resync helper walks active drafts and re-runs the per-type
|
||||
regenerator to bring preview back in line with the (restored) draft
|
||||
state. This file pins the dispatch contract."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim import parametric_lifecycle
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
def test_undo_regenerators_target_registered_parametric_types():
|
||||
"""Every entry in ``UNDO_REGENERATORS`` must name a real parametric
|
||||
type. A typo would silently no-op on Ctrl+Z, restoring the desync
|
||||
this helper is meant to prevent."""
|
||||
registered_names = {f.name for f in tool.Parametric.EDIT_TYPES}
|
||||
unknown = set(parametric_lifecycle.UNDO_REGENERATORS) - registered_names
|
||||
assert not unknown, f"UNDO_REGENERATORS keys {unknown} are not in tool.Parametric.EDIT_TYPES"
|
||||
|
||||
|
||||
def test_resync_skips_objects_not_in_parametric_edit():
|
||||
"""Objects with no active parametric edit must not trigger any
|
||||
regenerator — the helper is called from undo_post which fires on
|
||||
every undo, including undos that touch zero parametric drafts."""
|
||||
captured = []
|
||||
|
||||
def fake_dispatch(obj):
|
||||
captured.append(obj)
|
||||
|
||||
with patch.dict(parametric_lifecycle.UNDO_REGENERATORS, {"wall": fake_dispatch}, clear=False), patch.object(
|
||||
tool.Parametric, "is_object_editing", return_value=None
|
||||
):
|
||||
parametric_lifecycle.resync_parametric_drafts_after_undo()
|
||||
|
||||
assert captured == []
|
||||
|
||||
|
||||
def test_resync_dispatches_to_registered_regenerator_for_editing_object():
|
||||
"""When an object is in parametric edit and its type has a registered
|
||||
regenerator, the regenerator must run with that object as the sole
|
||||
arg. This is the load-bearing branch: preview mesh re-renders from
|
||||
current props, so the gizmo and preview re-sync."""
|
||||
captured = []
|
||||
|
||||
def fake_wall_regenerator(obj):
|
||||
captured.append(obj)
|
||||
|
||||
fake_feature = MagicMock(spec=tool.parametric.ParametricObject)
|
||||
fake_feature.name = "wall"
|
||||
|
||||
obj = bpy.data.objects.new("test_wall_obj", bpy.data.meshes.new("test_wall_mesh"))
|
||||
try:
|
||||
with patch.dict(
|
||||
parametric_lifecycle.UNDO_REGENERATORS, {"wall": fake_wall_regenerator}, clear=False
|
||||
), patch.object(tool.Parametric, "is_object_editing", side_effect=lambda o: fake_feature if o is obj else None):
|
||||
parametric_lifecycle.resync_parametric_drafts_after_undo()
|
||||
finally:
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
|
||||
assert captured == [obj]
|
||||
|
||||
|
||||
def test_resync_skips_editing_object_whose_type_has_no_regenerator():
|
||||
"""A parametric type without an ``UNDO_REGENERATORS`` entry (door /
|
||||
window / array — IFC-derived preview, no desync) must not raise; the
|
||||
helper silently skips it."""
|
||||
fake_feature = MagicMock(spec=tool.parametric.ParametricObject)
|
||||
fake_feature.name = "door" # door has no entry in UNDO_REGENERATORS
|
||||
|
||||
obj = bpy.data.objects.new("test_door_obj", bpy.data.meshes.new("test_door_mesh"))
|
||||
try:
|
||||
with patch.object(
|
||||
tool.Parametric, "is_object_editing", side_effect=lambda o: fake_feature if o is obj else None
|
||||
):
|
||||
parametric_lifecycle.resync_parametric_drafts_after_undo()
|
||||
finally:
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
@@ -0,0 +1,154 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Behaviour contract: every wall gizmo group hides while a parametric-edit
|
||||
preview is active.
|
||||
|
||||
Enumerates wall gizmo groups by walking the wall module for ``bpy.types.GizmoGroup``
|
||||
subclasses rather than naming them — adding a new wall gizmo group automatically
|
||||
joins the test. The test then asserts the BEHAVIOUR (poll returns False when
|
||||
``preview_base.any_preview_active`` is True) without pinning the name of the
|
||||
helper function the gizmo uses internally to enforce it."""
|
||||
|
||||
import inspect
|
||||
import types
|
||||
from unittest.mock import patch
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _require_real_bpy():
|
||||
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
|
||||
pytest.skip("requires real Blender (bpy is mocked or absent)")
|
||||
|
||||
|
||||
def _wall_gizmo_groups():
|
||||
"""Walk the wall module for ``bpy.types.GizmoGroup`` subclasses defined
|
||||
locally (skip imported references). Returns a list of (name, cls) tuples.
|
||||
|
||||
A gizmo group whose ``poll`` legitimately needs to fire WHILE a preview
|
||||
is active — i.e. it IS the preview's own gizmo group — is excluded by
|
||||
convention: classes whose bl_idname references the preview surface
|
||||
(``preview`` in the idname) are the preview-owner exception."""
|
||||
from bonsai.bim.module.model import wall as wall_mod
|
||||
|
||||
out = []
|
||||
for name in dir(wall_mod):
|
||||
obj = getattr(wall_mod, name)
|
||||
if not isinstance(obj, type):
|
||||
continue
|
||||
if not issubclass(obj, bpy.types.GizmoGroup) or obj is bpy.types.GizmoGroup:
|
||||
continue
|
||||
# Local definitions only — skip re-exports / aliases.
|
||||
if obj.__module__ != wall_mod.__name__:
|
||||
continue
|
||||
# Preview-owner exception: the gizmo group that drives a preview
|
||||
# itself must remain visible while its preview is active, so a
|
||||
# "no preview active" gate would self-block it. The bl_idname
|
||||
# contains the substring 'preview' for these groups by Bonsai
|
||||
# convention (e.g. OBJECT_GGT_bim_wall_fillet_preview).
|
||||
bl_idname = getattr(obj, "bl_idname", "") or ""
|
||||
if "preview" in bl_idname.lower():
|
||||
continue
|
||||
out.append((name, obj))
|
||||
return out
|
||||
|
||||
|
||||
class TestWallGizmoGroupsHideDuringPreview:
|
||||
"""Behaviour contract: a parametric-edit preview is the only interactive
|
||||
surface in the viewport, so every sister wall gizmo must self-hide via
|
||||
its poll. The test exercises this BEHAVIOUR — when ``any_preview_active``
|
||||
reports True, every wall gizmo's poll returns False — without pinning
|
||||
the helper function name each poll uses internally."""
|
||||
|
||||
def test_discovery_finds_wall_gizmo_groups(self):
|
||||
"""Sanity check: at least one wall gizmo group is found. If this fails,
|
||||
the discovery walk drifted out of sync with the module structure (e.g.
|
||||
wall gizmo groups got moved to a separate file)."""
|
||||
groups = _wall_gizmo_groups()
|
||||
assert groups, "Expected at least one wall GizmoGroup subclass in wall.py — discovery walk broke?"
|
||||
|
||||
def test_every_wall_gizmo_hides_when_a_preview_is_active(self):
|
||||
"""For each discovered wall gizmo group, mock ``any_preview_active`` to
|
||||
True and call ``poll(bpy.context)``. Every poll must return False —
|
||||
any True is a poll that wouldn't hide during a fillet/bend preview,
|
||||
leaving the user with two competing icon stacks on the same selection."""
|
||||
groups = _wall_gizmo_groups()
|
||||
offenders = []
|
||||
with patch("bonsai.bim.module.model.preview_base.any_preview_active", return_value=True):
|
||||
for name, cls in groups:
|
||||
poll = getattr(cls, "poll", None)
|
||||
if poll is None:
|
||||
# Inherits poll from a mixin / base — the base poll's gating
|
||||
# is covered separately. Skip rather than crash.
|
||||
continue
|
||||
try:
|
||||
result = poll(bpy.context)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
offenders.append((name, f"poll raised: {type(exc).__name__}: {exc}"))
|
||||
continue
|
||||
if result:
|
||||
offenders.append((name, "poll returned True with preview active"))
|
||||
|
||||
assert not offenders, (
|
||||
"Wall gizmo polls that don't gate on any_preview_active "
|
||||
"(or raise instead of returning False): "
|
||||
+ ", ".join(f"{n} — {why}" for n, why in offenders)
|
||||
+ ". Hide sister gizmos during previews so the preview is the only "
|
||||
"interactive surface in the viewport. The conventional path is to "
|
||||
"early-return from poll when preview_base.any_preview_active(context) "
|
||||
"is True."
|
||||
)
|
||||
|
||||
|
||||
class TestBaseParametricGizmoPollHidesDuringPreview:
|
||||
"""Mirror of the wall-specific test for the cross-feature parametric
|
||||
framework: door / window / stair / roof / railing / array all inherit
|
||||
``BaseParametricGizmoGroup``. Its poll must also short-circuit on
|
||||
``any_preview_active`` so sister features behave consistently with walls."""
|
||||
|
||||
def test_base_parametric_poll_returns_false_when_a_preview_is_active(self):
|
||||
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
|
||||
|
||||
# The base poll requires an active selected object before checking the
|
||||
# preview gate. Mock both the selected-object check (return a sentinel)
|
||||
# AND the gate so the test exercises ONLY the preview short-circuit.
|
||||
with patch("bonsai.tool.Blender.get_active_object", return_value=object()):
|
||||
with patch("bonsai.tool.Blender.are_viewport_gizmos_enabled", return_value=True):
|
||||
with patch(
|
||||
"bonsai.bim.module.model.preview_base.any_preview_active",
|
||||
return_value=True,
|
||||
):
|
||||
assert BaseParametricGizmoGroup.poll(bpy.context) is False
|
||||
|
||||
|
||||
class TestModulePathIsFindable:
|
||||
"""If wall.py is split across multiple modules (e.g. wall_gizmos.py),
|
||||
update ``_wall_gizmo_groups`` to walk each. This sanity check fails first
|
||||
so the diagnostic message is obvious."""
|
||||
|
||||
def test_wall_module_resolves(self):
|
||||
from bonsai.bim.module.model import wall as wall_mod
|
||||
|
||||
assert inspect.ismodule(wall_mod)
|
||||
@@ -0,0 +1,290 @@
|
||||
# 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 poll() preconditions of wall billboarding gizmo groups.
|
||||
|
||||
These tests patch ``tool.Blender`` / ``tool.Ifc`` / ``tool.Model`` so the poll
|
||||
logic can be exercised without a real IFC fixture. Each test pins one of the
|
||||
gates ``poll()`` walks, so any silent regression in the gate order or in the
|
||||
LAYER3-active / LAYER2-other contract is caught by a dedicated assertion."""
|
||||
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
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 _make_context(active, selected):
|
||||
"""Build a minimal ``context`` stub with the two attributes ``poll()`` reads."""
|
||||
return SimpleNamespace(active_object=active, selected_objects=list(selected))
|
||||
|
||||
|
||||
def _patch_tools(prefs_on, selected, active_element, other_element, active_usage, other_usage):
|
||||
"""Return a stack of patches that simulate one selection / IFC state for poll().
|
||||
|
||||
``prefs.gizmos.draw_gizmos_in_3d_viewport`` is the top-level toggle. The
|
||||
selection set, the IFC entity lookup, and the usage-type lookup are stubbed
|
||||
so the test only depends on the predicate ordering in poll()."""
|
||||
prefs = SimpleNamespace(gizmos=SimpleNamespace(draw_gizmos_in_3d_viewport=prefs_on))
|
||||
|
||||
entity_map = {}
|
||||
usage_map = {}
|
||||
# active_element/other_element are matched by object identity from the selected set
|
||||
if len(selected) == 2:
|
||||
entity_map[id(selected[0])] = active_element
|
||||
entity_map[id(selected[1])] = other_element
|
||||
usage_map[id(active_element)] = active_usage
|
||||
usage_map[id(other_element)] = other_usage
|
||||
|
||||
def get_entity(obj):
|
||||
return entity_map.get(id(obj))
|
||||
|
||||
def get_usage_type(element):
|
||||
return usage_map.get(id(element))
|
||||
|
||||
from bonsai import tool
|
||||
|
||||
return [
|
||||
patch.object(tool.Blender, "get_addon_preferences", return_value=prefs),
|
||||
patch.object(tool.Blender, "get_selected_objects", return_value=set(selected)),
|
||||
patch.object(tool.Ifc, "get_entity", side_effect=get_entity),
|
||||
patch.object(tool.Model, "get_usage_type", side_effect=get_usage_type),
|
||||
]
|
||||
|
||||
|
||||
def _run_poll(prefs_on, active_is_in_selected, len_override, active_usage, other_usage, active_has_entity=True):
|
||||
from bonsai.bim.module.model.wall import GizmoWallExtendVertically
|
||||
|
||||
slab_obj = object()
|
||||
wall_obj = object()
|
||||
active = slab_obj if active_is_in_selected else object()
|
||||
if len_override is None:
|
||||
selected = [slab_obj, wall_obj]
|
||||
else:
|
||||
selected = [object() for _ in range(len_override)]
|
||||
if active_is_in_selected and selected:
|
||||
active = selected[0]
|
||||
|
||||
slab_element = object() if active_has_entity else None
|
||||
wall_element = object()
|
||||
|
||||
patches = _patch_tools(prefs_on, selected, slab_element, wall_element, active_usage, other_usage)
|
||||
for p in patches:
|
||||
p.start()
|
||||
try:
|
||||
return GizmoWallExtendVertically.poll(_make_context(active, selected))
|
||||
finally:
|
||||
for p in patches:
|
||||
p.stop()
|
||||
|
||||
|
||||
def test_poll_accepts_layer3_active_with_layer2_other():
|
||||
assert (
|
||||
_run_poll(
|
||||
prefs_on=True, active_is_in_selected=True, len_override=None, active_usage="LAYER3", other_usage="LAYER2"
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
def test_poll_rejects_when_gizmo_toggle_off():
|
||||
assert (
|
||||
_run_poll(
|
||||
prefs_on=False, active_is_in_selected=True, len_override=None, active_usage="LAYER3", other_usage="LAYER2"
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_poll_rejects_when_selection_count_is_not_two():
|
||||
assert (
|
||||
_run_poll(
|
||||
prefs_on=True, active_is_in_selected=True, len_override=3, active_usage="LAYER3", other_usage="LAYER2"
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
_run_poll(
|
||||
prefs_on=True, active_is_in_selected=True, len_override=1, active_usage="LAYER3", other_usage="LAYER2"
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_poll_rejects_when_active_has_no_ifc_entity():
|
||||
assert (
|
||||
_run_poll(
|
||||
prefs_on=True,
|
||||
active_is_in_selected=True,
|
||||
len_override=None,
|
||||
active_usage="LAYER3",
|
||||
other_usage="LAYER2",
|
||||
active_has_entity=False,
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_poll_rejects_when_active_is_not_layer3():
|
||||
# A LAYER2 active (wall) must NOT trigger this gizmo — the wall-join gizmo
|
||||
# owns that case, and extend_walls_to_underside expects the slab to be active.
|
||||
assert (
|
||||
_run_poll(
|
||||
prefs_on=True, active_is_in_selected=True, len_override=None, active_usage="LAYER2", other_usage="LAYER2"
|
||||
)
|
||||
is False
|
||||
)
|
||||
# Active with no usage at all (generic mesh, e.g. an opening blocker) is also rejected.
|
||||
assert (
|
||||
_run_poll(prefs_on=True, active_is_in_selected=True, len_override=None, active_usage=None, other_usage="LAYER2")
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_poll_rejects_when_other_is_not_layer2_wall():
|
||||
assert (
|
||||
_run_poll(
|
||||
prefs_on=True, active_is_in_selected=True, len_override=None, active_usage="LAYER3", other_usage="LAYER3"
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
_run_poll(prefs_on=True, active_is_in_selected=True, len_override=None, active_usage="LAYER3", other_usage=None)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# _iter_path_connections — IfcRelConnectsPathElements inverse-graph walk
|
||||
# ----------------------------------------------------------------------------
|
||||
#
|
||||
# Normalises both ConnectedTo and ConnectedFrom orientations to (other, self_ct,
|
||||
# other_ct) so callers always read "self first" regardless of which side of the
|
||||
# rel this wall was authored on. Non-wall partners and malformed (None) refs are
|
||||
# filtered out so per-frame gizmo positioning survives partial IFC state.
|
||||
|
||||
|
||||
def _make_path_rel(relating, related, relating_ct, related_ct, kind="IfcRelConnectsPathElements"):
|
||||
"""Build a stub IfcRelConnectsPathElements for inverse-walk tests."""
|
||||
return SimpleNamespace(
|
||||
is_a=lambda name, _k=kind: name == _k,
|
||||
RelatingElement=relating,
|
||||
RelatedElement=related,
|
||||
RelatingConnectionType=relating_ct,
|
||||
RelatedConnectionType=related_ct,
|
||||
)
|
||||
|
||||
|
||||
def _run_iter_path_connections(elem, *, is_wall_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):
|
||||
return _iter_path_connections(elem)
|
||||
|
||||
|
||||
def test_iter_path_connections_empty_inverses_yields_nothing():
|
||||
elem = SimpleNamespace(ConnectedTo=[], ConnectedFrom=[])
|
||||
assert _run_iter_path_connections(elem) == []
|
||||
|
||||
|
||||
def test_iter_path_connections_connected_to_orientation_is_self_first():
|
||||
# Self is the rel's RelatingElement → its connection type is RelatingConnectionType.
|
||||
self_elem = object()
|
||||
other = object()
|
||||
rel = _make_path_rel(relating=self_elem, related=other, relating_ct="ATEND", related_ct="ATSTART")
|
||||
elem = SimpleNamespace(ConnectedTo=[rel], ConnectedFrom=[])
|
||||
assert _run_iter_path_connections(elem) == [(other, "ATEND", "ATSTART")]
|
||||
|
||||
|
||||
def test_iter_path_connections_connected_from_orientation_is_self_first():
|
||||
# Self is the rel's RelatedElement → its connection type is RelatedConnectionType.
|
||||
# The helper must FLIP the tuple so callers still see (other, self_ct, other_ct).
|
||||
self_elem = object()
|
||||
other = object()
|
||||
rel = _make_path_rel(relating=other, related=self_elem, relating_ct="ATSTART", related_ct="ATEND")
|
||||
elem = SimpleNamespace(ConnectedTo=[], ConnectedFrom=[rel])
|
||||
assert _run_iter_path_connections(elem) == [(other, "ATEND", "ATSTART")]
|
||||
|
||||
|
||||
def test_iter_path_connections_skips_non_path_rels():
|
||||
# IfcRelAggregates, IfcRelContainedInSpatialStructure, etc. share the
|
||||
# ConnectedTo/ConnectedFrom inverse arrays — only IfcRelConnectsPathElements
|
||||
# carries the per-end connection-type semantics we care about.
|
||||
self_elem = object()
|
||||
other = object()
|
||||
non_path = _make_path_rel(
|
||||
relating=self_elem, related=other, relating_ct="ATSTART", related_ct="ATEND", kind="IfcRelAggregates"
|
||||
)
|
||||
path = _make_path_rel(relating=self_elem, related=other, relating_ct="ATEND", related_ct="ATSTART")
|
||||
elem = SimpleNamespace(ConnectedTo=[non_path, path], ConnectedFrom=[])
|
||||
assert _run_iter_path_connections(elem) == [(other, "ATEND", "ATSTART")]
|
||||
|
||||
|
||||
def test_iter_path_connections_skips_non_wall_partners():
|
||||
# Walls may path-connect to non-wall elements (columns, beams). The single-
|
||||
# wall unjoin gizmo only surfaces wall-to-wall joins to match the existing
|
||||
# two-wall gizmo's scope.
|
||||
self_elem = object()
|
||||
wall_partner = object()
|
||||
non_wall_partner = object()
|
||||
rel_wall = _make_path_rel(relating=self_elem, related=wall_partner, relating_ct="ATEND", related_ct="ATSTART")
|
||||
rel_non_wall = _make_path_rel(
|
||||
relating=self_elem, related=non_wall_partner, relating_ct="ATEND", related_ct="ATSTART"
|
||||
)
|
||||
elem = SimpleNamespace(ConnectedTo=[rel_wall, rel_non_wall], ConnectedFrom=[])
|
||||
result = _run_iter_path_connections(elem, is_wall_predicate=lambda e: e is wall_partner)
|
||||
assert result == [(wall_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.
|
||||
self_elem = object()
|
||||
other = object()
|
||||
rel_none = _make_path_rel(relating=self_elem, related=None, relating_ct="ATEND", related_ct="ATSTART")
|
||||
rel_ok = _make_path_rel(relating=self_elem, related=other, relating_ct="ATSTART", related_ct="ATEND")
|
||||
elem = SimpleNamespace(ConnectedTo=[rel_none, rel_ok], ConnectedFrom=[])
|
||||
assert _run_iter_path_connections(elem) == [(other, "ATSTART", "ATEND")]
|
||||
|
||||
|
||||
def test_iter_path_connections_walks_both_inverses_in_order():
|
||||
# A wall can sit on both sides of different path rels (e.g. authored once
|
||||
# as the RelatingElement, once as the RelatedElement). The helper walks
|
||||
# ConnectedTo first, then ConnectedFrom — pinning the order so callers can
|
||||
# depend on it for icon-slot allocation.
|
||||
self_elem = object()
|
||||
p1 = object()
|
||||
p2 = object()
|
||||
rel_to = _make_path_rel(relating=self_elem, related=p1, relating_ct="ATSTART", related_ct="ATSTART")
|
||||
rel_from = _make_path_rel(relating=p2, related=self_elem, relating_ct="ATEND", related_ct="ATEND")
|
||||
elem = SimpleNamespace(ConnectedTo=[rel_to], ConnectedFrom=[rel_from])
|
||||
assert _run_iter_path_connections(elem) == [(p1, "ATSTART", "ATSTART"), (p2, "ATEND", "ATEND")]
|
||||
@@ -0,0 +1,87 @@
|
||||
# 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.
|
||||
|
||||
"""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.
|
||||
|
||||
Bug repro before the fix: hotkey operators that edited the active wall in
|
||||
place (``bpy.ops.bim.hotkey(hotkey="S_E")`` / ``"C_E"``) mutated IFC but never
|
||||
fired ``active_object_callback`` (no selection change), so the header H/L/A
|
||||
fields and the gizmo cache both kept showing stale values. ``refresh_ui_data``
|
||||
ran, but it never resynced ``BIMModelProperties`` and never invalidated the
|
||||
per-gizmo-group geometry cache. The fix wires both refreshes through
|
||||
``tool.Parametric.refresh_post_commit`` and calls it from every
|
||||
``tool.Ifc.Operator`` epilogue."""
|
||||
|
||||
import types
|
||||
from unittest.mock import patch
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
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_refresh_post_commit_bumps_generation_and_resyncs_header():
|
||||
"""``refresh_post_commit`` must bump the generation counter and call
|
||||
``update_bim_tool_props`` so the workspace tool header re-syncs from IFC."""
|
||||
import bonsai.bim.handler as handler
|
||||
from bonsai import tool
|
||||
|
||||
before = tool.Parametric.get_geom_generation()
|
||||
with patch.object(handler, "update_bim_tool_props") as mock_resync:
|
||||
tool.Parametric.refresh_post_commit()
|
||||
assert tool.Parametric.get_geom_generation() == before + 1
|
||||
mock_resync.assert_called_once()
|
||||
|
||||
|
||||
def test_geom_generation_invalidates_wall_geom_cache():
|
||||
"""Bumping the generation must cause ``_get_wall_geom_cached`` to drop its
|
||||
stored entries on the next read, even when the same gizmo group instance
|
||||
and the same wall object are reused (the case Blender's
|
||||
``GizmoGroup.refresh()`` does not cover)."""
|
||||
from bonsai import tool
|
||||
from bonsai.bim.module.model import wall as wall_mod
|
||||
|
||||
class _FakeGroup:
|
||||
pass
|
||||
|
||||
group = _FakeGroup()
|
||||
fake_obj = types.SimpleNamespace(name="Wall/W001")
|
||||
sentinel_a = {"length": 1.0, "height": 2.0, "x_angle": 0.0}
|
||||
sentinel_b = {"length": 1.5, "height": 2.5, "x_angle": 0.0}
|
||||
|
||||
with patch.object(tool.Wall, "read_geometry", side_effect=[sentinel_a, sentinel_b]):
|
||||
first = wall_mod._get_wall_geom_cached(group, fake_obj)
|
||||
assert first is sentinel_a
|
||||
# Same call without a generation bump must hit the cache (no extra read).
|
||||
assert wall_mod._get_wall_geom_cached(group, fake_obj) is sentinel_a
|
||||
# Simulate an IFC commit: generation advances, cache must drop.
|
||||
tool.Parametric._geom_generation += 1
|
||||
second = wall_mod._get_wall_geom_cached(group, fake_obj)
|
||||
assert second is sentinel_b
|
||||
assert second is not first
|
||||
@@ -0,0 +1,377 @@
|
||||
# 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.
|
||||
|
||||
"""Regression tests for wall-split opening assignment when the cut passes
|
||||
through an opening.
|
||||
|
||||
Bug repro before the fix: when ``bpy.ops.bim.split_wall`` (Shift+K) cut a wall
|
||||
through an opening, ``DumbWallJoiner.split`` decided opening assignment using
|
||||
the opening's centre-point projected onto the wall axis. Any opening whose
|
||||
extent straddled the cut was therefore assigned to whichever side its centre
|
||||
sat on, leaving the neighbour wall with no void where the opening overlapped.
|
||||
|
||||
The fix replaces the single-point test with an axis-projected extent
|
||||
(``_opening_axis_extent``): an opening is removed from a wall only when its
|
||||
extent lies *entirely* outside that wall's portion of the axis. Straddling
|
||||
openings stay on both walls."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.wall
|
||||
|
||||
|
||||
def _fake_shape(verts_local, matrix_world_4x4):
|
||||
"""Build a stand-in for the ``shape`` object returned by
|
||||
``ifcopenshell.geom.create_shape``. ``get_vertices`` and
|
||||
``get_shape_matrix`` are mocked separately to read off this stand-in."""
|
||||
import numpy as np
|
||||
|
||||
shape = MagicMock(name="shape")
|
||||
shape.geometry = MagicMock(name="geometry")
|
||||
shape._verts = np.asarray(verts_local, dtype=np.float64)
|
||||
shape._matrix = np.asarray(matrix_world_4x4, dtype=np.float64)
|
||||
return shape
|
||||
|
||||
|
||||
def test_opening_axis_extent_uses_geometry_kernel_vertices():
|
||||
"""``_opening_axis_extent`` drives ``ifcopenshell.geom.create_shape`` to
|
||||
get the opening's real geometry vertices and ``get_shape_matrix`` to get
|
||||
its world placement, then projects the world-space corners onto the wall
|
||||
axis. This is the production path — works for every representation type
|
||||
Bonsai may produce (mapped representation, swept area, brep, boolean).
|
||||
|
||||
A unit cube centred at world X=5 on a 10m wall axis projects to
|
||||
t ∈ [0.45, 0.55] (the cube spans 0.5m on each axis around the centre)."""
|
||||
from bonsai.bim.module.model.wall import _opening_axis_extent
|
||||
|
||||
# Unit cube in local coords, centred at (0,0,0), extent ±0.5.
|
||||
verts_local = [
|
||||
(-0.5, -0.5, -0.5),
|
||||
(0.5, -0.5, -0.5),
|
||||
(0.5, 0.5, -0.5),
|
||||
(-0.5, 0.5, -0.5),
|
||||
(-0.5, -0.5, 0.5),
|
||||
(0.5, -0.5, 0.5),
|
||||
(0.5, 0.5, 0.5),
|
||||
(-0.5, 0.5, 0.5),
|
||||
]
|
||||
matrix_world = [
|
||||
[1.0, 0.0, 0.0, 5.0],
|
||||
[0.0, 1.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 1.0, 0.0],
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
]
|
||||
fake_shape = _fake_shape(verts_local, matrix_world)
|
||||
opening = MagicMock(name="opening")
|
||||
axis_reference = (
|
||||
__import__("mathutils").Vector((0.0, 0.0)),
|
||||
__import__("mathutils").Vector((10.0, 0.0)),
|
||||
)
|
||||
|
||||
with (
|
||||
patch("ifcopenshell.geom.create_shape", return_value=fake_shape),
|
||||
patch("ifcopenshell.util.shape.get_vertices", return_value=fake_shape._verts),
|
||||
patch("ifcopenshell.util.shape.get_shape_matrix", return_value=fake_shape._matrix),
|
||||
):
|
||||
min_t, max_t = _opening_axis_extent(opening, axis_reference, unit_scale=1.0)
|
||||
|
||||
# Cube spans world X ∈ [4.5, 5.5] → t ∈ [0.45, 0.55].
|
||||
assert min_t == pytest.approx(0.45)
|
||||
assert max_t == pytest.approx(0.55)
|
||||
|
||||
|
||||
def test_opening_axis_extent_falls_back_to_placement_when_geometry_kernel_fails():
|
||||
"""When ``ifcopenshell.geom.create_shape`` raises (representation it
|
||||
can't process), the helper falls back to a degenerate single-point range
|
||||
at the opening's composed placement origin. This is the safety net — it
|
||||
matches the pre-fix center-only semantics rather than dropping the
|
||||
opening entirely."""
|
||||
from bonsai.bim.module.model.wall import _opening_axis_extent
|
||||
|
||||
opening = MagicMock(name="opening")
|
||||
placement_matrix = [
|
||||
[1.0, 0.0, 0.0, 5.0],
|
||||
[0.0, 1.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 1.0, 0.0],
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
]
|
||||
axis_reference = (
|
||||
__import__("mathutils").Vector((0.0, 0.0)),
|
||||
__import__("mathutils").Vector((10.0, 0.0)),
|
||||
)
|
||||
|
||||
with (
|
||||
patch("ifcopenshell.geom.create_shape", side_effect=RuntimeError("kernel failure")),
|
||||
patch("ifcopenshell.util.placement.get_local_placement") as mock_get_placement,
|
||||
):
|
||||
mock_get_placement.return_value = type("FakeArr", (), {"tolist": lambda self: placement_matrix})()
|
||||
min_t, max_t = _opening_axis_extent(opening, axis_reference, unit_scale=1.0)
|
||||
|
||||
assert min_t == max_t == pytest.approx(0.5)
|
||||
|
||||
|
||||
def test_opening_axis_extent_offset_cursor_inside_extent_returns_straddling_range():
|
||||
"""The regression guard for the user-reported bug across two fix attempts:
|
||||
when the cursor is placed *inside* the opening but not at its exact
|
||||
centre, the helper must still return a range that straddles the cursor
|
||||
position so the side test keeps the opening on both walls.
|
||||
|
||||
Pre-fix v2/v3 collapsed to a degenerate range whenever the production
|
||||
representation type wasn't recognised (Blender bound_box absent in v2;
|
||||
mapped representation not walked in v3). The current implementation uses
|
||||
``ifcopenshell.geom.create_shape``, which handles every representation
|
||||
Bonsai may produce."""
|
||||
from bonsai.bim.module.model.wall import _opening_axis_extent
|
||||
|
||||
# 2m-wide opening centred at world X=5 → world X ∈ [4.0, 6.0] → t ∈ [0.4, 0.6].
|
||||
verts_local = [(-1.0, -0.5, -0.5), (1.0, 0.5, 0.5)]
|
||||
matrix_world = [
|
||||
[1.0, 0.0, 0.0, 5.0],
|
||||
[0.0, 1.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 1.0, 0.0],
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
]
|
||||
fake_shape = _fake_shape(verts_local, matrix_world)
|
||||
opening = MagicMock(name="opening")
|
||||
axis_reference = (
|
||||
__import__("mathutils").Vector((0.0, 0.0)),
|
||||
__import__("mathutils").Vector((10.0, 0.0)),
|
||||
)
|
||||
|
||||
with (
|
||||
patch("ifcopenshell.geom.create_shape", return_value=fake_shape),
|
||||
patch("ifcopenshell.util.shape.get_vertices", return_value=fake_shape._verts),
|
||||
patch("ifcopenshell.util.shape.get_shape_matrix", return_value=fake_shape._matrix),
|
||||
):
|
||||
min_t, max_t = _opening_axis_extent(opening, axis_reference, unit_scale=1.0)
|
||||
|
||||
# Cursor at world X=4.7 → t=0.47 (inside the opening, not centred on it).
|
||||
cut_percentage = 0.47
|
||||
assert (
|
||||
min_t < cut_percentage < max_t
|
||||
), f"opening [t={min_t}, t={max_t}] must straddle off-centre cursor at t={cut_percentage}"
|
||||
|
||||
|
||||
def test_straddling_opening_is_kept_on_both_sides():
|
||||
"""The pruning logic must keep an opening whose extent straddles the cut
|
||||
on *both* element1 and element2.
|
||||
|
||||
Before the fix, an opening with centre at t=0.5 and cut_percentage=0.6
|
||||
would be removed from element2 (centre < cut) but kept on element1; the
|
||||
opening's right half — which physically overlaps element2 — would be
|
||||
silently dropped. After the fix, the opening overlaps both portions of
|
||||
the axis (min_t=0.3 < 0.6 < max_t=0.7) so both walls keep it.
|
||||
|
||||
Replays the boolean comparisons that ``DumbWallJoiner.split`` performs on
|
||||
the helper's return value; does not call the helper itself."""
|
||||
min_t, max_t = 0.3, 0.7 # straddles any cut_percentage in (0.3, 0.7)
|
||||
cut_percentage = 0.6
|
||||
|
||||
removed_from_element1 = min_t > cut_percentage
|
||||
removed_from_element2 = max_t < cut_percentage
|
||||
|
||||
assert removed_from_element1 is False, "straddling opening must remain on element1"
|
||||
assert removed_from_element2 is False, "straddling opening must remain on element2"
|
||||
|
||||
|
||||
def test_opening_entirely_past_cut_is_removed_from_element1_only():
|
||||
"""Opening lies wholly on element2's side (min_t > cut_percentage).
|
||||
Pre-fix and post-fix both remove it from element1; post-fix additionally
|
||||
guarantees it stays on element2 because max_t > cut_percentage."""
|
||||
min_t, max_t = 0.7, 0.9
|
||||
cut_percentage = 0.5
|
||||
|
||||
assert (min_t > cut_percentage) is True # removed from element1
|
||||
assert (max_t < cut_percentage) is False # kept on element2
|
||||
|
||||
|
||||
def test_opening_entirely_before_cut_is_removed_from_element2_only():
|
||||
"""Mirror of the above: opening wholly on element1's side."""
|
||||
min_t, max_t = 0.1, 0.3
|
||||
cut_percentage = 0.5
|
||||
|
||||
assert (min_t > cut_percentage) is False # kept on element1
|
||||
assert (max_t < cut_percentage) is True # removed from element2
|
||||
|
||||
|
||||
def test_opening_touching_cut_at_boundary_stays_on_both_walls():
|
||||
"""Boundary touch: an opening's ``max_t`` lands exactly on the cut. Strict
|
||||
inequalities keep the opening on both walls — the safer default. (Non-
|
||||
strict ``<=`` would have removed from element2 instead.)"""
|
||||
min_t, max_t = 0.2, 0.5
|
||||
cut_percentage = 0.5
|
||||
|
||||
assert (min_t > cut_percentage) is False # kept on element1
|
||||
assert (max_t < cut_percentage) is False # kept on element2 (boundary == cut)
|
||||
|
||||
|
||||
def test_degenerate_range_at_cut_keeps_opening_on_both_walls():
|
||||
"""Regression guard for the **post-fix-v1 regression**: when the helper
|
||||
falls back to a degenerate range ``(t, t)`` (geometry kernel failed, or
|
||||
the pre-create_shape fix attempts that produced only the placement
|
||||
centre), placing the 3D cursor *on* the opening's centre makes
|
||||
``cut_percentage == t``.
|
||||
|
||||
With non-strict ``>=`` / ``<=`` tests, the degenerate range matched both
|
||||
removal conditions and both walls dropped the opening — leaving the user
|
||||
with two walls and no hole anywhere. Strict ``>`` / ``<`` tests keep the
|
||||
opening on both walls in this case, which matches the visible geometry."""
|
||||
min_t, max_t = 0.5, 0.5 # degenerate range — both bounds at the centre
|
||||
cut_percentage = 0.5 # cursor placed exactly on the opening centre
|
||||
|
||||
removed_from_element1 = min_t > cut_percentage
|
||||
removed_from_element2 = max_t < cut_percentage
|
||||
|
||||
assert removed_from_element1 is False, "must not remove from element1 when cursor sits on opening centre"
|
||||
assert removed_from_element2 is False, "must not remove from element2 when cursor sits on opening centre"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Filled-opening void-straddle behaviour
|
||||
#
|
||||
# When a wall split passes through a door/window, the filling (the door
|
||||
# element itself) belongs to whichever wall contains its centre — but the
|
||||
# void cut by the IfcOpeningElement may still straddle the cut, in which
|
||||
# case the neighbour wall's body must also be cut. The helper that adds the
|
||||
# pure-void copy is ``_add_void_copy``; the decision is taken in
|
||||
# ``DumbWallJoiner.split``'s filled-opening loop.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_void_copy_mock(has_filling_rel=True):
|
||||
"""Build the ``void_copy`` MagicMock returned by ``copy_class`` so its
|
||||
``HasFillings`` / ``VoidsElements`` / ``ObjectPlacement`` shape matches
|
||||
what ``_add_void_copy`` mutates."""
|
||||
copy_placement = MagicMock(name="copy_placement")
|
||||
copy_placement.is_a = lambda klass: klass == "IfcLocalPlacement"
|
||||
void_relation = MagicMock(name="VoidsRelation")
|
||||
void_copy = MagicMock(name="void_copy")
|
||||
void_copy.HasFillings = (MagicMock(name="copy_filling_rel"),) if has_filling_rel else ()
|
||||
void_copy.VoidsElements = (void_relation,)
|
||||
void_copy.ObjectPlacement = copy_placement
|
||||
return void_copy, void_relation, copy_placement
|
||||
|
||||
|
||||
def test_add_void_copy_strips_fillings_and_reparents_to_target_wall():
|
||||
"""``_add_void_copy`` must create a pure-void IfcOpeningElement attached
|
||||
to the target wall: the filling relationship copied along with the source
|
||||
must be removed, ``VoidsElements[0].RelatingBuildingElement`` must point
|
||||
at the target wall, and the representation must be a deep copy (not a
|
||||
shared reference with the source)."""
|
||||
from bonsai.bim.module.model.wall import _add_void_copy
|
||||
|
||||
source_representation = MagicMock(name="source_representation")
|
||||
source_opening = MagicMock(name="source_opening")
|
||||
source_opening.Representation = source_representation
|
||||
|
||||
void_copy, void_relation, copy_placement = _make_void_copy_mock()
|
||||
carried_filling_rel = void_copy.HasFillings[0]
|
||||
|
||||
target_placement = MagicMock(name="target_placement")
|
||||
target_wall = MagicMock(name="target_wall")
|
||||
target_wall.ObjectPlacement = target_placement
|
||||
|
||||
ifc_file = MagicMock(name="ifc_file")
|
||||
deep_copy_result = MagicMock(name="copied_representation")
|
||||
|
||||
with (
|
||||
patch("bonsai.tool.Ifc.get", return_value=ifc_file),
|
||||
patch("ifcopenshell.api.root.copy_class", return_value=void_copy) as mock_copy_class,
|
||||
patch("ifcopenshell.util.element.copy_deep", return_value=deep_copy_result),
|
||||
):
|
||||
_add_void_copy(target_wall, source_opening)
|
||||
|
||||
# The carried-over filling relationship must be removed — the copy is a pure void.
|
||||
ifc_file.remove.assert_called_once_with(carried_filling_rel)
|
||||
# The void now points at the target wall, not the source's wall.
|
||||
assert void_relation.RelatingBuildingElement is target_wall
|
||||
# The placement is reparented under the target wall's local placement.
|
||||
assert copy_placement.PlacementRelTo is target_placement
|
||||
# The representation is deep-copied so future edits don't ripple back to source.
|
||||
assert void_copy.Representation is deep_copy_result
|
||||
mock_copy_class.assert_called_once_with(ifc_file, product=source_opening)
|
||||
|
||||
|
||||
def test_add_void_copy_handles_source_with_no_fillings():
|
||||
"""If the source opening has no ``HasFillings`` (the copy_class result
|
||||
inherits that), the loop over ``void_copy.HasFillings or ()`` must run
|
||||
zero times — no spurious ``ifc_file.remove`` call."""
|
||||
from bonsai.bim.module.model.wall import _add_void_copy
|
||||
|
||||
source_opening = MagicMock(name="source_opening")
|
||||
source_opening.Representation = MagicMock(name="rep")
|
||||
|
||||
void_copy, _void_relation, _copy_placement = _make_void_copy_mock(has_filling_rel=False)
|
||||
target_wall = MagicMock(name="target_wall")
|
||||
|
||||
ifc_file = MagicMock(name="ifc_file")
|
||||
|
||||
with (
|
||||
patch("bonsai.tool.Ifc.get", return_value=ifc_file),
|
||||
patch("ifcopenshell.api.root.copy_class", return_value=void_copy),
|
||||
patch("ifcopenshell.util.element.copy_deep", return_value=MagicMock()),
|
||||
):
|
||||
_add_void_copy(target_wall, source_opening)
|
||||
|
||||
ifc_file.remove.assert_not_called()
|
||||
|
||||
|
||||
def test_filled_opening_void_straddle_decision_keeps_void_on_neighbour():
|
||||
"""Replays the decision logic in ``DumbWallJoiner.split``'s filled-opening
|
||||
loop for the case ``filling_position <= cut_percentage and void_straddles``:
|
||||
filling stays on element1 (its centre is before the cut), but the void
|
||||
extent crosses the cut, so the neighbour wall (element2) must receive a
|
||||
pure-void copy via ``_add_void_copy``.
|
||||
|
||||
Mirrors the unfilled-opening decision tests — exercises the boolean
|
||||
branching rather than full ``split()`` integration."""
|
||||
cut_percentage = 0.5
|
||||
filling_position = 0.4 # filling centre on element1's side
|
||||
min_t, max_t = 0.3, 0.7 # void extent straddles cut at 0.5
|
||||
|
||||
void_straddles = min_t < cut_percentage < max_t
|
||||
filling_on_element2 = filling_position > cut_percentage
|
||||
|
||||
# Expected branch: filling stays, but void straddles → add copy to element2.
|
||||
assert void_straddles is True
|
||||
assert filling_on_element2 is False
|
||||
# Equivalent to the ``elif void_straddles:`` path adding a void copy to element2.
|
||||
|
||||
|
||||
def test_filled_opening_void_straddle_with_filling_on_far_side_keeps_void_on_origin():
|
||||
"""The symmetric case: ``filling_position > cut_percentage and void_straddles``.
|
||||
Filling moves to element2 with the original void; element1 needs a
|
||||
pure-void copy back (the void's element1 portion would otherwise be
|
||||
orphaned). Documents the boolean state of the inner branch."""
|
||||
cut_percentage = 0.5
|
||||
filling_position = 0.6 # filling centre on element2's side
|
||||
min_t, max_t = 0.3, 0.7
|
||||
|
||||
void_straddles = min_t < cut_percentage < max_t
|
||||
filling_on_element2 = filling_position > cut_percentage
|
||||
|
||||
assert void_straddles is True
|
||||
assert filling_on_element2 is True
|
||||
# Equivalent to the outer ``if filling_position > cut_percentage`` path
|
||||
# taking its inner ``if void_straddles`` branch and adding a void copy
|
||||
# back to element1.
|
||||
@@ -15,6 +15,8 @@
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was modified with the assistance of an AI coding tool.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -1131,6 +1133,17 @@ def the_variable_key_is_value(key, value):
|
||||
variables[key] = eval(replace_variables(value))
|
||||
|
||||
|
||||
@then(parsers.parse('the variable "{key}" equals "{value}"'))
|
||||
def the_variable_key_equals_value(key, value):
|
||||
assert key in variables, f'Variable "{key}" was never set'
|
||||
expected = eval(replace_variables(value))
|
||||
actual = variables[key]
|
||||
if isinstance(actual, float) and isinstance(expected, float):
|
||||
assert abs(actual - expected) < 1e-5, f'Variable "{key}" is {actual!r}, expected {expected!r}'
|
||||
else:
|
||||
assert actual == expected, f'Variable "{key}" is {actual!r}, expected {expected!r}'
|
||||
|
||||
|
||||
@then("nothing happens")
|
||||
def nothing_happens():
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
# 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 coverage for the shared parametric-edit lifecycle mixins.
|
||||
|
||||
``bonsai.bim.parametric_lifecycle`` is the load-bearing path for 4 of 6
|
||||
parametric features (door, window, railing, roof). The registry smoke test
|
||||
elsewhere verifies operators are wired up; the mixins' own state-transition
|
||||
contracts are tested here.
|
||||
|
||||
The mixins are exercised through minimal in-test subclasses that supply the
|
||||
abstract hooks (``_is_element_type``, ``_get_props``, etc.). All ``tool.*`` and
|
||||
``ifcopenshell.*`` references at the module top of ``parametric_lifecycle`` are
|
||||
patched at the module attribute (not the source module) so each test sees
|
||||
isolated mock state."""
|
||||
|
||||
import json
|
||||
from typing import ClassVar
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _require_real_bpy():
|
||||
import types as _types
|
||||
|
||||
import bpy
|
||||
|
||||
if not isinstance(bpy, _types.ModuleType) or hasattr(bpy, "_mock_name"):
|
||||
pytest.skip("requires real Blender (bpy is mocked or absent)")
|
||||
|
||||
|
||||
class _FakeProps:
|
||||
"""Stand-in for ``BIM<Name>Properties`` — records what was set so tests can
|
||||
assert state transitions without instantiating real PropertyGroups."""
|
||||
|
||||
def __init__(self):
|
||||
self.is_editing = False
|
||||
self.last_kwargs = None
|
||||
self.general = {"width": 1000}
|
||||
self.lining = {"thickness": 50}
|
||||
self.panel = {"material": "wood"}
|
||||
|
||||
def set_props_kwargs_from_ifc_data(self, data):
|
||||
self.last_kwargs = dict(data)
|
||||
|
||||
def get_general_kwargs(self, convert_to_project_units=True):
|
||||
return dict(self.general)
|
||||
|
||||
def get_lining_kwargs(self, convert_to_project_units=True):
|
||||
return dict(self.lining)
|
||||
|
||||
def get_panel_kwargs(self, convert_to_project_units=True):
|
||||
return dict(self.panel)
|
||||
|
||||
|
||||
def _make_obj(props):
|
||||
obj = mock.Mock()
|
||||
obj.props = props
|
||||
obj.name = "TestObj"
|
||||
return obj
|
||||
|
||||
|
||||
def _make_pset_text(general, lining, panel):
|
||||
payload = {"lining_properties": lining, "panel_properties": panel, **general}
|
||||
return json.dumps(payload)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# FeatureModifierEditMixin (door/window pattern)
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def _door_mixin_cls(match=True, raise_on_update=False):
|
||||
from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin
|
||||
|
||||
raised = raise_on_update
|
||||
|
||||
class _TestDoorMixin(FeatureModifierEditMixin):
|
||||
pset_name: ClassVar[str] = "BBIM_Door"
|
||||
representations_called: ClassVar[list] = []
|
||||
|
||||
@classmethod
|
||||
def _is_element_type(cls, element):
|
||||
return match
|
||||
|
||||
@classmethod
|
||||
def _get_props(cls, obj):
|
||||
return obj.props
|
||||
|
||||
@classmethod
|
||||
def _update_modifier_representation(cls, obj, context):
|
||||
cls.representations_called.append(obj)
|
||||
if raised:
|
||||
raise RuntimeError("simulated representation failure")
|
||||
|
||||
return _TestDoorMixin
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_tool_and_ifc():
|
||||
"""Patch ``tool`` and ``ifcopenshell.*`` references on the lifecycle module.
|
||||
|
||||
Yields ``(mock_tool, mock_ifc_util_element, mock_ifc_api_pset,
|
||||
mock_ifc_util_rep, mock_core_geometry)`` so tests can configure return
|
||||
values and assert call args."""
|
||||
target = "bonsai.bim.parametric_lifecycle"
|
||||
with mock.patch(f"{target}.tool") as mock_tool, mock.patch(f"{target}.ifcopenshell") as mock_ifc, mock.patch(
|
||||
f"{target}.bonsai"
|
||||
) as mock_bonsai:
|
||||
# Element returned by tool.Ifc.get_entity is reused across mocks.
|
||||
element = mock.Mock(name="entity")
|
||||
mock_tool.Ifc.get_entity.return_value = element
|
||||
mock_tool.Ifc.get.return_value = mock.Mock(name="ifc_file")
|
||||
mock_tool.Model.get_constituents_props_data.return_value = {"materials": []}
|
||||
mock_tool.Pset.get_element_pset.return_value = mock.Mock(name="pset")
|
||||
mock_ifc.util.element.get_type.return_value = None # skip thumbnail mark
|
||||
yield {
|
||||
"tool": mock_tool,
|
||||
"ifc": mock_ifc,
|
||||
"bonsai": mock_bonsai,
|
||||
"element": element,
|
||||
}
|
||||
|
||||
|
||||
def test_feature_modifier_enable_one_sets_is_editing_and_loads_kwargs(patched_tool_and_ifc):
|
||||
props = _FakeProps()
|
||||
obj = _make_obj(props)
|
||||
patched_tool_and_ifc["ifc"].util.element.get_pset.return_value = _make_pset_text(
|
||||
{"width": 1234}, {"thickness": 50}, {"material": "wood"}
|
||||
)
|
||||
|
||||
cls = _door_mixin_cls(match=True)
|
||||
cls._enable_one(obj)
|
||||
|
||||
assert props.is_editing is True
|
||||
assert props.last_kwargs is not None
|
||||
assert props.last_kwargs["width"] == 1234
|
||||
assert props.last_kwargs["thickness"] == 50
|
||||
assert props.last_kwargs["material"] == "wood"
|
||||
assert "materials" in props.last_kwargs # from get_constituents_props_data
|
||||
|
||||
|
||||
def test_feature_modifier_enable_one_noop_when_element_not_match(patched_tool_and_ifc):
|
||||
props = _FakeProps()
|
||||
obj = _make_obj(props)
|
||||
|
||||
cls = _door_mixin_cls(match=False)
|
||||
cls._enable_one(obj)
|
||||
|
||||
assert props.is_editing is False
|
||||
assert props.last_kwargs is None
|
||||
# get_pset must not be called when _is_element_type returns False — the
|
||||
# _resolve guard short-circuits before reading pset data.
|
||||
patched_tool_and_ifc["ifc"].util.element.get_pset.assert_not_called()
|
||||
|
||||
|
||||
def test_feature_modifier_enable_one_noop_when_no_entity(patched_tool_and_ifc):
|
||||
"""tool.Ifc.get_entity returning None must short-circuit before predicate runs."""
|
||||
props = _FakeProps()
|
||||
obj = _make_obj(props)
|
||||
patched_tool_and_ifc["tool"].Ifc.get_entity.return_value = None
|
||||
|
||||
cls = _door_mixin_cls(match=True)
|
||||
cls._enable_one(obj)
|
||||
|
||||
assert props.is_editing is False
|
||||
|
||||
|
||||
def test_feature_modifier_finish_one_clears_is_editing_and_writes_pset(patched_tool_and_ifc):
|
||||
props = _FakeProps()
|
||||
props.is_editing = True
|
||||
obj = _make_obj(props)
|
||||
ctx = mock.Mock(name="context")
|
||||
|
||||
cls = _door_mixin_cls(match=True)
|
||||
cls._finish_one(obj, ctx)
|
||||
|
||||
assert props.is_editing is False
|
||||
assert obj in cls.representations_called
|
||||
# tool.Pset.write_bbim_data is called exactly once with the merged dict.
|
||||
patched_tool_and_ifc["tool"].Pset.write_bbim_data.assert_called_once()
|
||||
call_args = patched_tool_and_ifc["tool"].Pset.write_bbim_data.call_args
|
||||
assert call_args.args[1] == "BBIM_Door" # pset_name positional arg
|
||||
written_data = call_args.args[2]
|
||||
assert "lining_properties" in written_data and "panel_properties" in written_data
|
||||
|
||||
|
||||
def test_feature_modifier_finish_one_exception_leaves_draft_in_progress(patched_tool_and_ifc):
|
||||
"""If _update_modifier_representation raises, is_editing must stay True
|
||||
so the user's draft survives for retry. This is the contract called out
|
||||
in parametric_lifecycle.py:161 — set is_editing=False only on success."""
|
||||
props = _FakeProps()
|
||||
props.is_editing = True
|
||||
obj = _make_obj(props)
|
||||
ctx = mock.Mock(name="context")
|
||||
|
||||
cls = _door_mixin_cls(match=True, raise_on_update=True)
|
||||
with pytest.raises(RuntimeError, match="simulated representation failure"):
|
||||
cls._finish_one(obj, ctx)
|
||||
|
||||
assert props.is_editing is True # draft survives
|
||||
|
||||
|
||||
def test_feature_modifier_cancel_one_restores_and_clears_is_editing(patched_tool_and_ifc):
|
||||
props = _FakeProps()
|
||||
props.is_editing = True
|
||||
obj = _make_obj(props)
|
||||
patched_tool_and_ifc["ifc"].util.element.get_pset.return_value = _make_pset_text(
|
||||
{"width": 900}, {"thickness": 60}, {"material": "steel"}
|
||||
)
|
||||
|
||||
cls = _door_mixin_cls(match=True)
|
||||
cls._cancel_one(obj)
|
||||
|
||||
assert props.is_editing is False
|
||||
assert props.last_kwargs is not None and props.last_kwargs["width"] == 900
|
||||
# switch_representation must be called via bonsai.core.geometry.
|
||||
patched_tool_and_ifc["bonsai"].core.geometry.switch_representation.assert_called_once()
|
||||
|
||||
|
||||
def test_feature_modifier_targets_loop_uses_iter_targets(patched_tool_and_ifc):
|
||||
"""_enable_targets / _finish_targets / _cancel_targets iterate
|
||||
_iter_targets — default is [active_object]; subclasses can override."""
|
||||
props_a, props_b = _FakeProps(), _FakeProps()
|
||||
obj_a, obj_b = _make_obj(props_a), _make_obj(props_b)
|
||||
patched_tool_and_ifc["ifc"].util.element.get_pset.return_value = _make_pset_text(
|
||||
{"width": 1000}, {"thickness": 50}, {"material": "wood"}
|
||||
)
|
||||
|
||||
cls = _door_mixin_cls(match=True)
|
||||
cls._iter_targets = classmethod(lambda c, ctx: [obj_a, obj_b])
|
||||
|
||||
result = cls()._enable_targets(mock.Mock())
|
||||
|
||||
assert result == {"FINISHED"}
|
||||
assert props_a.is_editing is True
|
||||
assert props_b.is_editing is True
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# PathPreservingEditMixin (railing/roof pattern)
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakePathProps:
|
||||
"""Stand-in for railing/roof properties — get_general_kwargs only (no lining/panel)."""
|
||||
|
||||
def __init__(self):
|
||||
self.is_editing = False
|
||||
self.last_kwargs = None
|
||||
self.general = {"width": 200, "thickness": 10}
|
||||
|
||||
def set_props_kwargs_from_ifc_data(self, data):
|
||||
self.last_kwargs = dict(data)
|
||||
|
||||
def get_general_kwargs(self, convert_to_project_units=True):
|
||||
return dict(self.general)
|
||||
|
||||
|
||||
def _path_mixin_cls(match=True):
|
||||
from bonsai.bim.parametric_lifecycle import PathPreservingEditMixin
|
||||
|
||||
class _TestPathMixin(PathPreservingEditMixin):
|
||||
pset_name: ClassVar[str] = "BBIM_Railing"
|
||||
pset_updates: ClassVar[list] = []
|
||||
ifc_data_updates: ClassVar[list] = []
|
||||
bmesh_updates: ClassVar[list] = []
|
||||
|
||||
@classmethod
|
||||
def _is_element_type(cls, element):
|
||||
return match
|
||||
|
||||
@classmethod
|
||||
def _get_props(cls, obj):
|
||||
return obj.props
|
||||
|
||||
@classmethod
|
||||
def _update_pset(cls, element, data):
|
||||
cls.pset_updates.append((element, data))
|
||||
|
||||
@classmethod
|
||||
def _update_modifier_ifc_data(cls, obj, context):
|
||||
cls.ifc_data_updates.append(obj)
|
||||
|
||||
@classmethod
|
||||
def _restore_viewport_after_cancel(cls, obj, context):
|
||||
cls.bmesh_updates.append(obj)
|
||||
|
||||
return _TestPathMixin
|
||||
|
||||
|
||||
def test_path_preserving_enable_one_sets_is_editing(patched_tool_and_ifc):
|
||||
props = _FakePathProps()
|
||||
obj = _make_obj(props)
|
||||
patched_tool_and_ifc["tool"].Model.get_modeling_bbim_pset_data.return_value = {
|
||||
"data_dict": {"width": 250, "path_data": {"points": [[0, 0], [1, 0]]}}
|
||||
}
|
||||
|
||||
cls = _path_mixin_cls(match=True)
|
||||
cls._enable_one(obj)
|
||||
|
||||
assert props.is_editing is True
|
||||
assert props.last_kwargs is not None
|
||||
assert props.last_kwargs["width"] == 250
|
||||
# path_data passes through (default _post_load_data is pass-through)
|
||||
assert props.last_kwargs["path_data"] == {"points": [[0, 0], [1, 0]]}
|
||||
|
||||
|
||||
def test_path_preserving_finish_one_preserves_path_data_and_clears_is_editing(patched_tool_and_ifc):
|
||||
props = _FakePathProps()
|
||||
props.is_editing = True
|
||||
obj = _make_obj(props)
|
||||
ctx = mock.Mock(name="context")
|
||||
sentinel_path = {"points": [[5, 5], [9, 9]], "edges": [[0, 1]]}
|
||||
patched_tool_and_ifc["tool"].Model.get_modeling_bbim_pset_data.return_value = {
|
||||
"data_dict": {"path_data": sentinel_path}
|
||||
}
|
||||
|
||||
cls = _path_mixin_cls(match=True)
|
||||
cls._finish_one(obj, ctx)
|
||||
|
||||
assert props.is_editing is False
|
||||
assert cls.pset_updates, "_update_pset must be called on Finish"
|
||||
assert cls.pset_updates[-1][1]["path_data"] is sentinel_path # preserved by reference
|
||||
assert obj in cls.ifc_data_updates
|
||||
|
||||
|
||||
def test_path_preserving_cancel_one_calls_restore_viewport_after_cancel(patched_tool_and_ifc):
|
||||
props = _FakePathProps()
|
||||
props.is_editing = True
|
||||
obj = _make_obj(props)
|
||||
ctx = mock.Mock(name="context")
|
||||
patched_tool_and_ifc["tool"].Model.get_modeling_bbim_pset_data.return_value = {
|
||||
"data_dict": {"width": 250, "path_data": {"points": []}}
|
||||
}
|
||||
|
||||
cls = _path_mixin_cls(match=True)
|
||||
cls._cancel_one(obj, ctx)
|
||||
|
||||
assert props.is_editing is False
|
||||
assert obj in cls.bmesh_updates
|
||||
|
||||
|
||||
def test_path_preserving_enable_one_post_load_data_hook_runs(patched_tool_and_ifc):
|
||||
"""Railing overrides _post_load_data to JSON-serialise path_data —
|
||||
confirm the hook is honoured (here we drop a sentinel key)."""
|
||||
props = _FakePathProps()
|
||||
obj = _make_obj(props)
|
||||
patched_tool_and_ifc["tool"].Model.get_modeling_bbim_pset_data.return_value = {
|
||||
"data_dict": {"width": 250, "extra": "drop_me"}
|
||||
}
|
||||
|
||||
cls = _path_mixin_cls(match=True)
|
||||
cls._post_load_data = classmethod(lambda c, data: {k: v for k, v in data.items() if k != "extra"})
|
||||
cls._enable_one(obj)
|
||||
|
||||
assert "extra" not in props.last_kwargs
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# _ParametricEditMixinBase._resolve guard
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_resolve_returns_none_when_obj_has_no_entity(patched_tool_and_ifc):
|
||||
cls = _door_mixin_cls(match=True)
|
||||
patched_tool_and_ifc["tool"].Ifc.get_entity.return_value = None
|
||||
obj = _make_obj(_FakeProps())
|
||||
|
||||
assert cls._resolve(obj) is None
|
||||
|
||||
|
||||
def test_resolve_returns_none_when_element_type_mismatch(patched_tool_and_ifc):
|
||||
cls = _door_mixin_cls(match=False)
|
||||
obj = _make_obj(_FakeProps())
|
||||
|
||||
assert cls._resolve(obj) is None
|
||||
|
||||
|
||||
def test_resolve_returns_tuple_when_match(patched_tool_and_ifc):
|
||||
cls = _door_mixin_cls(match=True)
|
||||
props = _FakeProps()
|
||||
obj = _make_obj(props)
|
||||
|
||||
resolved = cls._resolve(obj)
|
||||
|
||||
assert resolved is not None
|
||||
element, returned_props = resolved
|
||||
assert element is patched_tool_and_ifc["element"]
|
||||
assert returned_props is props
|
||||
@@ -0,0 +1,157 @@
|
||||
# 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.
|
||||
|
||||
"""Registration smoke test for `tool.Parametric.EDIT_TYPES`.
|
||||
|
||||
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.
|
||||
|
||||
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`."""
|
||||
|
||||
import types
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _require_real_bpy():
|
||||
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
|
||||
pytest.skip("requires real Blender (bpy is mocked or absent)")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registry():
|
||||
from bonsai import tool
|
||||
|
||||
return tool.Parametric.EDIT_TYPES
|
||||
|
||||
|
||||
def test_registry_is_non_empty(registry):
|
||||
assert len(registry) >= 1
|
||||
|
||||
|
||||
def test_every_entry_has_enable_op_registered(registry):
|
||||
missing = [e.enable_op for e in registry if not hasattr(bpy.ops.bim, e.enable_op.removeprefix("bim."))]
|
||||
assert not missing, f"Missing enable operators: {missing}"
|
||||
|
||||
|
||||
def test_every_entry_has_finish_op_registered(registry):
|
||||
missing = [e.finish_op for e in registry if not hasattr(bpy.ops.bim, e.finish_op.removeprefix("bim."))]
|
||||
assert not missing, f"Missing finish operators: {missing}"
|
||||
|
||||
|
||||
def test_every_entry_has_cancel_op_registered(registry):
|
||||
missing = [e.cancel_op for e in registry if not hasattr(bpy.ops.bim, e.cancel_op.removeprefix("bim."))]
|
||||
assert not missing, f"Missing cancel operators: {missing}"
|
||||
|
||||
|
||||
def test_every_entry_has_property_group_attached(registry):
|
||||
# ``register_object_properties`` runs at addon enable; if any entry's
|
||||
# PropertyGroup class is missing on prop module the attribute is skipped.
|
||||
missing = [e.props_attr for e in registry if not hasattr(bpy.types.Object, e.props_attr)]
|
||||
assert not missing, (
|
||||
f"bpy.types.Object missing attributes: {missing} — "
|
||||
f"verify the matching PropertyGroup classes exist in bim.module.model.prop"
|
||||
)
|
||||
|
||||
|
||||
def test_every_entry_has_modifier_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}"
|
||||
|
||||
|
||||
def test_every_predicate_does_not_raise_on_non_matching_element(registry):
|
||||
"""Each ``is_<name>`` predicate must be **total**: accept any IFC entity
|
||||
and return a truthy/falsy value, never raise.
|
||||
|
||||
The registry iterates every predicate against the active IFC element on
|
||||
save; a raising predicate (e.g. ``AttributeError`` from a missing pset
|
||||
accessor when handed a non-matching element type) propagates upward and
|
||||
breaks the save path for *all* parametric types, not just its own.
|
||||
This test probes each predicate with an ``IfcAnnotation`` (an element
|
||||
that carries none of the BBIM_<Type> psets the predicates look up) and
|
||||
asserts the call does not raise. Falsy returns are acceptable — the
|
||||
registry treats them as 'no match'. What's forbidden is raising."""
|
||||
import ifcopenshell
|
||||
|
||||
from bonsai import tool
|
||||
|
||||
probe = ifcopenshell.file(schema="IFC4").create_entity("IfcAnnotation")
|
||||
|
||||
raised = []
|
||||
for feature in registry:
|
||||
predicate = getattr(tool.Blender.Modifier, f"is_{feature.name}", None)
|
||||
if predicate is None:
|
||||
continue
|
||||
try:
|
||||
predicate(probe)
|
||||
except Exception as e:
|
||||
raised.append((feature.name, type(e).__name__, str(e)))
|
||||
assert not raised, (
|
||||
f"is_<name> predicates raised on a non-matching IfcAnnotation: {raised}. "
|
||||
f"Predicates must be total — return bool, never raise. Add an "
|
||||
f"`if not element.is_a('IfcXxx'): return False` short-circuit or guard the pset lookup."
|
||||
)
|
||||
|
||||
|
||||
def test_gizmo_preferences_attached_when_class_exists(registry):
|
||||
"""For every registry entry whose ``GizmoPreferences<Name>`` class exists in
|
||||
``bonsai.bim.ui``, the matching sub-PointerProperty must be declared on
|
||||
``ui.GizmoPreferences`` under the registry entry's ``name`` token.
|
||||
|
||||
Catches the silent-skip behaviour of the registry-driven gizmo-prefs
|
||||
discovery: a typo in the class name or a dropped registration would
|
||||
otherwise produce a missing sub-panel at runtime with no error.
|
||||
Entries without a ``GizmoPreferences<Name>`` class are allowed — not
|
||||
every parametric type ships gizmo prefs.
|
||||
|
||||
Checks ``__annotations__`` rather than ``hasattr`` because Blender's
|
||||
PropertyGroup syntax (``field: bpy.props.PointerProperty(...)``) is an
|
||||
annotation-only assignment — the attribute only materialises on the
|
||||
class after Blender's metaclass installs the bpy_struct descriptor,
|
||||
which depends on registration timing. Reading ``__annotations__``
|
||||
pins the source-level contract independently of when register() ran."""
|
||||
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))
|
||||
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``"
|
||||
)
|
||||
@@ -0,0 +1,243 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Tests for pure-Python math helpers in bonsai.core.model used by the wall gizmo system.
|
||||
|
||||
These run in the core lane (``pytest test/core/``) — no Blender, no IFC file. The
|
||||
helpers under test live in ``bonsai/core/model.py`` and are deliberately pure (tuple
|
||||
in, tuple out) so they're exercisable without ``mathutils`` or ``bpy``."""
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
import bonsai.core.model as subject
|
||||
|
||||
|
||||
class TestBaselineFromOffset:
|
||||
THICKNESS = 0.2
|
||||
|
||||
def test_positive_direction_exterior(self):
|
||||
assert subject.baseline_from_offset(0.0, self.THICKNESS) == "EXTERIOR"
|
||||
|
||||
def test_positive_direction_center(self):
|
||||
assert subject.baseline_from_offset(-self.THICKNESS / 2, self.THICKNESS) == "CENTER"
|
||||
|
||||
def test_positive_direction_interior(self):
|
||||
assert subject.baseline_from_offset(-self.THICKNESS, self.THICKNESS) == "INTERIOR"
|
||||
|
||||
def test_negative_direction_exterior(self):
|
||||
assert subject.baseline_from_offset(self.THICKNESS, self.THICKNESS) == "EXTERIOR"
|
||||
|
||||
def test_negative_direction_center(self):
|
||||
assert subject.baseline_from_offset(self.THICKNESS / 2, self.THICKNESS) == "CENTER"
|
||||
|
||||
def test_negative_direction_interior(self):
|
||||
assert subject.baseline_from_offset(0.0, self.THICKNESS) == "EXTERIOR"
|
||||
|
||||
def test_within_tolerance_still_matches(self):
|
||||
# A 0.5mm jitter on a 200mm wall should still classify cleanly.
|
||||
assert subject.baseline_from_offset(-self.THICKNESS / 2 + 0.0005, self.THICKNESS) == "CENTER"
|
||||
|
||||
def test_outside_tolerance_falls_back_to_center(self):
|
||||
# 50mm offset on a 200mm wall — not a canonical position.
|
||||
assert subject.baseline_from_offset(0.05, self.THICKNESS) == "CENTER"
|
||||
|
||||
|
||||
class TestProjectAxisIntersection:
|
||||
PARALLEL_THRESHOLD = 0.9994 # cos(2°)
|
||||
|
||||
def test_perpendicular_walls_meet_at_corner(self):
|
||||
# Wall A along +X from origin; wall B along +Y from (5, 0, 0).
|
||||
# Axes meet exactly at (5, 0).
|
||||
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
|
||||
seg_b = ((5.0, 0.0, 0.0), (5.0, 3.0, 0.0))
|
||||
result = subject.project_axis_intersection(seg_a, seg_b, self.PARALLEL_THRESHOLD)
|
||||
assert result is not None
|
||||
assert result[0] == pytest.approx(5.0)
|
||||
assert result[1] == pytest.approx(0.0)
|
||||
|
||||
def test_offset_walls_intersect_at_extrapolated_point(self):
|
||||
# Wall A: y=0 from x=1 to x=6.
|
||||
# Wall B: x=0 from y=1 to y=4.
|
||||
# Infinite-line intersection at (0, 0).
|
||||
seg_a = ((1.0, 0.0, 0.0), (6.0, 0.0, 0.0))
|
||||
seg_b = ((0.0, 1.0, 0.0), (0.0, 4.0, 0.0))
|
||||
result = subject.project_axis_intersection(seg_a, seg_b, self.PARALLEL_THRESHOLD)
|
||||
assert result is not None
|
||||
assert result[0] == pytest.approx(0.0)
|
||||
assert result[1] == pytest.approx(0.0)
|
||||
|
||||
def test_parallel_walls_return_none(self):
|
||||
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
|
||||
seg_b = ((0.0, 1.0, 0.0), (5.0, 1.0, 0.0))
|
||||
assert subject.project_axis_intersection(seg_a, seg_b, self.PARALLEL_THRESHOLD) is None
|
||||
|
||||
def test_anti_parallel_walls_return_none(self):
|
||||
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
|
||||
seg_b = ((5.0, 1.0, 0.0), (0.0, 1.0, 0.0)) # opposite direction
|
||||
assert subject.project_axis_intersection(seg_a, seg_b, self.PARALLEL_THRESHOLD) is None
|
||||
|
||||
def test_nearly_parallel_walls_return_none(self):
|
||||
# 1° off parallel — within the ~2° dead-band.
|
||||
angle = math.radians(1)
|
||||
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
|
||||
seg_b = ((0.0, 1.0, 0.0), (5.0 * math.cos(angle), 1.0 + 5.0 * math.sin(angle), 0.0))
|
||||
assert subject.project_axis_intersection(seg_a, seg_b, self.PARALLEL_THRESHOLD) is None
|
||||
|
||||
def test_zero_length_segment_returns_none(self):
|
||||
seg_a = ((0.0, 0.0, 0.0), (0.0, 0.0, 0.0))
|
||||
seg_b = ((0.0, 0.0, 0.0), (1.0, 1.0, 0.0))
|
||||
assert subject.project_axis_intersection(seg_a, seg_b, self.PARALLEL_THRESHOLD) is None
|
||||
|
||||
def test_intersection_z_is_average_of_endpoint_zs(self):
|
||||
# Walls at different elevations; the icon-placement Z should be the average.
|
||||
seg_a = ((0.0, 0.0, 1.0), (5.0, 0.0, 1.0)) # at z=1
|
||||
seg_b = ((5.0, 0.0, 3.0), (5.0, 3.0, 3.0)) # at z=3
|
||||
result = subject.project_axis_intersection(seg_a, seg_b, self.PARALLEL_THRESHOLD)
|
||||
assert result is not None
|
||||
assert result[2] == pytest.approx(2.0)
|
||||
|
||||
|
||||
class TestSlopeRoundTrip:
|
||||
def test_zero_angle_zero_displacement(self):
|
||||
assert subject.displacement_from_x_angle(3.0, 0.0) == pytest.approx(0.0)
|
||||
assert subject.x_angle_from_displacement(3.0, 0.0) == pytest.approx(0.0)
|
||||
|
||||
def test_positive_angle_positive_displacement(self):
|
||||
# 30° slope on a 3m wall → top moves ~1.732m in +Y.
|
||||
displacement = subject.displacement_from_x_angle(3.0, math.radians(30))
|
||||
assert displacement == pytest.approx(3.0 * math.tan(math.radians(30)))
|
||||
|
||||
def test_negative_angle_negative_displacement(self):
|
||||
displacement = subject.displacement_from_x_angle(3.0, math.radians(-15))
|
||||
assert displacement < 0
|
||||
|
||||
def test_round_trip_preserves_angle(self):
|
||||
# Drag-to-angle-to-drag preserves the original.
|
||||
original_angle = math.radians(20)
|
||||
displacement = subject.displacement_from_x_angle(3.0, original_angle)
|
||||
recovered = subject.x_angle_from_displacement(3.0, displacement)
|
||||
assert recovered == pytest.approx(original_angle, abs=1e-9)
|
||||
|
||||
def test_round_trip_handles_zero_height(self):
|
||||
# Walls of effectively zero height should not divide-by-zero.
|
||||
recovered = subject.x_angle_from_displacement(0.0, 1.0)
|
||||
assert recovered == pytest.approx(math.pi / 2, abs=1e-3)
|
||||
|
||||
|
||||
class TestAreAxesCollinear:
|
||||
PARALLEL_THRESHOLD = 0.9994
|
||||
LINE_TOLERANCE = 0.05
|
||||
|
||||
def test_end_to_end_walls_along_x_are_collinear(self):
|
||||
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
|
||||
seg_b = ((5.0, 0.0, 0.0), (10.0, 0.0, 0.0))
|
||||
assert subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE)
|
||||
|
||||
def test_separated_collinear_walls_with_gap(self):
|
||||
# Walls with a 1m gap between them — still on the same line.
|
||||
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
|
||||
seg_b = ((6.0, 0.0, 0.0), (10.0, 0.0, 0.0))
|
||||
assert subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE)
|
||||
|
||||
def test_perpendicular_walls_are_not_collinear(self):
|
||||
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
|
||||
seg_b = ((0.0, 0.0, 0.0), (0.0, 5.0, 0.0))
|
||||
assert not subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE)
|
||||
|
||||
def test_parallel_walls_offset_perpendicular_are_not_collinear(self):
|
||||
# Two parallel walls 1m apart — same direction but not the same line.
|
||||
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
|
||||
seg_b = ((0.0, 1.0, 0.0), (5.0, 1.0, 0.0))
|
||||
assert not subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE)
|
||||
|
||||
def test_anti_parallel_collinear_walls(self):
|
||||
# Reversed direction on the same line still counts as collinear.
|
||||
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
|
||||
seg_b = ((10.0, 0.0, 0.0), (6.0, 0.0, 0.0))
|
||||
assert subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE)
|
||||
|
||||
def test_z_is_ignored_for_plan_collinearity(self):
|
||||
# Walls on different floors are still considered collinear in plan.
|
||||
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
|
||||
seg_b = ((5.0, 0.0, 3.0), (10.0, 0.0, 3.0))
|
||||
assert subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE)
|
||||
|
||||
def test_zero_length_segment_is_not_collinear(self):
|
||||
seg_a = ((0.0, 0.0, 0.0), (0.0, 0.0, 0.0))
|
||||
seg_b = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
|
||||
assert not subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE)
|
||||
|
||||
def test_slightly_off_line_within_tolerance(self):
|
||||
# 2cm perpendicular offset — still within the 5cm tolerance.
|
||||
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
|
||||
seg_b = ((5.0, 0.02, 0.0), (10.0, 0.02, 0.0))
|
||||
assert subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE)
|
||||
|
||||
def test_too_far_off_line_fails_tolerance(self):
|
||||
# 10cm perpendicular offset — outside the 5cm tolerance.
|
||||
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
|
||||
seg_b = ((5.0, 0.10, 0.0), (10.0, 0.10, 0.0))
|
||||
assert not subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE)
|
||||
|
||||
|
||||
class TestClosestEndpointMidpoint:
|
||||
def test_end_to_end_walls_midpoint_is_the_shared_corner(self):
|
||||
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
|
||||
seg_b = ((5.0, 0.0, 0.0), (10.0, 0.0, 0.0))
|
||||
result = subject.closest_endpoint_midpoint(seg_a, seg_b)
|
||||
assert result == (pytest.approx(5.0), pytest.approx(0.0), pytest.approx(0.0))
|
||||
|
||||
def test_walls_with_gap_midpoint_is_in_the_gap(self):
|
||||
# Wall A ends at x=5; wall B starts at x=7. Boundary midpoint is at x=6.
|
||||
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
|
||||
seg_b = ((7.0, 0.0, 0.0), (12.0, 0.0, 0.0))
|
||||
result = subject.closest_endpoint_midpoint(seg_a, seg_b)
|
||||
assert result == (pytest.approx(6.0), pytest.approx(0.0), pytest.approx(0.0))
|
||||
|
||||
def test_perpendicular_walls_midpoint_is_between_nearest_endpoints(self):
|
||||
# Wall A's +X endpoint (5,0,0) and wall B's origin (5,0,0) → midpoint at (5,0,0).
|
||||
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
|
||||
seg_b = ((5.0, 0.0, 0.0), (5.0, 3.0, 0.0))
|
||||
result = subject.closest_endpoint_midpoint(seg_a, seg_b)
|
||||
assert result == (pytest.approx(5.0), pytest.approx(0.0), pytest.approx(0.0))
|
||||
|
||||
def test_z_averaged_when_walls_at_different_elevations(self):
|
||||
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
|
||||
seg_b = ((5.0, 0.0, 3.0), (10.0, 0.0, 3.0))
|
||||
result = subject.closest_endpoint_midpoint(seg_a, seg_b)
|
||||
# Closest pair: (5,0,0) and (5,0,3); midpoint Z = 1.5.
|
||||
assert result[2] == pytest.approx(1.5)
|
||||
|
||||
|
||||
class TestVerticalHeightFromExtrusionDepth:
|
||||
def test_vertical_wall_returns_depth_unchanged(self):
|
||||
assert subject.vertical_height_from_extrusion_depth(3.0, 0.0) == pytest.approx(3.0)
|
||||
|
||||
def test_30_degree_slope(self):
|
||||
# cos(30°) ≈ 0.866 → vertical height of a 3m slanted extrusion ≈ 2.598m.
|
||||
result = subject.vertical_height_from_extrusion_depth(3.0, math.radians(30))
|
||||
assert result == pytest.approx(3.0 * math.cos(math.radians(30)))
|
||||
|
||||
def test_negative_angle_yields_same_magnitude(self):
|
||||
positive = subject.vertical_height_from_extrusion_depth(3.0, math.radians(30))
|
||||
negative = subject.vertical_height_from_extrusion_depth(3.0, math.radians(-30))
|
||||
assert positive == pytest.approx(negative)
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,380 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026 Bruno Perdigão <contact@brunopo.com>
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import inspect
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import pytest
|
||||
|
||||
from bonsai import tool as tool
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
from bonsai.bim.module.model.data import AuthoringData as Model
|
||||
|
||||
GREEN = "\033[32m"
|
||||
RED = "\033[31m"
|
||||
RESET = "\033[0m"
|
||||
|
||||
|
||||
def _assert_pass(message: str) -> None:
|
||||
caller_name = inspect.stack()[1].function
|
||||
print(f"{GREEN}{caller_name} PASSED: {message}{RESET}")
|
||||
|
||||
|
||||
def _handle_error(e: Exception, on_done) -> None:
|
||||
print(f"{RED}Assertion failed: {e}{RESET}")
|
||||
if on_done:
|
||||
on_done()
|
||||
|
||||
|
||||
def run_iter_from_timer(event_iter, on_complete=None, on_error=None):
|
||||
i = iter(event_iter)
|
||||
done = False
|
||||
|
||||
def event_step():
|
||||
nonlocal done, on_complete
|
||||
try:
|
||||
ret = next(i, "STOP")
|
||||
if ret in (None, "STOP", "FINISHED"):
|
||||
done = True
|
||||
if on_complete:
|
||||
on_complete()
|
||||
return None
|
||||
except StopIteration:
|
||||
done = True
|
||||
if on_complete:
|
||||
on_complete()
|
||||
return None
|
||||
except Exception as e:
|
||||
done = True
|
||||
print(f"Exception: {e}")
|
||||
if on_error:
|
||||
on_error(e)
|
||||
elif on_complete:
|
||||
on_complete()
|
||||
return None
|
||||
return 0.0
|
||||
|
||||
bpy.app.timers.register(event_step, first_interval=0.0)
|
||||
|
||||
|
||||
def preset_event_simulate(window, event_type, value, x, y):
|
||||
if value == "TAP":
|
||||
yield window.event_simulate(event_type, "PRESS", x=x, y=y)
|
||||
yield window.event_simulate(event_type, "RELEASE", x=x, y=y)
|
||||
else:
|
||||
yield window.event_simulate(event_type, value, x=x, y=y)
|
||||
|
||||
|
||||
def cleanup():
|
||||
bpy.app.use_event_simulate = False
|
||||
bpy.ops.wm.quit_blender()
|
||||
|
||||
|
||||
def _get_valid_window() -> bpy.types.Window:
|
||||
win = bpy.context.window
|
||||
if win is not None:
|
||||
return win
|
||||
wm = getattr(bpy.context, "window_manager", None)
|
||||
if wm and wm.windows:
|
||||
return wm.windows[0]
|
||||
raise RuntimeError("Unable to locate a Blender UI window.")
|
||||
|
||||
|
||||
def new_project():
|
||||
IfcStore.purge()
|
||||
bpy.ops.wm.read_homefile(app_template="", use_factory_startup=True)
|
||||
if len(bpy.data.objects) > 0:
|
||||
bpy.data.batch_remove(bpy.data.objects)
|
||||
bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True)
|
||||
if len(bpy.data.materials) > 0:
|
||||
bpy.data.batch_remove(bpy.data.materials)
|
||||
bpy.context.scene.unit_settings.system = "METRIC"
|
||||
bpy.context.scene.unit_settings.length_unit = "MILLIMETERS"
|
||||
props = tool.Project.get_project_props()
|
||||
props.template_file = "0"
|
||||
tool.Blender.get_addon_preferences().should_play_chaching_sound = False
|
||||
|
||||
def get_area_and_region(window):
|
||||
area = next(area for area in window.screen.areas if area.type == "VIEW_3D")
|
||||
region = next(region for region in area.regions if region.type == "WINDOW")
|
||||
return area, region
|
||||
|
||||
def test_snap_object_detection(window):
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", 0, 0)
|
||||
area, region = get_area_and_region(window)
|
||||
x = round(area.width * 0.5 + area.x)
|
||||
y = round(area.height * 0.54 + area.y)
|
||||
|
||||
yield from preset_event_simulate(window, "ESC", "TAP", x, y)
|
||||
|
||||
measure_settings = tool.Project.get_measure_tool_settings()
|
||||
measure_settings.measurement_type = "POLYLINE"
|
||||
for obj in tool.Blender.get_selected_objects():
|
||||
obj.select_set(False)
|
||||
with bpy.context.temp_override(area=area, region=region, space_data=area.spaces[0]):
|
||||
bpy.ops.bim.measure_tool("INVOKE_DEFAULT", measure_type="POLYLINE")
|
||||
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y)
|
||||
yield from preset_event_simulate(window, "LEFTMOUSE", "TAP", x, y)
|
||||
snap_point = tool.Model.get_polyline_props().snap_mouse_point[0]
|
||||
assert_msg = "First click should have a snap_object"
|
||||
assert snap_point.snap_object, assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "snap_object should be a string with the object name"
|
||||
assert type(snap_point.snap_object) == str, assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "Object should be an IfcWall"
|
||||
assert snap_point.snap_object.split("/")[0] == "IfcWall", assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
|
||||
offset = 200
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x - offset, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x - offset, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x - offset, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x - offset, y)
|
||||
yield from preset_event_simulate(window, "LEFTMOUSE", "TAP", x - offset, y)
|
||||
snap_point = tool.Model.get_polyline_props().snap_mouse_point[0]
|
||||
assert_msg = "Second click should not have a snap_object"
|
||||
assert not snap_point.snap_object, assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
|
||||
yield from preset_event_simulate(window, "RET", "TAP", x, y)
|
||||
yield "FINISHED"
|
||||
|
||||
def test_snap_partially_behind_camera(window):
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", 0, 0)
|
||||
area, region = get_area_and_region(window)
|
||||
x = round(area.width * 0.20 + area.x)
|
||||
y = round(area.height * 0.15 + area.y)
|
||||
|
||||
yield from preset_event_simulate(window, "ESC", "TAP", x, y)
|
||||
|
||||
measure_settings = tool.Project.get_measure_tool_settings()
|
||||
measure_settings.measurement_type = "POLYLINE"
|
||||
for obj in tool.Blender.get_selected_objects():
|
||||
obj.select_set(False)
|
||||
with bpy.context.temp_override(area=area, region=region, space_data=area.spaces[0]):
|
||||
bpy.ops.bim.measure_tool("INVOKE_DEFAULT", measure_type="POLYLINE")
|
||||
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y)
|
||||
yield from preset_event_simulate(window, "LEFTMOUSE", "TAP", x, y)
|
||||
snap_point = tool.Model.get_polyline_props().snap_mouse_point[0]
|
||||
assert_msg = "First click should have a snap_object"
|
||||
assert snap_point.snap_object, assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "snap_object should be a string with the object name"
|
||||
assert type(snap_point.snap_object) == str, assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "snap_type should be 'Edge'"
|
||||
assert snap_point.snap_type == "Edge", assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "Object should be an IfcSlab"
|
||||
assert snap_point.snap_object.split("/")[0] == "IfcSlab", assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
|
||||
offset = 200
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x + offset, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x + offset, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x + offset, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x + offset, y)
|
||||
yield from preset_event_simulate(window, "LEFTMOUSE", "TAP", x - offset, y)
|
||||
snap_point = tool.Model.get_polyline_props().snap_mouse_point[0]
|
||||
assert_msg = "Second click should have a snap_object"
|
||||
assert snap_point.snap_object, assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "snap_object should be a string with the object name"
|
||||
assert type(snap_point.snap_object) == str, assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "snap_type should be 'Face'"
|
||||
assert snap_point.snap_type == "Face", assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "Object should be an IfcSlab"
|
||||
assert snap_point.snap_object.split("/")[0] == "IfcSlab", assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
|
||||
yield from preset_event_simulate(window, "RET", "TAP", x, y)
|
||||
yield "FINISHED"
|
||||
|
||||
def test_snap_in_xray_mode(window):
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", 0, 0)
|
||||
area, region = get_area_and_region(window)
|
||||
x = round(area.width * 0.68+ area.x)
|
||||
y = round(area.height * 0.54 + area.y)
|
||||
|
||||
area.spaces[0].shading.show_xray = True
|
||||
|
||||
yield from preset_event_simulate(window, "ESC", "TAP", x, y)
|
||||
|
||||
measure_settings = tool.Project.get_measure_tool_settings()
|
||||
measure_settings.measurement_type = "POLYLINE"
|
||||
for obj in tool.Blender.get_selected_objects():
|
||||
obj.select_set(False)
|
||||
with bpy.context.temp_override(area=area, region=region, space_data=area.spaces[0]):
|
||||
bpy.ops.bim.measure_tool("INVOKE_DEFAULT", measure_type="POLYLINE")
|
||||
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y)
|
||||
yield from preset_event_simulate(window, "LEFTMOUSE", "TAP", x, y)
|
||||
snap_point = tool.Model.get_polyline_props().snap_mouse_point[0]
|
||||
assert_msg = "First click should have a snap_object"
|
||||
assert snap_point.snap_object, assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "snap_object should be a string with the object name"
|
||||
assert type(snap_point.snap_object) == str, assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "Object should be an IfcFurniture"
|
||||
assert snap_point.snap_object.split("/")[0] == "IfcFurniture", assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
|
||||
yield from preset_event_simulate(window, "RET", "TAP", x, y)
|
||||
yield "FINISHED"
|
||||
|
||||
def test_snap_far_from_origin(window):
|
||||
bpy.context.view_layer.objects.active = None
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", 0, 0)
|
||||
area, region = get_area_and_region(window)
|
||||
x = round(area.width * 0.155 + area.x)
|
||||
y = round(area.height * 0.18 + area.y)
|
||||
|
||||
yield from preset_event_simulate(window, "ESC", "TAP", x, y)
|
||||
|
||||
bpy.data.objects['IfcBuildingElementProxy/Cube'].select_set(True)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y)
|
||||
with bpy.context.temp_override(area=area, region=region, space_data=area.spaces[0]):
|
||||
bpy.ops.view3d.view_selected()
|
||||
|
||||
|
||||
measure_settings = tool.Project.get_measure_tool_settings()
|
||||
measure_settings.measurement_type = "POLYLINE"
|
||||
for obj in tool.Blender.get_selected_objects():
|
||||
obj.select_set(False)
|
||||
with bpy.context.temp_override(area=area, region=region, space_data=area.spaces[0]):
|
||||
bpy.ops.bim.measure_tool("INVOKE_DEFAULT", measure_type="POLYLINE")
|
||||
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y)
|
||||
yield from preset_event_simulate(window, "LEFTMOUSE", "TAP", x, y)
|
||||
snap_point = tool.Model.get_polyline_props().snap_mouse_point[0]
|
||||
assert_msg = "First click should have a snap_object"
|
||||
assert snap_point.snap_object, assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "snap_object should be a string with the object name"
|
||||
assert type(snap_point.snap_object) == str, assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "snap_type should be 'Vertex'"
|
||||
assert snap_point.snap_type == "Vertex", assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "x should be 1000000"
|
||||
assert round(snap_point.x, 3) == 1000.0, assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "y should be 1000000"
|
||||
assert round(snap_point.y, 3) == 1000.0, assert_msg
|
||||
_assert_pass(assert_msg)
|
||||
|
||||
yield from preset_event_simulate(window, "RET", "TAP", x, y)
|
||||
yield "FINISHED"
|
||||
|
||||
def test_draw_polyline_wall(window, x, y):
|
||||
yield from preset_event_simulate(window, "ESC", "TAP", x, y)
|
||||
area, region = get_area_and_region(window)
|
||||
|
||||
for obj in tool.Blender.get_selected_objects():
|
||||
obj.select_set(False)
|
||||
with bpy.context.temp_override(area=area, region=region, space_data=area.spaces[0]):
|
||||
props = tool.Model.get_model_props()
|
||||
ifc = tool.Ifc.get()
|
||||
relating_type = ifc.by_type("IfcWallType")[0]
|
||||
|
||||
if tool.Model.get_usage_type(relating_type) == "LAYER2":
|
||||
props.ifc_class = "IfcWallType"
|
||||
props.relating_type_id = str(relating_type.id())
|
||||
|
||||
bpy.ops.bim.draw_polyline_wall("INVOKE_DEFAULT")
|
||||
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y)
|
||||
yield from preset_event_simulate(window, "LEFTMOUSE", "TAP", x, y)
|
||||
yield from preset_event_simulate(window, "X", "TAP", x, y)
|
||||
|
||||
offset = 200
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x + offset, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x + offset, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x + offset, y)
|
||||
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x + offset, y)
|
||||
yield from preset_event_simulate(window, "LEFTMOUSE", "TAP", x + offset, y)
|
||||
|
||||
yield from preset_event_simulate(window, "RET", "TAP", x, y)
|
||||
element = tool.Ifc.get_entity(bpy.context.selected_objects[0])
|
||||
|
||||
assert_msg = "Created object should be IfcWall"
|
||||
assert element.is_a() == "IfcWall"
|
||||
_assert_pass(assert_msg)
|
||||
assert_msg = "Created object should be typed by IfcWallType"
|
||||
assert ifcopenshell.util.element.get_type(element).is_a() == "IfcWallType"
|
||||
_assert_pass(assert_msg)
|
||||
# TODO Asset the axis has the same X value
|
||||
|
||||
yield "FINISHED"
|
||||
|
||||
|
||||
def run_tests():
|
||||
module_name = os.getenv("MODULE", "snap")
|
||||
if module_name == "wall":
|
||||
filepath = f"./test/files/wall.ifc"
|
||||
bpy.ops.bim.load_project(filepath=filepath)
|
||||
window = _get_valid_window()
|
||||
test_queue = [lambda w=window: test_draw_polyline_wall(w, 960, 540)]
|
||||
elif module_name == "snap":
|
||||
filepath = f"./test/files/snap.ifc"
|
||||
bpy.ops.bim.load_project(filepath=filepath)
|
||||
window = _get_valid_window()
|
||||
test_queue = [
|
||||
lambda w=window: test_snap_object_detection(w),
|
||||
lambda w=window: test_snap_partially_behind_camera(w),
|
||||
lambda w=window: test_snap_in_xray_mode(w),
|
||||
lambda w=window: test_snap_far_from_origin(w),
|
||||
]
|
||||
else:
|
||||
cleanup()
|
||||
|
||||
def _next():
|
||||
if not test_queue:
|
||||
cleanup()
|
||||
return
|
||||
test_fn = test_queue.pop(0)
|
||||
# use the shared timer infrastructure
|
||||
run_iter_from_timer(
|
||||
test_fn(),
|
||||
on_complete=_next,
|
||||
on_error=lambda e: _handle_error(e, _next),
|
||||
)
|
||||
|
||||
_next()
|
||||
|
||||
if __name__ == "__main__":
|
||||
new_project()
|
||||
run_tests()
|
||||
@@ -17,20 +17,20 @@
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import test.bim.bootstrap
|
||||
import ifcopenshell.api.cost
|
||||
|
||||
import bonsai.core.tool
|
||||
import bonsai.tool as tool
|
||||
import test.bim.bootstrap
|
||||
from bonsai.tool.cost import Cost as subject
|
||||
from test.bim.bootstrap import NewFile
|
||||
|
||||
from bonsai.tool.cost import Cost as subject
|
||||
|
||||
class TestImplementsTool(NewFile):
|
||||
def test_run(self):
|
||||
assert isinstance(subject(), bonsai.core.tool.Cost)
|
||||
|
||||
|
||||
class TestDisableEditingCostItemParent(NewFile):
|
||||
def test_avoid_recursion_error(newfile, monkeypatch):
|
||||
class DummyProps:
|
||||
@@ -39,11 +39,7 @@ class TestDisableEditingCostItemParent(NewFile):
|
||||
self.active_cost_item_id = 5
|
||||
|
||||
props = DummyProps()
|
||||
monkeypatch.setattr(
|
||||
"bonsai.tool.Cost.get_cost_props",
|
||||
lambda: props
|
||||
)
|
||||
monkeypatch.setattr("bonsai.tool.Cost.get_cost_props", lambda: props)
|
||||
subject.disable_editing_cost_item_parent()
|
||||
assert props.active_cost_item_id == 0
|
||||
assert props.change_cost_item_parent is not False
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
|
||||
|
||||
|
||||
#let bill_of_quantities_table = table(
|
||||
#let bill_of_quantities_table(currency: "") = table(
|
||||
columns: (18mm,54mm, 12mm,12mm,12mm,12mm, 20mm, 20mm, 25mm),
|
||||
rows: (6mm, 248mm),
|
||||
align: (center, left, center, center, center, center, center, center, center),
|
||||
@@ -36,12 +36,12 @@
|
||||
top: 1pt,
|
||||
bottom: 1pt
|
||||
),
|
||||
[Hierarchy], [Description], [n°],[l],[w],[h/w], [Quantity], [Rate], [Total]
|
||||
[Hierarchy], [Description], [n°],[l],[w],[h/w], [Quantity], [Rate (#currency)], [Total (#currency)]
|
||||
)
|
||||
|
||||
|
||||
|
||||
#let schedule_of_rates_table = table(
|
||||
#let schedule_of_rates_table(currency: "") = table(
|
||||
columns: (30mm,130mm, 25mm),
|
||||
rows: (6mm, 248mm),
|
||||
align: (center, left, center),
|
||||
@@ -51,12 +51,12 @@
|
||||
top: 1pt,
|
||||
bottom: 1pt
|
||||
),
|
||||
[Identification], [Description], [Rate]
|
||||
[Identification], [Description], [Rate (#currency)]
|
||||
)
|
||||
|
||||
|
||||
|
||||
#let summary_table = table(
|
||||
#let summary_table(currency: "") = table(
|
||||
columns: (18mm,107mm, 30mm, 30mm),
|
||||
rows: (6mm, 248mm),
|
||||
align: (center, left, center, center, center, center, center, center, center),
|
||||
@@ -67,9 +67,9 @@
|
||||
bottom: 1pt
|
||||
),
|
||||
text(size: 8pt)[Hierarchy],
|
||||
text(size: 8pt)[Description],
|
||||
text(size: 8pt)[Sub Total],
|
||||
text(size: 8pt)[Total]
|
||||
text(size: 8pt)[Description],
|
||||
text(size: 8pt)[Sub Total (#currency)],
|
||||
text(size: 8pt)[Total (#currency)]
|
||||
)
|
||||
|
||||
|
||||
@@ -127,7 +127,6 @@
|
||||
#let arrange_summary_row(row, options) = {
|
||||
let name = strong(upper(row.at("Name")))
|
||||
let description = [#par(justify: true, text(8pt, row.at("Description", default: "")))]
|
||||
let total = if row.at("RateSubtotal") == "" {0.0} else {float(row.at("RateSubtotal"))}
|
||||
if row.at("ItemIsASum") == "True" {
|
||||
if row.at("Index") == "1" {
|
||||
// ROOT COST
|
||||
@@ -216,7 +215,8 @@
|
||||
format-decimal(float(row.at("Quantity")))}
|
||||
let rate = if row.at("RateSubtotal") == "" {0.0} else {
|
||||
format-decimal(float(row.at("RateSubtotal")))}
|
||||
let total = if row.at("Quantity") == "" {0.0} else {
|
||||
let total = if row.at("Quantity") == "" or row.at("RateSubtotal") == "" {
|
||||
format-decimal(0.0, places: 2)} else {
|
||||
format-decimal(float(row.at("Quantity")) * float(row.at("RateSubtotal")), places: 2)}
|
||||
|
||||
(
|
||||
@@ -281,18 +281,18 @@
|
||||
#let arrange_schedule_of_rates_row(row, options) = {
|
||||
let name = strong(upper(row.at("Name")))
|
||||
let description = [#par(justify: true, text(8pt, row.at("Description", default: "")))]
|
||||
let unit = table.cell(align: right)[#unit_map.at(row.at("Unit"), default: "")]
|
||||
let unit = table.cell(align: right + bottom)[#unit_map.at(row.at("Unit"), default: "")]
|
||||
let rate = if row.at("RateSubtotal") == "" {0.0} else {
|
||||
format-decimal(float(row.at("RateSubtotal")))}
|
||||
if row.at("ItemIsASum") == "True" {return ()} //skip sections in schedule of rates
|
||||
(
|
||||
row.at("Identification"),
|
||||
if row.at("Identification") == "" {name + linebreak() + description} else {name + linebreak() + description},
|
||||
name + linebreak() + description,
|
||||
[]
|
||||
)
|
||||
(
|
||||
[],
|
||||
table.cell(align: right+bottom)[#unit],
|
||||
unit,
|
||||
table.cell(align: right+bottom)[#rate],
|
||||
)
|
||||
(
|
||||
@@ -342,8 +342,12 @@
|
||||
) = {
|
||||
let data = csv(path, delimiter: delimiter, row-type: dictionary)
|
||||
let new_rows = data.map(item => arrange_summary_row(item, options))
|
||||
let general_total = data.filter(row => row.at("ItemIsASum") == "False")
|
||||
.map(row => float(row.at("RateSubtotal", default: 0.0))*float(row.at("Quantity", default: 0.0)))
|
||||
let general_total = data.filter(row => row.at("ItemIsASum") == "False")
|
||||
.map(row => {
|
||||
let qty = if row.at("Quantity", default: "") == "" { 0.0 } else { float(row.at("Quantity")) }
|
||||
let rate = if row.at("RateSubtotal", default: "") == "" { 0.0 } else { float(row.at("RateSubtotal")) }
|
||||
qty * rate
|
||||
})
|
||||
.sum(default: 0.00)
|
||||
|
||||
set text(size: 10pt)
|
||||
@@ -477,9 +481,9 @@
|
||||
[#counter(page).display("1/1", both: true)]
|
||||
)
|
||||
],
|
||||
background:
|
||||
background:
|
||||
place( top + left, dx: 15mm, dy: 25mm,
|
||||
format_table.at(schedule_type, default: bill_of_quantities_table)
|
||||
(format_table.at(schedule_type, default: bill_of_quantities_table))(currency: project_currency)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -522,9 +526,9 @@
|
||||
set page(
|
||||
background:
|
||||
place( top + left, dx: 15mm, dy: 25mm,
|
||||
format_table.at("SUMMARY")
|
||||
(format_table.at("SUMMARY"))(currency: project_currency)
|
||||
)
|
||||
)
|
||||
create-summary(schedule_path, options)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ each JSON object. The model is opened once and saved once regardless of how
|
||||
many elements are processed.
|
||||
|
||||
```bash
|
||||
ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product {id}
|
||||
ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product '{id}'
|
||||
```
|
||||
|
||||
```json
|
||||
@@ -201,7 +201,7 @@ Placeholder tokens match the fields emitted by `ifcquery` — typically `{id}`,
|
||||
|
||||
```bash
|
||||
ifcquery model.ifc select 'IfcDoor' | ifcedit foreach model.ifc attribute.edit_attributes \
|
||||
--product {id} --attributes '{"Name": "Door"}'
|
||||
--product '{id}' --attributes '{"Name": "Door"}'
|
||||
```
|
||||
|
||||
**Options:**
|
||||
@@ -297,7 +297,7 @@ ifcedit run model.ifc spatial.unassign_container \
|
||||
--products "$(ifcquery model.ifc --format ids select 'IfcWall')"
|
||||
|
||||
# Fan-out — one operation per element, model opened and saved once
|
||||
ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product {id}
|
||||
ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product '{id}'
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
#include <stack>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <cstdint>
|
||||
#include <BRepExtrema_TriangleSet.hxx>
|
||||
#include <BRepLProp_SLProps.hxx>
|
||||
#include <BVH_BinaryTree.hxx>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "clash_utils.h"
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
#include <cfloat>
|
||||
|
||||
#define GU_CULLING_EPSILON_RAY_TRIANGLE FLT_EPSILON*FLT_EPSILON
|
||||
#define PX_MAX_F32 3.4028234663852885981170418348452e+38F
|
||||
|
||||
@@ -300,7 +300,11 @@ bool OpenCascadeKernel::convert(const taxonomy::sweep_along_curve::ptr scs, Topo
|
||||
|
||||
if (applied_temporary_offset) {
|
||||
gp_Trsf trsf;
|
||||
trsf.SetTranslation(gp_Vec(-mean.x(), -mean.y(), -mean.z()));
|
||||
// Restore original position: add back the mean subtracted from the
|
||||
// directrix points above. Previously negated, which placed the swept
|
||||
// solid at -mean instead of its original location for geometry far
|
||||
// from the origin.
|
||||
trsf.SetTranslation(gp_Vec(mean.x(), mean.y(), mean.z()));
|
||||
result.Move(trsf);
|
||||
}
|
||||
|
||||
|
||||
@@ -133,23 +133,21 @@ struct spiral_parent_curve : public parent_curve_function {
|
||||
|
||||
// this is the piecewise curve segment function for horizontal and vertical
|
||||
struct curve_segment_function {
|
||||
curve_segment_function(const Eigen::Matrix4d& curve_segment_placement, const Eigen::Matrix4d& remove_parent_curve_rotation, const Eigen::Matrix4d& remove_parent_curve_translation, std::shared_ptr<parent_curve_function> parent_curve_fn) :
|
||||
curve_segment_function(const Eigen::Matrix4d& curve_segment_placement, const Eigen::Matrix4d& parent_curve_normalization, std::shared_ptr<parent_curve_function> parent_curve_fn) :
|
||||
curve_segment_placement_(curve_segment_placement),
|
||||
remove_parent_curve_rotation_(remove_parent_curve_rotation),
|
||||
remove_parent_curve_translation_(remove_parent_curve_translation),
|
||||
parent_curve_normalization_(parent_curve_normalization),
|
||||
parent_curve_fn_(parent_curve_fn) {
|
||||
}
|
||||
|
||||
Eigen::Matrix4d operator()(double u) const {
|
||||
Eigen::Matrix4d parent_curve_point = (*parent_curve_fn_)(u);
|
||||
Eigen::Matrix4d curve_segment_point = curve_segment_placement_ * remove_parent_curve_rotation_ * remove_parent_curve_translation_ * parent_curve_point;
|
||||
Eigen::Matrix4d curve_segment_point = curve_segment_placement_ * parent_curve_normalization_ * parent_curve_point;
|
||||
return curve_segment_point + parent_curve_fn_->curvature(u);
|
||||
}
|
||||
|
||||
private:
|
||||
Eigen::Matrix4d curve_segment_placement_;
|
||||
Eigen::Matrix4d remove_parent_curve_rotation_;
|
||||
Eigen::Matrix4d remove_parent_curve_translation_;
|
||||
Eigen::Matrix4d parent_curve_normalization_;
|
||||
std::shared_ptr<parent_curve_function> parent_curve_fn_;
|
||||
};
|
||||
|
||||
@@ -166,9 +164,46 @@ struct cant_curve_segment_function {
|
||||
// Subtract the parent_curve_start_point to get the incremental cant rotation and superelevation
|
||||
// Add the incremental cant rotation and superelevation to curve_segment_placement to get the curve_segment_point
|
||||
Eigen::Matrix4d parent_curve_point = (*parent_curve_fn_)(u);
|
||||
Eigen::Matrix4d cant_increment = parent_curve_point - parent_curve_start_point_;
|
||||
Eigen::Matrix4d curve_segment_point = curve_segment_placement_ + cant_increment;
|
||||
|
||||
Eigen::Matrix3d parent_curve_start_rotation_ = parent_curve_start_point_.block<3, 3>(0, 0);
|
||||
Eigen::Matrix3d parent_curve_point_rotation = parent_curve_point.block<3, 3>(0, 0);
|
||||
Eigen::Matrix3d incremental_rotation = parent_curve_point_rotation * parent_curve_start_rotation_.transpose();
|
||||
Eigen::Matrix3d placement_rotation_ = curve_segment_placement_.block<3, 3>(0, 0);
|
||||
Eigen::Matrix3d curve_segment_rotation = incremental_rotation * placement_rotation_;
|
||||
|
||||
Eigen::Vector3d parent_curve_start_translation_ = parent_curve_start_point_.block<3, 1>(0, 3);
|
||||
Eigen::Vector3d parent_curve_point_translation = parent_curve_point.block<3, 1>(0, 3);
|
||||
Eigen::Vector3d incremental_translation = parent_curve_point_translation - parent_curve_start_translation_;
|
||||
Eigen::Vector3d placement_translation_ = curve_segment_placement_.block<3, 1>(0, 3);
|
||||
Eigen::Vector3d curve_segment_translation = incremental_translation + placement_translation_;
|
||||
|
||||
Eigen::Matrix4d curve_segment_point = Eigen::Matrix4d::Identity();
|
||||
curve_segment_point.block<3, 3>(0, 0) = curve_segment_rotation;
|
||||
curve_segment_point.block<3, 1>(0, 3) = curve_segment_translation;
|
||||
|
||||
//if (0.0 < u) {
|
||||
// Eigen::IOFormat latexFormat(
|
||||
// Eigen::FullPrecision, // full precision
|
||||
// 0, // no alignment flags
|
||||
// " & ", // coeff separator
|
||||
// " \\\\ \n", // row separator
|
||||
// "", // row prefix
|
||||
// "", // row suffix
|
||||
// "", // matrix prefix
|
||||
// "" // matrix suffix
|
||||
// );
|
||||
// std::cout << "Placement (M_CSP)" << std::endl;
|
||||
// std::cout << curve_segment_placement_.format(latexFormat) << std::endl;
|
||||
// std::cout << "Parent curve start point (M_PCS)" << std::endl;
|
||||
// std::cout << parent_curve_start_point_.format(latexFormat) << std::endl;
|
||||
// std::cout << "Parent curve point (M_PCl)" << std::endl;
|
||||
// std::cout << parent_curve_point.format(latexFormat) << std::endl;
|
||||
// std::cout << "Curve segment point (M_c)" << std::endl;
|
||||
// std::cout << curve_segment_point.format(latexFormat) << std::endl;
|
||||
//}
|
||||
|
||||
return curve_segment_point + parent_curve_fn_->curvature(u);
|
||||
|
||||
}
|
||||
|
||||
private:
|
||||
@@ -318,33 +353,26 @@ class curve_segment_evaluator {
|
||||
return taxonomy::make<taxonomy::functor_item>(length, fn);
|
||||
} else {
|
||||
// The parent curve function returns the 4x4 matrix for the parent curve.
|
||||
// Subtract the parent curve start point (remove the translation and rotation)
|
||||
// to get the incremental translation and rotation. Apply the incremental
|
||||
// Normalize the parent curve so that the trim start point and tangent direction at the start point
|
||||
// are aligned with the origin. This is accomplished with a normalization matrix that subtracts the
|
||||
// incremental parent curve start point and applies a rotation. Apply the incremental
|
||||
// translation and rotation to the curve_segment_placement to get the curve_segment_point
|
||||
|
||||
// Do a negative translation of the parent curve point relative to the start of the parent curve.
|
||||
// This moves parent_curve_fn(u=0.0) to coordinate (0,0).
|
||||
// This is done so the curve_segment_placement is applied relative to (0,0)
|
||||
Eigen::Matrix4d remove_parent_curve_translation = Eigen::Matrix4d::Identity();
|
||||
remove_parent_curve_translation.col(3) = -1.0 * (*parent_curve_start_point_).col(3);
|
||||
remove_parent_curve_translation(3, 3) = 1.0;
|
||||
auto rotation = (*parent_curve_start_point_).block<3, 3>(0, 0);
|
||||
auto dxo = rotation(0, 0);
|
||||
auto dyo = rotation(1, 0);
|
||||
rotation(0, 1) *= -1.0;
|
||||
rotation(1, 0) *= -1.0;
|
||||
auto xo = (*parent_curve_start_point_)(0, 3);
|
||||
auto yo = (*parent_curve_start_point_)(1, 3);
|
||||
auto xn = -xo*dxo - yo*dyo;
|
||||
auto yn = xo*dyo - yo*dxo;
|
||||
Eigen::Matrix4d parent_curve_normalization = Eigen::Matrix4d::Identity();
|
||||
parent_curve_normalization.block<3, 3>(0, 0) = rotation;
|
||||
parent_curve_normalization(0, 3) = xn;
|
||||
parent_curve_normalization(1, 3) = yn;
|
||||
|
||||
// Do a rotation so that the tangent of the parent curve is in the direction (1,0)
|
||||
// Example: if the parent curve IfcLine is at a 30 degree clockwise angle, this does
|
||||
// a 30 degree counter-clockwise rotation
|
||||
// Clockwise rotation matrix = [cos(angle) -sin(angle)]
|
||||
// [sin(angle) cos(angle)]
|
||||
//
|
||||
// Counter-clockwise rotation = [ cos(angle) sin(angle)]
|
||||
// [-sin(angle) cos(angle)]
|
||||
//
|
||||
// That's just a sign flip in positions (0,1) and (1,0)
|
||||
Eigen::Matrix4d remove_parent_curve_rotation = (*parent_curve_start_point_);
|
||||
remove_parent_curve_rotation(0, 1) *= -1.0;
|
||||
remove_parent_curve_rotation(1, 0) *= -1.0;
|
||||
remove_parent_curve_rotation.col(3) = Eigen::Vector4d(0, 0, 0, 1); // remove the parent curve placement point
|
||||
|
||||
auto fn = curve_segment_function(*curve_segment_placement_, remove_parent_curve_rotation, remove_parent_curve_translation, parent_curve_fn_);
|
||||
auto fn = curve_segment_function(*curve_segment_placement_, parent_curve_normalization, parent_curve_fn_);
|
||||
return taxonomy::make<taxonomy::functor_item>(length, fn);
|
||||
}
|
||||
}
|
||||
@@ -495,11 +523,10 @@ class curve_segment_evaluator {
|
||||
// tilt angle in the plane of the cross section
|
||||
auto cant = Cant(u);
|
||||
auto tilt_angle = start_angle + delta_angle * (cant - start_cant) / delta_cant;
|
||||
Eigen::Vector4d z(0.0, cos(tilt_angle), sin(tilt_angle), 0.0);
|
||||
Eigen::Vector4d axis(0.0, cos(tilt_angle), sin(tilt_angle), 0.0);
|
||||
|
||||
// compute axis direction
|
||||
Eigen::Vector4d y = z.cross3(ref_dir);
|
||||
Eigen::Vector4d axis = ref_dir.cross3(y);
|
||||
// compute cross slope direction
|
||||
Eigen::Vector4d y = axis.cross3(ref_dir);
|
||||
|
||||
Eigen::Matrix4d m = Eigen::Matrix4d::Identity();
|
||||
m.col(0) = ref_dir;
|
||||
@@ -831,6 +858,8 @@ class curve_segment_evaluator {
|
||||
auto R = c->Radius() * length_unit_;
|
||||
auto parent_curve_position = taxonomy::cast<taxonomy::matrix4>(mapping_->map(c->Position()))->ccomponents();
|
||||
|
||||
auto sign_l = sign(length_);
|
||||
|
||||
// center point of the parent curve
|
||||
auto pcCenterX = parent_curve_position(0, 3);
|
||||
auto pcCenterY = parent_curve_position(1, 3);
|
||||
@@ -844,7 +873,8 @@ class curve_segment_evaluator {
|
||||
// angle from X = 0 to the first point on the trimmed curve
|
||||
auto start_angle = pc_axis_angle + sweep_start_angle;
|
||||
|
||||
auto sign_l = sign(length_);
|
||||
auto pcStartX = pcCenterX + R * cos(start_angle);
|
||||
auto pcStartY = pcCenterY + R * sin(start_angle);
|
||||
|
||||
projected_length_ = length_;
|
||||
|
||||
@@ -856,34 +886,27 @@ class curve_segment_evaluator {
|
||||
#ifdef SCHEMA_IfcCurveSegment_HAS_Placement
|
||||
curve_segment_placement = taxonomy::cast<taxonomy::matrix4>(mapping_->map(inst_->Placement()))->ccomponents();
|
||||
#endif
|
||||
auto csStartX = curve_segment_placement(0, 3);
|
||||
auto csStartY = curve_segment_placement(1, 3);
|
||||
auto csStartDx = curve_segment_placement(0, 0);
|
||||
auto csStartDy = curve_segment_placement(1, 0);
|
||||
auto csCenterX = csStartX - sign_l * csStartDy * R;
|
||||
auto csCenterY = csStartY + sign_l * csStartDx * R;
|
||||
|
||||
// determine projected length along the x-axis
|
||||
auto subtended_angle = R ? length_ / R : 0.0;
|
||||
auto end_angle = start_angle + subtended_angle;
|
||||
auto csEndX = csCenterX + R * cos(end_angle);
|
||||
projected_length_ = csEndX - csStartX;
|
||||
auto pcEndX = pcCenterX + R * cos(end_angle);
|
||||
projected_length_ = pcEndX - pcStartX;
|
||||
|
||||
convert_u = [csStartX, csStartY, csCenterX, csCenterY, R, sign_l](double u) {
|
||||
convert_u = [pcStartX, pcStartY, pcCenterX, pcCenterY, R, sign_l](double u) {
|
||||
// for vertical, u is measured along the horizonal but we need it to be an arc length
|
||||
|
||||
// x and y are coordinates on the curve segment for horizontal distance u from the start point
|
||||
// u is a horizontal distance so x = csStartX + u
|
||||
// Recognizing the triangle
|
||||
// R^2 = (u + csStartX - csCenterX)^2 + (y - csCenterY)^2
|
||||
// R^2 = (u + pcStartX - pcCenterX)^2 + (y - pcCenterY)^2
|
||||
// solve for y
|
||||
// (y - csCenterY) = sqrt( R^2 - (u + csStartX - csCenterX)^2 )
|
||||
// y = csCenterY + sqrt( R^2 - (u + csStartX - csCenterX)^2 )
|
||||
auto x = csStartX + u;
|
||||
auto y = csCenterY - sign_l * sqrt(pow(R, 2) - pow(u + csStartX - csCenterX, 2));
|
||||
// (y - pcCenterY) = sqrt( R^2 - (u + pcStartX - pcCenterX)^2 )
|
||||
// y = pcCenterY + sqrt( R^2 - (u + pcStartX - pcCenterX)^2 )
|
||||
auto x = pcStartX + u;
|
||||
auto y = pcCenterY - sign_l * sqrt(pow(R, 2) - pow(u + pcStartX - pcCenterX, 2));
|
||||
|
||||
// compute the chord distance between the start point and (x,y)
|
||||
auto c = sqrt(pow(x - csStartX, 2.0) + pow(y - csStartY, 2.0));
|
||||
auto c = sqrt(pow(x - pcStartX, 2.0) + pow(y - pcStartY, 2.0));
|
||||
|
||||
// compute the subtended angle
|
||||
// c = 2R*sin(delta/2)
|
||||
@@ -986,18 +1009,18 @@ class curve_segment_evaluator {
|
||||
double m_squared = std::inner_product(dr.begin(), dr.end(), dr.begin(), 0.0);
|
||||
double m = sqrt(m_squared);
|
||||
std::transform(dr.begin(), dr.end(), dr.begin(), [m](auto& d) { return d / m; });
|
||||
auto pcDx = dr[0];
|
||||
auto pcDy = dr[1];
|
||||
auto pcDXx = dr[0];
|
||||
auto pcDXy = dr[1];
|
||||
|
||||
if (segment_type_ == ST_VERTICAL && curve_segment_placement_) {
|
||||
// the general algorithm for mapping parent curve onto curve segment doesn't
|
||||
// exactly work for IfcLine. This is easily overcome by using the curve segment
|
||||
// placement for the IfcLine direction
|
||||
pcDx = (*curve_segment_placement_)(0, 0);
|
||||
pcDy = (*curve_segment_placement_)(1, 0);
|
||||
pcDXx = (*curve_segment_placement_)(0, 0);
|
||||
pcDXy = (*curve_segment_placement_)(1, 0);
|
||||
|
||||
// projected length along the x-axis is the 'i' component of the total length
|
||||
projected_length_ = length_ * pcDx;
|
||||
projected_length_ = length_ * pcDXx;
|
||||
}
|
||||
|
||||
if (segment_type_ == ST_HORIZONTAL || segment_type_ == ST_VERTICAL || segment_type_ == ST_CANT) {
|
||||
@@ -1006,19 +1029,32 @@ class curve_segment_evaluator {
|
||||
convert_u = [](double u) { return u; }; // u is along curve
|
||||
} else {
|
||||
// u is along horizontal, convert to along curve
|
||||
convert_u = [pcDx](double u) { return u/pcDx; };
|
||||
convert_u = [pcDXx](double u) { return u/pcDXx; };
|
||||
}
|
||||
|
||||
auto pcDZy = curve_segment_placement_ ? (*curve_segment_placement_)(1, 2) : 0.;
|
||||
auto pcDZz = curve_segment_placement_ ? (*curve_segment_placement_)(2, 2) : 1.;
|
||||
|
||||
parent_curve_fn_ = std::make_shared<line_parent_curve>(
|
||||
[pcX, pcY, pcDx, pcDy, convert_u](double u)->Eigen::Matrix4d {
|
||||
[segment_type = segment_type_,pcX, pcY, pcDXx, pcDXy, pcDZy, pcDZz, convert_u](double u)->Eigen::Matrix4d {
|
||||
u = convert_u(u);
|
||||
|
||||
auto x = pcX + pcDx * u;
|
||||
auto y = pcY + pcDy * u;
|
||||
auto x = pcX + pcDXx * u;
|
||||
auto y = pcY + pcDXy * u;
|
||||
|
||||
Eigen::Matrix4d m = Eigen::Matrix4d::Identity();
|
||||
m.col(0) = Eigen::Vector4d(pcDx, pcDy, 0, 0);
|
||||
m.col(1) = Eigen::Vector4d(-pcDy, pcDx, 0, 0);
|
||||
Eigen::Vector3d X(pcDXx, pcDXy, 0);
|
||||
Eigen::Vector3d Z(0, 0, 1);
|
||||
|
||||
if (segment_type == ST_CANT) {
|
||||
Z = Eigen::Vector3d(0, pcDZy, pcDZz);
|
||||
}
|
||||
|
||||
Eigen::Vector3d Y = Z.cross(X).normalized();
|
||||
|
||||
m.col(0) = Eigen::Vector4d(X[0], X[1], X[2], 0);
|
||||
m.col(1) = Eigen::Vector4d(Y[0], Y[1], Y[2], 0);
|
||||
m.col(2) = Eigen::Vector4d(Z[0], Z[1], Z[2], 0);
|
||||
m.col(3) = Eigen::Vector4d(x, y, 0.0, 1.0);
|
||||
return m;
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "../../ifcparse/IfcLogger.h"
|
||||
|
||||
#include <mutex>
|
||||
#include <cstdint>
|
||||
|
||||
#define INCLUDE_SCHEMA(x) STRINGIFY(../../ifcparse/x.h)
|
||||
#include INCLUDE_SCHEMA(IfcSchema)
|
||||
|
||||
+14
-7
@@ -17,6 +17,13 @@
|
||||
#include <tuple>
|
||||
#include <exception>
|
||||
#include <numeric>
|
||||
#include <cstdint>
|
||||
#include <cmath>
|
||||
#include <array>
|
||||
#include <limits>
|
||||
#include <functional>
|
||||
#include <algorithm>
|
||||
#include <stdexcept>
|
||||
|
||||
#ifndef TAXONOMY_USE_UNIQUE_PTR
|
||||
#ifndef TAXONOMY_USE_NAKED_PTR
|
||||
@@ -1625,19 +1632,19 @@ typedef item const* ptr;
|
||||
// @todo Sad... now that we have templated collection members,
|
||||
// we can't generally use collection_base anymore as a cast target.
|
||||
if (auto s = std::dynamic_pointer_cast<taxonomy::collection>(i)) {
|
||||
visit<taxonomy::collection>(s, fn);
|
||||
ifcopenshell::geometry::visit<taxonomy::collection>(s, fn);
|
||||
} else if (auto s = std::dynamic_pointer_cast<taxonomy::loop>(i)) {
|
||||
visit<taxonomy::loop>(s, fn);
|
||||
ifcopenshell::geometry::visit<taxonomy::loop>(s, fn);
|
||||
} else if (auto s = std::dynamic_pointer_cast<taxonomy::face>(i)) {
|
||||
visit<taxonomy::face>(s, fn);
|
||||
ifcopenshell::geometry::visit<taxonomy::face>(s, fn);
|
||||
} else if (auto s = std::dynamic_pointer_cast<taxonomy::shell>(i)) {
|
||||
visit<taxonomy::shell>(s, fn);
|
||||
ifcopenshell::geometry::visit<taxonomy::shell>(s, fn);
|
||||
} else if (auto s = std::dynamic_pointer_cast<taxonomy::solid>(i)) {
|
||||
visit<taxonomy::solid>(s, fn);
|
||||
ifcopenshell::geometry::visit<taxonomy::solid>(s, fn);
|
||||
} else if (auto s = std::dynamic_pointer_cast<taxonomy::loft>(i)) {
|
||||
visit<taxonomy::loft>(s, fn);
|
||||
ifcopenshell::geometry::visit<taxonomy::loft>(s, fn);
|
||||
} else if (auto s = std::dynamic_pointer_cast<taxonomy::boolean_result>(i)) {
|
||||
visit<taxonomy::boolean_result>(s, fn);
|
||||
ifcopenshell::geometry::visit<taxonomy::boolean_result>(s, fn);
|
||||
}
|
||||
else {
|
||||
fn(i);
|
||||
|
||||
@@ -57,13 +57,13 @@ Dry-run to validate without modifying the file::
|
||||
Apply an API function to each element in a JSON array from stdin (``{field}``
|
||||
placeholders are substituted from each item; model is opened and saved once)::
|
||||
|
||||
$ ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product {id}
|
||||
$ ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product '{id}'
|
||||
$ ifcquery model.ifc select 'IfcDoor' | ifcedit foreach model.ifc attribute.edit_attributes \
|
||||
--product {id} --attributes '{"Name": "Door"}'
|
||||
--product '{id}' --attributes '{"Name": "Door"}'
|
||||
|
||||
Write to a separate output file instead of overwriting::
|
||||
|
||||
$ ifcquery model.ifc select 'IfcWall' | ifcedit foreach model.ifc root.remove_product -o output.ifc --product {id}
|
||||
$ ifcquery model.ifc select 'IfcWall' | ifcedit foreach model.ifc root.remove_product -o output.ifc --product '{id}'
|
||||
|
||||
Quantity take-off (writes ``IfcElementQuantity`` psets back to the file; requires C++ geometry bindings)::
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ pass query results directly into ``ifcedit run`` parameters, or pipe JSON into
|
||||
--products "$(ifcquery model.ifc --format ids select 'IfcWall')"
|
||||
|
||||
# Fan-out — one operation per element, model opened and saved once
|
||||
$ ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product {id}
|
||||
$ ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product '{id}'
|
||||
|
||||
# Render an element highlighted against everything related to it
|
||||
$ ifcquery model.ifc render -o relations.png \
|
||||
|
||||
@@ -70,8 +70,10 @@ from .get_basis_curve import get_basis_curve
|
||||
from .get_cant_layout import get_cant_layout
|
||||
from .get_child_alignments import get_child_alignments
|
||||
from .get_curve import get_curve
|
||||
from .get_curve_segment import get_curve_segment
|
||||
from .get_curve_segment_transition_code import get_curve_segment_transition_code
|
||||
from .get_horizontal_layout import get_horizontal_layout
|
||||
from .get_layout import get_layout
|
||||
from .get_layout_curve import get_layout_curve
|
||||
from .get_layout_segments import get_layout_segments
|
||||
from .get_mapped_segments import get_mapped_segments
|
||||
@@ -86,6 +88,7 @@ from .layout_vertical_alignment_by_pi_method import (
|
||||
layout_vertical_alignment_by_pi_method,
|
||||
)
|
||||
from .name_segments import name_segments
|
||||
from .update_end_point import update_end_point
|
||||
from .update_fallback_position import update_fallback_position
|
||||
from .util import *
|
||||
|
||||
@@ -112,8 +115,10 @@ __all__ = [
|
||||
"get_cant_layout",
|
||||
"get_child_alignments",
|
||||
"get_curve",
|
||||
"get_curve_segment",
|
||||
"get_curve_segment_transition_code",
|
||||
"get_horizontal_layout",
|
||||
"get_layout",
|
||||
"get_layout_curve",
|
||||
"get_layout_segments",
|
||||
"get_parent_alignment",
|
||||
@@ -124,6 +129,7 @@ __all__ = [
|
||||
"layout_vertical_alignment_by_pi_method",
|
||||
"name_segments",
|
||||
"register_referent_name_callback",
|
||||
"update_end_point",
|
||||
"update_fallback_position",
|
||||
"get_mapped_segments",
|
||||
]
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from typing import Union
|
||||
import numpy as np
|
||||
|
||||
import ifcopenshell
|
||||
@@ -24,6 +25,9 @@ import ifcopenshell.geom
|
||||
import ifcopenshell.ifcopenshell_wrapper as ifcopenshell_wrapper
|
||||
import ifcopenshell.util.unit
|
||||
from ifcopenshell import entity_instance
|
||||
from ifcopenshell.api.alignment._get_segment_endpoint import _get_segment_endpoint
|
||||
from ifcopenshell.api.alignment._update_zero_length_segment_placement import _update_zero_length_segment_placement
|
||||
|
||||
from ifcopenshell.api.alignment._map_alignment_cant_segment import (
|
||||
_map_alignment_cant_segment,
|
||||
)
|
||||
@@ -39,11 +43,26 @@ from ifcopenshell.api.alignment._update_curve_segment_transition_code import (
|
||||
|
||||
|
||||
def _add_curve_segment_to_composite_curve(
|
||||
file: ifcopenshell.file, curve_segment: entity_instance, composite_curve: entity_instance
|
||||
):
|
||||
file: ifcopenshell.file,
|
||||
layout_segment: entity_instance,
|
||||
curve_segment: entity_instance,
|
||||
composite_curve: entity_instance,
|
||||
) -> Union[np.array, None]:
|
||||
"""
|
||||
Adds a curve segment to a composite curve and returns the end point of the added segment.
|
||||
|
||||
:param file: The IFC file
|
||||
:param layout_segment: The layout segment
|
||||
:param curve_segment: The curve segment to be added
|
||||
:param composite_curve: The composite curve to which the segment will be added
|
||||
:return: The end point of the added segment or None if an error occurs
|
||||
"""
|
||||
if 0 < len(curve_segment.UsingCurves):
|
||||
raise TypeError("IfcCurveSegment cannot belong to other curves")
|
||||
|
||||
prev_segment = None
|
||||
zero_length_segment = None
|
||||
|
||||
settings = ifcopenshell.geom.settings()
|
||||
if composite_curve.Segments == None or 0 == len(composite_curve.Segments):
|
||||
# this is the first segment so just add it
|
||||
@@ -56,22 +75,29 @@ def _add_curve_segment_to_composite_curve(
|
||||
composite_curve.Segments += (curve_segment,)
|
||||
assert len(curve_segment.UsingCurves) == 1
|
||||
else:
|
||||
# not the first segment, so get the zero_length segment (if it exists)
|
||||
zero_length_segment = (
|
||||
composite_curve.Segments[-1]
|
||||
if ifcopenshell.api.alignment.has_zero_length_segment(composite_curve)
|
||||
else None
|
||||
)
|
||||
prev_segment = None
|
||||
|
||||
# get the previous segment, which is either the on preceeding the zero length segment (if it exists) or
|
||||
# the last curve segment if there is no zero length segment.
|
||||
# This segment's transition code will need to be updated to match the new curve segment.
|
||||
if zero_length_segment and 1 < len(composite_curve.Segments):
|
||||
prev_segment = composite_curve.Segments[-2]
|
||||
elif zero_length_segment == None:
|
||||
prev_segment = composite_curve.Segments[-1]
|
||||
|
||||
curve_segment.Transition = "CONTINUOUS"
|
||||
# IfcCompositeCurve is supposed to be comprised of continuous segments
|
||||
curve_segment.Transition = "DISCONTINUOUS"
|
||||
|
||||
# get a list of all but the last segment (skips the zero length segment, if it exists)
|
||||
segments = composite_curve.Segments[0:-1]
|
||||
if zero_length_segment:
|
||||
# if there is a zero length segment, need to append new curve_segment and the zero length segment to the array
|
||||
# them update the composite curve segments with the new array
|
||||
segments += (
|
||||
curve_segment,
|
||||
zero_length_segment,
|
||||
@@ -79,31 +105,23 @@ def _add_curve_segment_to_composite_curve(
|
||||
composite_curve.Segments = []
|
||||
composite_curve.Segments += segments
|
||||
else:
|
||||
# if there is no zero length segment, we can just append the new curve segment to the existing array of segments
|
||||
composite_curve.Segments += (curve_segment,)
|
||||
|
||||
if prev_segment:
|
||||
_update_curve_segment_transition_code(prev_segment, curve_segment)
|
||||
if prev_segment:
|
||||
_update_curve_segment_transition_code(prev_segment, curve_segment)
|
||||
|
||||
if zero_length_segment:
|
||||
settings = ifcopenshell.geom.settings()
|
||||
segment_fn = ifcopenshell_wrapper.map_shape(settings, curve_segment.wrapped_data)
|
||||
segment_evaluator = ifcopenshell_wrapper.function_item_evaluator(settings, segment_fn)
|
||||
e = segment_evaluator.evaluate(segment_fn.end())
|
||||
end = np.array(e)
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file)
|
||||
x = float(end[0, 3]) / unit_scale
|
||||
y = float(end[1, 3]) / unit_scale
|
||||
dx = float(end[0, 0])
|
||||
dy = float(end[1, 0])
|
||||
end_point = _get_segment_endpoint(file, layout_segment)
|
||||
if zero_length_segment:
|
||||
_update_zero_length_segment_placement(file, zero_length_segment, end_point)
|
||||
_update_curve_segment_transition_code(curve_segment, zero_length_segment)
|
||||
|
||||
# assume IfcAxis2Placement2D
|
||||
zero_length_segment.Placement.Location.Coordinates = (x, y)
|
||||
zero_length_segment.Placement.RefDirection.DirectionRatios = (dx, dy)
|
||||
|
||||
_update_curve_segment_transition_code(curve_segment, zero_length_segment)
|
||||
return end_point
|
||||
|
||||
|
||||
def _add_segment_to_curve(file: ifcopenshell.file, segment: entity_instance, curve: entity_instance) -> None:
|
||||
def _add_segment_to_curve(
|
||||
file: ifcopenshell.file, layout_segment: entity_instance, curve: entity_instance
|
||||
) -> Union[np.array, None]:
|
||||
"""
|
||||
Creates an IfcCurveSegment from the IfcAlignmentSegment and adds it to the representation curve. The IfcCurveSegment is added
|
||||
at the end of the curve, but before the manditory zero length segment. The IfcCurveSegment.Transition for the segment
|
||||
@@ -114,16 +132,18 @@ def _add_segment_to_curve(file: ifcopenshell.file, segment: entity_instance, cur
|
||||
:return: None
|
||||
"""
|
||||
expected_types = ["IfcAlignmentSegment"]
|
||||
if not segment.is_a() in expected_types:
|
||||
if not layout_segment.is_a() in expected_types:
|
||||
raise TypeError(
|
||||
f"Expected entity type to be one of {[_ for _ in expected_types]}, instead received '{segment.is_a()}"
|
||||
f"Expected entity type to be one of {[_ for _ in expected_types]}, instead received '{layout_segment.is_a()}"
|
||||
)
|
||||
|
||||
if segment.DesignParameters.is_a("IfcAlignmentHorizontalSegment") and not curve.is_a("IfcCompositeCurve"):
|
||||
if layout_segment.DesignParameters.is_a("IfcAlignmentHorizontalSegment") and not curve.is_a("IfcCompositeCurve"):
|
||||
raise TypeError(f"Expected to see IfcCompositeCurve, instead received '{curve.is_a()}'.")
|
||||
elif segment.DesignParameters.is_a("IfcAlignmentVerticalSegment") and not curve.is_a("IfcGradientCurve"):
|
||||
elif layout_segment.DesignParameters.is_a("IfcAlignmentVerticalSegment") and not curve.is_a("IfcGradientCurve"):
|
||||
raise TypeError(f"Expected to see IfcGradientCurve, instead received '{curve.is_a()}'.")
|
||||
elif segment.DesignParameters.is_a("IfcAlignmentCantSegment") and not curve.is_a("IfcSegmentedReferenceCurve"):
|
||||
elif layout_segment.DesignParameters.is_a("IfcAlignmentCantSegment") and not curve.is_a(
|
||||
"IfcSegmentedReferenceCurve"
|
||||
):
|
||||
raise TypeError(f"Expected to see IfcSegmentedReferenceCurve, instead received '{curve.is_a()}'.")
|
||||
|
||||
expected_type = "IfcCompositeCurve"
|
||||
@@ -131,16 +151,18 @@ def _add_segment_to_curve(file: ifcopenshell.file, segment: entity_instance, cur
|
||||
raise TypeError(f"Expected to see {expected_type}, instead received {curve.is_a()}.")
|
||||
|
||||
# map the IfcAlignmentSegment to an IfcCurveSegment (or two in the case of helmert curves)
|
||||
if segment.DesignParameters.is_a("IfcAlignmentHorizontalSegment"):
|
||||
mapped_segments = _map_alignment_horizontal_segment(file, segment)
|
||||
elif segment.DesignParameters.is_a("IfcAlignmentVerticalSegment"):
|
||||
mapped_segments = _map_alignment_vertical_segment(file, segment)
|
||||
elif segment.DesignParameters.is_a("IfcAlignmentCantSegment"):
|
||||
cant_layout = segment.Nests[0].RelatingObject
|
||||
mapped_segments = _map_alignment_cant_segment(file, segment, cant_layout.RailHeadDistance)
|
||||
if layout_segment.DesignParameters.is_a("IfcAlignmentHorizontalSegment"):
|
||||
mapped_segments = _map_alignment_horizontal_segment(file, layout_segment)
|
||||
elif layout_segment.DesignParameters.is_a("IfcAlignmentVerticalSegment"):
|
||||
mapped_segments = _map_alignment_vertical_segment(file, layout_segment)
|
||||
elif layout_segment.DesignParameters.is_a("IfcAlignmentCantSegment"):
|
||||
cant_layout = layout_segment.Nests[0].RelatingObject
|
||||
mapped_segments = _map_alignment_cant_segment(file, layout_segment, cant_layout.RailHeadDistance)
|
||||
else:
|
||||
assert False
|
||||
|
||||
for mapped_segment in mapped_segments:
|
||||
if mapped_segment:
|
||||
_add_curve_segment_to_composite_curve(file, mapped_segment, curve)
|
||||
end_point = _add_curve_segment_to_composite_curve(file, layout_segment, mapped_segment, curve)
|
||||
|
||||
return end_point
|
||||
|
||||
@@ -16,12 +16,14 @@
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import math
|
||||
from typing import Union
|
||||
|
||||
import numpy as np
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.alignment
|
||||
from ifcopenshell.api.alignment import _map_alignment_cant_segment
|
||||
from ifcopenshell.api.alignment._update_zero_length_segment_placement import _update_zero_length_segment_placement
|
||||
import ifcopenshell.api.nest
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.geom
|
||||
@@ -29,15 +31,29 @@ import ifcopenshell.util.alignment
|
||||
import ifcopenshell.util.unit
|
||||
from ifcopenshell import entity_instance, ifcopenshell_wrapper
|
||||
from ifcopenshell.api.alignment._add_segment_to_curve import _add_segment_to_curve
|
||||
from ifcopenshell.api.alignment._get_segment_endpoint import _get_segment_endpoint
|
||||
from ifcopenshell.api.alignment._get_segment_start_point_label import (
|
||||
_get_segment_start_point_label,
|
||||
)
|
||||
from ifcopenshell.api.alignment._map_alignment_cant_segment import (
|
||||
_map_alignment_cant_segment,
|
||||
)
|
||||
from ifcopenshell.api.alignment._map_alignment_horizontal_segment import (
|
||||
_map_alignment_horizontal_segment,
|
||||
)
|
||||
from ifcopenshell.api.alignment._map_alignment_vertical_segment import (
|
||||
_map_alignment_vertical_segment,
|
||||
)
|
||||
|
||||
|
||||
def _add_segment_to_layout(file: ifcopenshell.file, layout: entity_instance, segment: entity_instance) -> None:
|
||||
def _add_segment_to_layout(
|
||||
file: ifcopenshell.file, layout: entity_instance, layout_segment: entity_instance
|
||||
) -> Union[np.array, None]:
|
||||
"""
|
||||
Adds an IfcAlignmentSegment to a layout alignment (IfcAlignmentHorizontal/Vertical/Cant). This segment is added at the end
|
||||
of the layout, before the manditory zero length segment. An IfcCurveSegment is created for the corresponding geometric representation.
|
||||
of the layout, before the manditory zero length segment (if it exists).
|
||||
If the layout has a corresponding geometric representation, an IfcCurveSegment is created for it and appended at the end
|
||||
of the representation curve, before the zero length segment (if it exists).
|
||||
|
||||
:param layout: The layout alignment
|
||||
:param segment: The segment to be appended
|
||||
@@ -50,160 +66,31 @@ def _add_segment_to_layout(file: ifcopenshell.file, layout: entity_instance, seg
|
||||
f"Expected entity type to be one of {[_ for _ in expected_types]}, instead received {layout.is_a()}"
|
||||
)
|
||||
|
||||
if not (segment.is_a("IfcAlignmentSegment")):
|
||||
raise TypeError(f"Expected to see IfcAlignmentSegment, instead received {segment.is_a()}.")
|
||||
|
||||
curve = ifcopenshell.api.alignment.get_layout_curve(layout)
|
||||
if not (layout_segment.is_a("IfcAlignmentSegment")):
|
||||
raise TypeError(f"Expected to see IfcAlignmentSegment, instead received {layout_segment.is_a()}.")
|
||||
|
||||
# add the new segment to the layout
|
||||
ifcopenshell.api.nest.assign_object(file, related_objects=[segment], relating_object=layout)
|
||||
ifcopenshell.api.nest.assign_object(file, related_objects=[layout_segment], relating_object=layout)
|
||||
|
||||
# segment is attached at the end, but this is after the zero length segment
|
||||
# swap the last two segments
|
||||
ifcopenshell.api.nest.reorder_nesting(file, segment, -1, -1)
|
||||
ifcopenshell.api.nest.reorder_nesting(file, layout_segment, -1, -1)
|
||||
|
||||
# For cant segments, the end point depends on the next segment. The next segment is the
|
||||
# zero-length segment and it hasn't been updated to match the end point.
|
||||
# For this reason, we can't compute the end point from the IfcCurveSegment, but instead we
|
||||
# compute it from the layout segment design parameters.
|
||||
end_point = _get_segment_endpoint(file, layout_segment)
|
||||
|
||||
# update the position of the zero length layout segment to be at the end point of the newly added segment
|
||||
segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(layout)
|
||||
zero_length_layout_segment = segment_nest.RelatedObjects[-1]
|
||||
_update_zero_length_segment_placement(file, zero_length_layout_segment, end_point)
|
||||
|
||||
# if there is a curve defined, add a new IfcCurveSegment to it.
|
||||
# _add_segment_to_curve maps the layout segment to the appropriate IfcCurveSegment type and adds it to the curve.
|
||||
curve = ifcopenshell.api.alignment.get_layout_curve(layout)
|
||||
if curve:
|
||||
# add the new segment to the geometric representation curve
|
||||
_add_segment_to_curve(file, segment, curve)
|
||||
_add_segment_to_curve(file, layout_segment, curve)
|
||||
|
||||
# gather information to:
|
||||
# (1) add a referent at the start of this segment
|
||||
# (2) update the name of the zero length segment's referent
|
||||
|
||||
# get the distance along the alignment to the start of the new segment
|
||||
dist_along = 0.0
|
||||
if layout.is_a("IfcAlignmentHorizontal"):
|
||||
for nest in layout.IsNestedBy:
|
||||
for seg in nest.RelatedObjects:
|
||||
if seg.is_a("IfcAlignmentSegment"):
|
||||
dist_along += seg.DesignParameters.SegmentLength
|
||||
|
||||
# the length of the current segment is in dist_along, so subtract it out
|
||||
dist_along -= segment.DesignParameters.SegmentLength
|
||||
else:
|
||||
dist_along = segment.DesignParameters.StartDistAlong
|
||||
|
||||
# get the station of the start of the segment
|
||||
alignment = ifcopenshell.api.alignment.get_alignment(layout)
|
||||
start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment)
|
||||
station = start_station + dist_along
|
||||
|
||||
# update the zero length layout segment
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file)
|
||||
|
||||
segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(layout)
|
||||
zero_length_segment = segment_nest.RelatedObjects[-1]
|
||||
mapped_segments = ifcopenshell.api.alignment.get_mapped_segments(segment)
|
||||
mapped_segment = mapped_segments[0] if mapped_segments[1] == None else mapped_segments[1]
|
||||
|
||||
# compute the end point matrix
|
||||
settings = ifcopenshell.geom.settings()
|
||||
segment_fn = ifcopenshell_wrapper.map_shape(settings, mapped_segment.wrapped_data)
|
||||
segment_evaluator = ifcopenshell_wrapper.function_item_evaluator(settings, segment_fn)
|
||||
e = segment_evaluator.evaluate(segment_fn.end())
|
||||
end = np.array(e)
|
||||
|
||||
# update the zero length segment semantic representation parameters
|
||||
if zero_length_segment.DesignParameters.is_a("IfcAlignmentHorizontalSegment"):
|
||||
x = float(end[0, 3]) / unit_scale
|
||||
y = float(end[1, 3]) / unit_scale
|
||||
dx = float(end[0, 0])
|
||||
dy = float(end[1, 0])
|
||||
zero_length_segment.DesignParameters.StartPoint.Coordinates = (x, y)
|
||||
zero_length_segment.DesignParameters.StartDirection = dy / dx
|
||||
elif zero_length_segment.DesignParameters.is_a("IfcAlignmentVerticalSegment"):
|
||||
y = float(end[1, 3]) / unit_scale
|
||||
zero_length_segment.DesignParameters.StartHeight = y
|
||||
dx = float(end[0, 0])
|
||||
dy = float(end[1, 0])
|
||||
zero_length_segment.DesignParameters.StartGradient = dy / dx
|
||||
zero_length_segment.DesignParameters.EndGradient = zero_length_segment.DesignParameters.StartGradient
|
||||
else:
|
||||
z = float(end[2, 3]) / unit_scale
|
||||
dx = float(end[0, 1])
|
||||
dy = float(end[1, 1])
|
||||
dz = float(end[2, 1])
|
||||
ds = math.sqrt(dx * dx + dy * dy)
|
||||
slope = dz / ds
|
||||
railhead = layout.RailHeadDistance
|
||||
|
||||
zero_length_segment.DesignParameters.StartCantLeft = z + slope * railhead / 2.0
|
||||
zero_length_segment.DesignParameters.StartCantRight = z - slope * railhead / 2.0
|
||||
|
||||
# updated the referent's name because the referent is now at a new station
|
||||
start_dist_along = 0.0
|
||||
if segment.DesignParameters.is_a("IfcAlignmentHorizontalSegment"):
|
||||
start_dist_along = dist_along + segment.DesignParameters.SegmentLength
|
||||
else:
|
||||
start_dist_along = segment.DesignParameters.StartDistAlong + segment.DesignParameters.HorizontalLength
|
||||
zero_length_segment.DesignParameters.StartDistAlong = start_dist_along
|
||||
|
||||
end_referent = zero_length_segment.PositionedRelativeTo[0].RelatingPositioningElement
|
||||
end_referent.Name = f"{_get_segment_start_point_label(zero_length_segment,None)} ({ifcopenshell.util.alignment.station_as_string(file,start_station+start_dist_along)})"
|
||||
|
||||
# update the referent's geometric representation's location
|
||||
end_referent.ObjectPlacement.RelativePlacement.Location.DistanceAlong.wrappedValue = start_dist_along
|
||||
settings = ifcopenshell.geom.settings()
|
||||
basis_curve = ifcopenshell.api.alignment.get_basis_curve(alignment)
|
||||
curve_fn = ifcopenshell_wrapper.map_shape(settings, basis_curve.wrapped_data)
|
||||
curve_evaluator = ifcopenshell_wrapper.function_item_evaluator(settings, curve_fn)
|
||||
p = curve_evaluator.evaluate(start_dist_along * unit_scale)
|
||||
p = np.array(p)
|
||||
|
||||
x = float(p[0, 3]) / unit_scale
|
||||
y = float(p[1, 3]) / unit_scale
|
||||
z = float(p[2, 3]) / unit_scale
|
||||
|
||||
rx = float(p[0, 0])
|
||||
ry = float(p[1, 0])
|
||||
rz = float(p[2, 0])
|
||||
|
||||
ax = float(p[0, 2])
|
||||
ay = float(p[1, 2])
|
||||
az = float(p[2, 2])
|
||||
|
||||
end_referent.ObjectPlacement.CartesianPosition.Location.Coordinates = (x, y, z)
|
||||
end_referent.ObjectPlacement.CartesianPosition.Axis.DirectionRatios = (ax, ay, az)
|
||||
end_referent.ObjectPlacement.CartesianPosition.RefDirection.DirectionRatios = (rx, ry, rz)
|
||||
|
||||
start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment)
|
||||
end_referent_station = start_station + start_dist_along
|
||||
pset_stationing = ifcopenshell.api.pset.add_pset(file, product=end_referent, name="Pset_Stationing")
|
||||
ifcopenshell.api.pset.edit_pset(file, pset=pset_stationing, properties={"Station": end_referent_station})
|
||||
|
||||
# create the start of segment referent
|
||||
|
||||
# get the previous segment. Working from the end of the basis curve, -1 is zero length segment
|
||||
# -2 is the newly added segment, so -3 is the segment occuring just before the newly added segment
|
||||
prev_segment = segment_nest.RelatedObjects[-3] if 2 < len(segment_nest.RelatedObjects) else None
|
||||
name = f"{_get_segment_start_point_label(prev_segment,segment)} ({ifcopenshell.util.alignment.station_as_string(file,station)})"
|
||||
referent = ifcopenshell.api.alignment.add_stationing_referent(
|
||||
file, alignment, distance_along=dist_along, station=station, name=name, positioned_product=segment
|
||||
)
|
||||
|
||||
if len(curve.Segments) == 2 and layout.is_a("IfcAlignmentHorizontal"):
|
||||
# this is the first real segment in the horizontal alignment
|
||||
# update the location of the alignment's stationing referent
|
||||
alignment = ifcopenshell.api.alignment.get_alignment(layout)
|
||||
ref_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
|
||||
stationing_referent = ref_nest.RelatedObjects[0]
|
||||
p = curve_evaluator.evaluate(
|
||||
stationing_referent.ObjectPlacement.RelativePlacement.Location.DistanceAlong.wrappedValue
|
||||
)
|
||||
p = np.array(p)
|
||||
|
||||
x = float(p[0, 3]) / unit_scale
|
||||
y = float(p[1, 3]) / unit_scale
|
||||
z = float(p[2, 3]) / unit_scale
|
||||
|
||||
rx = float(p[0, 0])
|
||||
ry = float(p[1, 0])
|
||||
rz = float(p[2, 0])
|
||||
|
||||
ax = float(p[0, 2])
|
||||
ay = float(p[1, 2])
|
||||
az = float(p[2, 2])
|
||||
|
||||
stationing_referent.ObjectPlacement.CartesianPosition.Location.Coordinates = (x, y, z)
|
||||
stationing_referent.ObjectPlacement.CartesianPosition.Axis.DirectionRatios = (ax, ay, az)
|
||||
stationing_referent.ObjectPlacement.CartesianPosition.RefDirection.DirectionRatios = (rx, ry, rz)
|
||||
return end_point
|
||||
|
||||
@@ -42,17 +42,8 @@ def _add_zero_length_segment(file: ifcopenshell.file, layout: entity_instance) -
|
||||
f"Expected layout type to be one of {[_ for _ in expected_types]}, instead received {layout.is_a()}"
|
||||
)
|
||||
|
||||
if not ifcopenshell.api.alignment.add_zero_length_segment(file, layout, include_referent=False):
|
||||
return # zero length segment not added, probably because it already exists
|
||||
ifcopenshell.api.alignment.add_zero_length_segment(file, layout)
|
||||
|
||||
curve = ifcopenshell.api.alignment.get_layout_curve(layout)
|
||||
|
||||
if curve:
|
||||
ifcopenshell.api.alignment.add_zero_length_segment(file, curve)
|
||||
|
||||
segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(layout)
|
||||
segment = segment_nest.RelatedObjects[-1]
|
||||
alignment = ifcopenshell.api.alignment.get_alignment(layout)
|
||||
station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment)
|
||||
name = f"{_get_segment_start_point_label(segment,None)} ({ifcopenshell.util.alignment.station_as_string(file,station)})"
|
||||
referent = ifcopenshell.api.alignment.add_stationing_referent(file, alignment, 0.0, station, name, segment)
|
||||
|
||||
+3
-8
@@ -35,6 +35,8 @@ def _create_geometric_representation(file: ifcopenshell.file, alignment: entity_
|
||||
4) Vertical only (this occurs when horizontal is reused from a parent alignment) -> IfcGradientCurve
|
||||
5) Vertical + Cant (this occurs when horizontal is reused from a parent alignment) -> IfcSegmentedReferenceCurve
|
||||
|
||||
This method creates the geometric representation entity and assigns it to the alignment, but does not populate the geometry of the representation.
|
||||
|
||||
:param alignment: The alignment for which the representation is being created
|
||||
:return: None
|
||||
"""
|
||||
@@ -43,13 +45,6 @@ def _create_geometric_representation(file: ifcopenshell.file, alignment: entity_
|
||||
if not alignment.is_a(expected_type):
|
||||
raise TypeError(f"Expected {expected_type} but got {alignment.is_a()}")
|
||||
|
||||
placement = file.createIfcLocalPlacement(
|
||||
PlacementRelTo=None,
|
||||
RelativePlacement=file.createIfcAxis2Placement2D(Location=file.createIfcCartesianPoint(Coordinates=(0.0, 0.0))),
|
||||
)
|
||||
|
||||
alignment.ObjectPlacement = placement
|
||||
|
||||
axis_geom_subcontext = ifcopenshell.api.alignment.get_axis_subcontext(file)
|
||||
|
||||
layouts = ifcopenshell.api.alignment.get_alignment_layouts(alignment)
|
||||
@@ -126,7 +121,7 @@ def _create_geometric_representation(file: ifcopenshell.file, alignment: entity_
|
||||
ifcopenshell.api.geometry.assign_representation(file, alignment, representation)
|
||||
|
||||
for child_alignment in children:
|
||||
child_alignment.ObjectPlacement = placement
|
||||
child_alignment.ObjectPlacement = alignment.ObjectPlacement
|
||||
child_layouts = ifcopenshell.api.alignment.get_alignment_layouts(child_alignment)
|
||||
if len(child_layouts) == 1:
|
||||
assert child_layouts[0].is_a("IfcAlignmentVertical")
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# IfcOpenShell - IFC toolkit and geometry engine
|
||||
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
|
||||
#
|
||||
# This file is part of IfcOpenShell.
|
||||
#
|
||||
# IfcOpenShell is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcOpenShell 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 Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import ifcopenshell.api.alignment
|
||||
from ifcopenshell import entity_instance, ifcopenshell_wrapper
|
||||
from ifcopenshell.api.alignment._map_alignment_segment import _map_alignment_segment
|
||||
from typing import Union
|
||||
import math
|
||||
import numpy as np
|
||||
|
||||
|
||||
def _get_segment_endpoint(file: ifcopenshell.file, segment: entity_instance) -> Union[np.array, None]:
|
||||
"""
|
||||
Computes the 4x4 matrix for a segment end point. The segment can be an IfcAlignmentSegment
|
||||
or IfcCurveSegment
|
||||
"""
|
||||
|
||||
expected_types = ["IfcAlignmentSegment", "IfcCurveSegment"]
|
||||
if not segment.is_a() in expected_types:
|
||||
raise TypeError(
|
||||
f"Expected entity type to be one of {[_ for _ in expected_types]}, instead received {segment.is_a()}"
|
||||
)
|
||||
|
||||
file.begin_transaction() # use a transaction so we can discard any temporary IFC entities created
|
||||
|
||||
curve_segment = segment
|
||||
if segment.is_a("IfcAlignmentSegment"):
|
||||
layout = ifcopenshell.api.alignment.get_layout(segment)
|
||||
mapped_segments = _map_alignment_segment(file, layout, segment)
|
||||
curve_segment = mapped_segments[0] if mapped_segments[1] == None else mapped_segments[1]
|
||||
|
||||
# Inside of the IfcOpenShell C++ implementation where the IfcCurveSegment calculations occur,
|
||||
# the composite curve owning the segment is evaluated to determine if a horizontal, vertical, or cant segment is being evaluated.
|
||||
# This is necessary to determine how the end point of the curve segment is calculated.
|
||||
# A temporary curve segment has been created and it needs to be associated with the correct composite curve for the end point to be calculated correctly.
|
||||
# Inside the C++ implementation, if a composite curve isn't associated with the segment the segment is assumed to be horizontal. For this reason
|
||||
# a temporary IfcCompositeCurve for horizontal segments doesn't need to be created.
|
||||
if layout.is_a("IfcAlignmentVertical"):
|
||||
gc = file.createIfcGradientCurve(Segments=[curve_segment])
|
||||
elif layout.is_a("IfcAlignmentCant"):
|
||||
# The evaluation of cant segments depend on the start conditions of the next segment. In the absense of a next segment the
|
||||
# optional EndPoint is used. Since a tempoaryar IfcSegmentReferenceCurve is being used, there is not a next segment.
|
||||
# For this reason the EndPoint must be created from the design parameters of the sementic segment definiton.
|
||||
Dsl = segment.DesignParameters.StartCantLeft
|
||||
Dsr = segment.DesignParameters.StartCantRight
|
||||
Del = segment.DesignParameters.EndCantLeft if segment.DesignParameters.EndCantLeft != None else Dsl
|
||||
Der = segment.DesignParameters.EndCantRight if segment.DesignParameters.EndCantRight != None else Dsr
|
||||
cant = Der - Del
|
||||
rh = layout.RailHeadDistance
|
||||
Ay = cant / rh
|
||||
Az = math.sqrt(rh**2 - cant**2) / rh
|
||||
|
||||
src = file.createIfcSegmentedReferenceCurve(
|
||||
Segments=[curve_segment],
|
||||
EndPoint=file.createIfcAxis2Placement3D(
|
||||
Location=file.createIfcCartesianPoint((segment.DesignParameters.StartDistAlong, 0.5 * cant, 0.0)),
|
||||
RefDirection=file.createIfcDirection((1.0, 0.0, 0.0)),
|
||||
Axis=file.createIfcDirection((0.0, Ay, Az)),
|
||||
),
|
||||
)
|
||||
|
||||
settings = ifcopenshell.geom.settings()
|
||||
|
||||
segment_fn = ifcopenshell_wrapper.map_shape(settings, curve_segment.wrapped_data)
|
||||
segment_evaluator = ifcopenshell_wrapper.function_item_evaluator(settings, segment_fn)
|
||||
x = segment_fn.end()
|
||||
e = segment_evaluator.evaluate(x)
|
||||
end = np.array(e)
|
||||
|
||||
file.discard_transaction()
|
||||
|
||||
return end
|
||||
@@ -24,10 +24,12 @@ from ifcopenshell import entity_instance
|
||||
|
||||
|
||||
def _get_axis(file: ifcopenshell.file, Ds: float, rail_head_distance: float) -> entity_instance:
|
||||
Dy = rail_head_distance
|
||||
Dz = 2 * Ds
|
||||
D = math.sqrt(Dy * Dy + Dz * Dz)
|
||||
return file.createIfcDirection((0.0, Dz / D, Dy / D))
|
||||
# solves the ratio right triangle legs to hypotenous
|
||||
# Dh^2 = Dy^2 + Dz^2
|
||||
Dh = rail_head_distance # hypotenous
|
||||
Dy = 2 * Ds # horizontal leg
|
||||
Dz = math.sqrt(Dh * Dh - Dy * Dy) # vertical leg
|
||||
return file.createIfcDirection((0.0, Dy / Dh, Dz / Dh))
|
||||
|
||||
|
||||
def _map_constant_cant(
|
||||
@@ -54,7 +56,7 @@ def _map_constant_cant(
|
||||
Transition=transition,
|
||||
Placement=file.createIfcAxis2Placement3D(
|
||||
Location=start_point,
|
||||
Axis=_get_axis(file, Ds, rail_head_distance),
|
||||
Axis=_get_axis(file, 0.5 * (Dsr - Dsl), rail_head_distance),
|
||||
RefDirection=file.createIfcDirection((math.cos(start_direction), math.sin(start_direction), 0.0)),
|
||||
),
|
||||
SegmentStart=file.createIfcLengthMeasure(0.0),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user