mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-26 02:07:36 +00:00
Merge pull request #8112 from Gorgious56/bonsai/parametric-framework-infra
Decorator cache + parametric lifecycle drift triad + wall split fixes
This commit is contained in:
@@ -27,7 +27,7 @@ import bpy
|
|||||||
import bpy.utils.previews
|
import bpy.utils.previews
|
||||||
from bpy_extras.io_utils import ExportHelper, ImportHelper
|
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]:
|
def _parametric_gizmo_preference_classes() -> list[type]:
|
||||||
@@ -283,6 +283,8 @@ def register():
|
|||||||
bpy.app.handlers.depsgraph_update_post.append(on_register)
|
bpy.app.handlers.depsgraph_update_post.append(on_register)
|
||||||
bpy.app.handlers.undo_post.append(handler.undo_post)
|
bpy.app.handlers.undo_post.append(handler.undo_post)
|
||||||
bpy.app.handlers.redo_post.append(handler.redo_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.load_post)
|
||||||
bpy.app.handlers.load_post.append(handler.loadIfcStore)
|
bpy.app.handlers.load_post.append(handler.loadIfcStore)
|
||||||
bpy.types.Scene.BIMProperties = bpy.props.PointerProperty(type=prop.BIMProperties)
|
bpy.types.Scene.BIMProperties = bpy.props.PointerProperty(type=prop.BIMProperties)
|
||||||
@@ -340,6 +342,7 @@ def unregister():
|
|||||||
|
|
||||||
unregister_classes(classes)
|
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.load_post)
|
||||||
bpy.app.handlers.load_post.remove(handler.loadIfcStore)
|
bpy.app.handlers.load_post.remove(handler.loadIfcStore)
|
||||||
del bpy.types.Scene.BIMProperties
|
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
|
||||||
@@ -34,7 +34,11 @@ from mathutils import Vector
|
|||||||
import bonsai.bim
|
import bonsai.bim
|
||||||
import bonsai.core.model as core_model
|
import bonsai.core.model as core_model
|
||||||
import bonsai.tool as tool
|
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.aggregate.decorator import AggregateDecorator
|
||||||
from bonsai.bim.module.georeference.decorator import GeoreferenceDecorator
|
from bonsai.bim.module.georeference.decorator import GeoreferenceDecorator
|
||||||
from bonsai.bim.module.model.data import AuthoringData
|
from bonsai.bim.module.model.data import AuthoringData
|
||||||
@@ -43,6 +47,7 @@ from bonsai.bim.module.model.decorator import (
|
|||||||
SlabDirectionDecorator,
|
SlabDirectionDecorator,
|
||||||
WallAxisDecorator,
|
WallAxisDecorator,
|
||||||
)
|
)
|
||||||
|
from bonsai.bim.module.model.preview_base import discard_pending_previews
|
||||||
from bonsai.bim.module.nest.decorator import NestDecorator
|
from bonsai.bim.module.nest.decorator import NestDecorator
|
||||||
|
|
||||||
cwd = os.path.dirname(os.path.realpath(__file__))
|
cwd = os.path.dirname(os.path.realpath(__file__))
|
||||||
@@ -378,8 +383,10 @@ def subscribe_to_viewport_shading_changes():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@persistent
|
def _apply_save_file_invariants(scene: bpy.types.Scene) -> None:
|
||||||
def load_post(scene):
|
"""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
|
global global_subscription_owner
|
||||||
active_object_key = bpy.types.LayerObjects, "active"
|
active_object_key = bpy.types.LayerObjects, "active"
|
||||||
bpy.msgbus.subscribe_rna(
|
bpy.msgbus.subscribe_rna(
|
||||||
@@ -390,6 +397,24 @@ def load_post(scene):
|
|||||||
ifcopenshell.api.owner.settings.get_application = get_application
|
ifcopenshell.api.owner.settings.get_application = get_application
|
||||||
AuthoringData.type_thumbnails = {}
|
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()
|
preferences = tool.Blender.get_addon_preferences()
|
||||||
if not preferences.should_setup_toolbar:
|
if not preferences.should_setup_toolbar:
|
||||||
tool.Blender.unregister_toolbar()
|
tool.Blender.unregister_toolbar()
|
||||||
@@ -413,11 +438,21 @@ def load_post(scene):
|
|||||||
tool.Blender.override_scene_panel(panel)
|
tool.Blender.override_scene_panel(panel)
|
||||||
tool.Blender.setup_tabs()
|
tool.Blender.setup_tabs()
|
||||||
|
|
||||||
if tool.Ifc.get() and bpy.data.is_saved:
|
if preferences.should_use_snap and (scene := bpy.context.scene):
|
||||||
props = tool.Blender.get_bim_props()
|
# Snapping is off by default in Blender, but in BIM, it's more useful to be on
|
||||||
props.has_blend_warning = True
|
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()
|
georeference_props = tool.Georeference.get_georeference_props()
|
||||||
aggregate_props = tool.Aggregate.get_aggregate_props()
|
aggregate_props = tool.Aggregate.get_aggregate_props()
|
||||||
nest_props = tool.Nest.get_nest_props()
|
nest_props = tool.Nest.get_nest_props()
|
||||||
@@ -427,23 +462,26 @@ def load_post(scene):
|
|||||||
NestDecorator.uninstall()
|
NestDecorator.uninstall()
|
||||||
WallAxisDecorator.uninstall()
|
WallAxisDecorator.uninstall()
|
||||||
SlabDirectionDecorator.uninstall()
|
SlabDirectionDecorator.uninstall()
|
||||||
if georeference_props.should_visualise:
|
uninstall_decorator_cache_handlers()
|
||||||
GeoreferenceDecorator.install(bpy.context)
|
try:
|
||||||
if aggregate_props.aggregate_decorator:
|
if georeference_props.should_visualise:
|
||||||
AggregateDecorator.install(bpy.context)
|
GeoreferenceDecorator.install(bpy.context)
|
||||||
if nest_props.nest_decorator:
|
if aggregate_props.aggregate_decorator:
|
||||||
NestDecorator.install(bpy.context)
|
AggregateDecorator.install(bpy.context)
|
||||||
if model_props.show_wall_axis:
|
if nest_props.nest_decorator:
|
||||||
WallAxisDecorator.install(bpy.context)
|
NestDecorator.install(bpy.context)
|
||||||
if model_props.show_slab_direction:
|
if model_props.show_wall_axis:
|
||||||
SlabDirectionDecorator.install(bpy.context)
|
WallAxisDecorator.install(bpy.context)
|
||||||
if model_props.show_bounding_box:
|
if model_props.show_slab_direction:
|
||||||
BoundingBoxDecorator.install(bpy.context)
|
SlabDirectionDecorator.install(bpy.context)
|
||||||
|
if model_props.show_bounding_box:
|
||||||
|
BoundingBoxDecorator.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()
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
# 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."""
|
||||||
|
preview = getattr(context.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)
|
||||||
|
|
||||||
|
|
||||||
|
# --- 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
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
import copy
|
import copy
|
||||||
import math
|
import math
|
||||||
from math import atan2, cos, degrees, pi, sin
|
from math import atan2, cos, degrees, pi, sin
|
||||||
from typing import TYPE_CHECKING, Any, ClassVar, Literal, Union, get_args
|
from typing import TYPE_CHECKING, Any, ClassVar, Literal, Optional, Union, get_args
|
||||||
|
|
||||||
import bmesh
|
import bmesh
|
||||||
import bpy
|
import bpy
|
||||||
@@ -34,9 +34,11 @@ import ifcopenshell.api.material
|
|||||||
import ifcopenshell.api.pset
|
import ifcopenshell.api.pset
|
||||||
import ifcopenshell.api.root
|
import ifcopenshell.api.root
|
||||||
import ifcopenshell.api.type
|
import ifcopenshell.api.type
|
||||||
|
import ifcopenshell.geom
|
||||||
import ifcopenshell.util.element
|
import ifcopenshell.util.element
|
||||||
import ifcopenshell.util.placement
|
import ifcopenshell.util.placement
|
||||||
import ifcopenshell.util.representation
|
import ifcopenshell.util.representation
|
||||||
|
import ifcopenshell.util.shape
|
||||||
import ifcopenshell.util.shape_builder
|
import ifcopenshell.util.shape_builder
|
||||||
import ifcopenshell.util.type
|
import ifcopenshell.util.type
|
||||||
import ifcopenshell.util.unit
|
import ifcopenshell.util.unit
|
||||||
@@ -1191,6 +1193,62 @@ class DumbWallPlaner:
|
|||||||
tool.Model.recalculate_walls([w for w in set(walls) if w])
|
tool.Model.recalculate_walls([w for w in set(walls) if w])
|
||||||
|
|
||||||
|
|
||||||
|
def _opening_axis_extent(opening, axis_reference, unit_scale):
|
||||||
|
"""Return ``(min_t, max_t)``: the opening's world-space footprint
|
||||||
|
projected onto ``axis_reference`` as parametric positions along the
|
||||||
|
wall axis (``0`` is the start of the axis line, ``1`` is its end).
|
||||||
|
Used to detect openings whose footprint straddles a cut.
|
||||||
|
|
||||||
|
Computed via ``ifcopenshell.geom.create_shape`` so the result is
|
||||||
|
correct for any representation type Bonsai may produce — mapped
|
||||||
|
representations, swept-area solids, breps, boolean clips, etc. —
|
||||||
|
without needing a Blender object (Bonsai hides openings after
|
||||||
|
``bim.add_opening``). Falls back to a degenerate single-point range
|
||||||
|
at the placement origin only when the geometry kernel cannot build
|
||||||
|
a shape from the opening."""
|
||||||
|
verts = None
|
||||||
|
shape_matrix: Optional[Matrix] = None
|
||||||
|
try:
|
||||||
|
settings = ifcopenshell.geom.settings()
|
||||||
|
shape = ifcopenshell.geom.create_shape(settings, opening)
|
||||||
|
verts = ifcopenshell.util.shape.get_vertices(shape.geometry)
|
||||||
|
shape_matrix = Matrix(ifcopenshell.util.shape.get_shape_matrix(shape).tolist())
|
||||||
|
except Exception:
|
||||||
|
verts = None
|
||||||
|
shape_matrix = None
|
||||||
|
|
||||||
|
if verts is None or shape_matrix is None or len(verts) == 0:
|
||||||
|
placement = Matrix(ifcopenshell.util.placement.get_local_placement(opening.ObjectPlacement).tolist())
|
||||||
|
placement.translation *= unit_scale
|
||||||
|
_, t = mathutils.geometry.intersect_point_line(placement.translation.to_2d(), *axis_reference)
|
||||||
|
return t, t
|
||||||
|
|
||||||
|
positions = []
|
||||||
|
for v in verts:
|
||||||
|
world = (shape_matrix @ Vector((float(v[0]), float(v[1]), float(v[2])))).to_2d()
|
||||||
|
_, t = mathutils.geometry.intersect_point_line(world, *axis_reference)
|
||||||
|
positions.append(t)
|
||||||
|
return min(positions), max(positions)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_void_copy(building_element, source_opening):
|
||||||
|
"""Add an unfilled IfcOpeningElement to ``building_element`` whose
|
||||||
|
geometry and placement mirror ``source_opening``. Used when a filled
|
||||||
|
opening's void straddles a wall split — the filling stays on its wall,
|
||||||
|
but the void must also apply to the neighbour so its body gets cut."""
|
||||||
|
void_copy = ifcopenshell.api.root.copy_class(tool.Ifc.get(), product=source_opening)
|
||||||
|
for fill_rel in list(void_copy.HasFillings or ()):
|
||||||
|
tool.Ifc.get().remove(fill_rel)
|
||||||
|
void_copy.VoidsElements[0].RelatingBuildingElement = building_element
|
||||||
|
if void_copy.ObjectPlacement and void_copy.ObjectPlacement.is_a("IfcLocalPlacement"):
|
||||||
|
if building_element.ObjectPlacement:
|
||||||
|
void_copy.ObjectPlacement.PlacementRelTo = building_element.ObjectPlacement
|
||||||
|
if source_opening.Representation:
|
||||||
|
void_copy.Representation = ifcopenshell.util.element.copy_deep(
|
||||||
|
tool.Ifc.get(), source_opening.Representation, exclude=["IfcGeometricRepresentationContext"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DumbWallJoiner:
|
class DumbWallJoiner:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||||
@@ -1255,39 +1313,44 @@ class DumbWallJoiner:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# During the duplication process, unfilled voids are copied, so we need
|
# During the duplication process, unfilled voids are copied, so we need
|
||||||
# to check openings on both element1 and element2. Let's check element1
|
# to check openings on both element1 and element2. Each wall keeps the
|
||||||
# first.
|
# opening when the opening's axis-projected extent overlaps that wall's
|
||||||
|
# portion of the axis — straddling openings are intentionally kept on
|
||||||
|
# both walls so each wall body gets the appropriate cut. Strict
|
||||||
|
# inequalities mean a boundary-only touch (or a degenerate single-point
|
||||||
|
# extent at the cut) keeps the opening on both walls — the safer
|
||||||
|
# default when the helper cannot resolve a true bounding range.
|
||||||
for opening in [
|
for opening in [
|
||||||
r.RelatedOpeningElement for r in element1.HasOpenings if not r.RelatedOpeningElement.HasFillings
|
r.RelatedOpeningElement for r in element1.HasOpenings if not r.RelatedOpeningElement.HasFillings
|
||||||
]:
|
]:
|
||||||
opening_matrix = Matrix(ifcopenshell.util.placement.get_local_placement(opening.ObjectPlacement).tolist())
|
min_t, _ = _opening_axis_extent(opening, axis1["reference"], unit_scale)
|
||||||
opening_matrix.translation *= unit_scale
|
if min_t > cut_percentage:
|
||||||
opening_location = opening_matrix.translation
|
# Opening lies entirely past the cut — only element2 should keep it.
|
||||||
_, opening_position = mathutils.geometry.intersect_point_line(opening_location.to_2d(), *axis1["reference"])
|
|
||||||
if opening_position > cut_percentage:
|
|
||||||
# The opening should be removed from element1.
|
|
||||||
ifcopenshell.api.feature.remove_feature(tool.Ifc.get(), feature=opening)
|
ifcopenshell.api.feature.remove_feature(tool.Ifc.get(), feature=opening)
|
||||||
|
|
||||||
# Now let's check element2.
|
|
||||||
for opening in [
|
for opening in [
|
||||||
r.RelatedOpeningElement for r in element2.HasOpenings if not r.RelatedOpeningElement.HasFillings
|
r.RelatedOpeningElement for r in element2.HasOpenings if not r.RelatedOpeningElement.HasFillings
|
||||||
]:
|
]:
|
||||||
opening_matrix = Matrix(ifcopenshell.util.placement.get_local_placement(opening.ObjectPlacement).tolist())
|
_, max_t = _opening_axis_extent(opening, axis1["reference"], unit_scale)
|
||||||
opening_matrix.translation *= unit_scale
|
if max_t < cut_percentage:
|
||||||
opening_location = opening_matrix.translation
|
# Opening lies entirely before the cut — only element1 should keep it.
|
||||||
_, opening_position = mathutils.geometry.intersect_point_line(opening_location.to_2d(), *axis1["reference"])
|
|
||||||
if opening_position < cut_percentage:
|
|
||||||
# The opening should be removed from element2.
|
|
||||||
ifcopenshell.api.feature.remove_feature(tool.Ifc.get(), feature=opening)
|
ifcopenshell.api.feature.remove_feature(tool.Ifc.get(), feature=opening)
|
||||||
|
|
||||||
# During the duplication process, filled voids are not copied. So we
|
# During the duplication process, filled voids are not copied. So we
|
||||||
# only need to check fillings on the original element1.
|
# only need to check fillings on the original element1. The filling
|
||||||
for opening in [r.RelatedOpeningElement for r in element1.HasOpenings if r.RelatedOpeningElement.HasFillings]:
|
# (door/window) belongs to whichever wall contains its center, but the
|
||||||
|
# void may need to apply to both walls when the void's extent straddles
|
||||||
|
# the cut — otherwise the neighbour wall's body would not be cut.
|
||||||
|
for opening in [
|
||||||
|
r.RelatedOpeningElement for r in list(element1.HasOpenings) if r.RelatedOpeningElement.HasFillings
|
||||||
|
]:
|
||||||
rel = opening.HasFillings[0]
|
rel = opening.HasFillings[0]
|
||||||
filling = rel.RelatedBuildingElement
|
filling = rel.RelatedBuildingElement
|
||||||
filling_obj = tool.Ifc.get_object(filling)
|
filling_obj = tool.Ifc.get_object(filling)
|
||||||
filling_location = filling_obj.matrix_world.translation
|
filling_location = filling_obj.matrix_world.translation
|
||||||
_, filling_position = mathutils.geometry.intersect_point_line(filling_location.to_2d(), *axis1["reference"])
|
_, filling_position = mathutils.geometry.intersect_point_line(filling_location.to_2d(), *axis1["reference"])
|
||||||
|
min_t, max_t = _opening_axis_extent(opening, axis1["reference"], unit_scale)
|
||||||
|
void_straddles = min_t < cut_percentage < max_t
|
||||||
if filling_position > cut_percentage:
|
if filling_position > cut_percentage:
|
||||||
# The filling should be moved from element1 to element2.
|
# The filling should be moved from element1 to element2.
|
||||||
new_opening = ifcopenshell.api.root.copy_class(tool.Ifc.get(), product=opening)
|
new_opening = ifcopenshell.api.root.copy_class(tool.Ifc.get(), product=opening)
|
||||||
@@ -1301,11 +1364,20 @@ class DumbWallJoiner:
|
|||||||
tool.Ifc.get(), opening.Representation, exclude=["IfcGeometricRepresentationContext"]
|
tool.Ifc.get(), opening.Representation, exclude=["IfcGeometricRepresentationContext"]
|
||||||
)
|
)
|
||||||
|
|
||||||
rel.RelatedBuildingElement = element2
|
rel.RelatingOpeningElement = new_opening
|
||||||
|
|
||||||
# Remove the old opening
|
# Remove the old opening
|
||||||
ifcopenshell.api.feature.remove_feature(tool.Ifc.get(), feature=opening)
|
ifcopenshell.api.feature.remove_feature(tool.Ifc.get(), feature=opening)
|
||||||
|
|
||||||
|
if void_straddles:
|
||||||
|
# Filling moved to element2, but void straddles — add a
|
||||||
|
# pure-void copy back to element1 so its body still gets cut.
|
||||||
|
_add_void_copy(element1, new_opening)
|
||||||
|
elif void_straddles:
|
||||||
|
# Filling stays on element1, but void straddles — add a pure-void
|
||||||
|
# copy to element2 so its body gets cut.
|
||||||
|
_add_void_copy(element2, opening)
|
||||||
|
|
||||||
p1, p2 = ifcopenshell.util.representation.get_reference_line(element1)
|
p1, p2 = ifcopenshell.util.representation.get_reference_line(element1)
|
||||||
p3 = (wall1.matrix_world.inverted() @ intersect.to_3d()).to_2d() / unit_scale
|
p3 = (wall1.matrix_world.inverted() @ intersect.to_3d()).to_2d() / unit_scale
|
||||||
self.set_axis(element1, p1, p3)
|
self.set_axis(element1, p1, p3)
|
||||||
|
|||||||
@@ -18,39 +18,64 @@
|
|||||||
#
|
#
|
||||||
# This file was generated with the assistance of an AI coding tool.
|
# This file was generated with the assistance of an AI coding tool.
|
||||||
|
|
||||||
"""Shared Enable / Finish / Cancel lifecycle mixins for parametric-edit operators.
|
"""Shared operator mixins for parametric-edit operators.
|
||||||
|
|
||||||
Two mixins fit the parametric-edit triads in ``bim/module/model/``:
|
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).
|
||||||
|
|
||||||
`FeatureModifierEditMixin`
|
Pattern selection (which approach a new feature should adopt):
|
||||||
Door, Window — BBIM_<Type> pset with nested ``lining_properties`` /
|
Every parametric edit lifecycle commits to one of three patterns. Pick by
|
||||||
``panel_properties``; Finish calls ``update_<type>_modifier_representation``
|
answering "does the feature share the Enable→Finish→Cancel shape that
|
||||||
via ``ifcopenshell.api.feature``; Cancel restores via ``switch_representation``.
|
one of the existing mixins already encodes?":
|
||||||
|
|
||||||
`PathPreservingEditMixin`
|
A. Inherit one of the shared mixins below and route through
|
||||||
Railing, Roof — BBIM_<Type> pset whose ``path_data`` is preserved through
|
`tool.Parametric.build_edit_lifecycle`:
|
||||||
edit (only general kwargs are user-editable); Finish calls
|
|
||||||
``update_<type>_modifier_bmesh`` / ``update_<type>_modifier_ifc_data``;
|
|
||||||
Cancel re-reads the pset and rebuilds the bmesh preview.
|
|
||||||
|
|
||||||
Stair and Wall stay standalone — their lifecycles diverge in ways that don't
|
- `FeatureModifierEditMixin` when the feature stores its pset as
|
||||||
fit either mixin without optional escape hatches (Stair has a unique
|
`{general fields} + {lining_properties: {...}} + {panel_properties: {...}}`
|
||||||
``update_ifc_stair_props`` post-Finish step + a separate ``get_props_kwargs_for_ifc_export``;
|
and Finish must call a per-type `update_<type>_modifier_representation`.
|
||||||
Wall is validation-first, snapshot-driven, no preview regen in operators).
|
|
||||||
|
|
||||||
This module sits separately from `bonsai.tool.Parametric` (the registry +
|
- `PathPreservingEditMixin` when the feature's pset carries a
|
||||||
auto-commit) because it imports ``bonsai.tool`` freely, while the registry
|
`path_data` field that survives general-kwarg edits untouched, with
|
||||||
itself must stay light — ``tool/blender.py`` consumes the registry at module load."""
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from collections.abc import Callable
|
||||||
from typing import TYPE_CHECKING, ClassVar
|
from typing import TYPE_CHECKING, ClassVar
|
||||||
|
|
||||||
import bpy
|
import bpy
|
||||||
import ifcopenshell.api.pset
|
|
||||||
import ifcopenshell.util.element
|
import ifcopenshell.util.element
|
||||||
import ifcopenshell.util.representation
|
from bpy.app.handlers import persistent
|
||||||
|
|
||||||
import bonsai.core.geometry
|
import bonsai.core.geometry
|
||||||
import bonsai.tool as tool
|
import bonsai.tool as tool
|
||||||
@@ -59,8 +84,8 @@ if TYPE_CHECKING:
|
|||||||
from ifcopenshell import entity_instance
|
from ifcopenshell import entity_instance
|
||||||
|
|
||||||
|
|
||||||
class _ParametricEditMixinBase:
|
class ParametricEditMixinBase:
|
||||||
"""Common scaffolding for parametric edit-triad mixins.
|
"""Common scaffolding for parametric edit-lifecycle mixins.
|
||||||
|
|
||||||
Each per-type subclass provides four hooks:
|
Each per-type subclass provides four hooks:
|
||||||
|
|
||||||
@@ -69,6 +94,11 @@ class _ParametricEditMixinBase:
|
|||||||
``_get_props(obj)``: PropertyGroup accessor
|
``_get_props(obj)``: PropertyGroup accessor
|
||||||
``_iter_targets(context)``: list of objects to act on (default: ``[active_object]``)
|
``_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`` /
|
Operator subclasses call one of ``_enable_targets`` / ``_finish_targets`` /
|
||||||
``_cancel_targets`` from their ``_execute`` method."""
|
``_cancel_targets`` from their ``_execute`` method."""
|
||||||
|
|
||||||
@@ -99,8 +129,29 @@ class _ParametricEditMixinBase:
|
|||||||
return None
|
return None
|
||||||
return element, cls._get_props(obj)
|
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)
|
||||||
|
|
||||||
class FeatureModifierEditMixin(_ParametricEditMixinBase):
|
@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.
|
"""Lifecycle for door- and window-style parametric modifier operators.
|
||||||
|
|
||||||
Enable:
|
Enable:
|
||||||
@@ -121,10 +172,7 @@ class FeatureModifierEditMixin(_ParametricEditMixinBase):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _update_modifier_representation(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
|
def _update_modifier_representation(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
|
||||||
"""Hook: call the per-type ``update_<type>_modifier_representation``.
|
"""Hook: call the per-type ``update_<type>_modifier_representation``."""
|
||||||
|
|
||||||
Door's helper takes ``obj``; window's takes ``context``. The hook lets
|
|
||||||
each subclass forward to its existing helper without unifying signatures."""
|
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -133,6 +181,7 @@ class FeatureModifierEditMixin(_ParametricEditMixinBase):
|
|||||||
if resolved is None:
|
if resolved is None:
|
||||||
return
|
return
|
||||||
element, props = resolved
|
element, props = resolved
|
||||||
|
cls._handle_drift_on_enable(obj)
|
||||||
data = json.loads(ifcopenshell.util.element.get_pset(element, cls.pset_name, "Data"))
|
data = json.loads(ifcopenshell.util.element.get_pset(element, cls.pset_name, "Data"))
|
||||||
data.update(data.pop("lining_properties"))
|
data.update(data.pop("lining_properties"))
|
||||||
data.update(data.pop("panel_properties"))
|
data.update(data.pop("panel_properties"))
|
||||||
@@ -152,12 +201,9 @@ class FeatureModifierEditMixin(_ParametricEditMixinBase):
|
|||||||
data["lining_properties"] = props.get_lining_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)
|
data["panel_properties"] = props.get_panel_kwargs(convert_to_project_units=True)
|
||||||
cls._update_modifier_representation(obj, context)
|
cls._update_modifier_representation(obj, context)
|
||||||
element_type = ifcopenshell.util.element.get_type(element)
|
cls._mark_type_thumbnail_dirty(element)
|
||||||
if element_type:
|
tool.Pset.write_bbim_data(element, cls.pset_name, data)
|
||||||
tool.Model.mark_thumbnail_for_update(element_type)
|
cls._handle_drift_on_finish(obj)
|
||||||
pset = tool.Pset.get_element_pset(element, cls.pset_name)
|
|
||||||
data_text = tool.Ifc.get().createIfcText(json.dumps(data, default=list))
|
|
||||||
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": data_text})
|
|
||||||
# Set only on success: if any IFC op above raised, the user's draft survives for retry.
|
# Set only on success: if any IFC op above raised, the user's draft survives for retry.
|
||||||
props.is_editing = False
|
props.is_editing = False
|
||||||
|
|
||||||
@@ -167,13 +213,21 @@ class FeatureModifierEditMixin(_ParametricEditMixinBase):
|
|||||||
if resolved is None:
|
if resolved is None:
|
||||||
return
|
return
|
||||||
element, props = resolved
|
element, props = resolved
|
||||||
data = json.loads(ifcopenshell.util.element.get_pset(element, cls.pset_name, "Data"))
|
# Cancel must always clear is_editing — leaving it True after a
|
||||||
data.update(data.pop("lining_properties"))
|
# restore-failure would block the user from re-entering edit mode and
|
||||||
data.update(data.pop("panel_properties"))
|
# the next save's stale-flag heal would silently roll back the
|
||||||
props.set_props_kwargs_from_ifc_data(data)
|
# cancellation. Wrap the restore in try/finally so the flag flips
|
||||||
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
|
# even on partial failure.
|
||||||
bonsai.core.geometry.switch_representation(tool.Ifc, tool.Geometry, obj=obj, representation=body)
|
try:
|
||||||
props.is_editing = False
|
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]:
|
def _enable_targets(self, context: bpy.types.Context) -> set[str]:
|
||||||
for obj in self._iter_targets(context):
|
for obj in self._iter_targets(context):
|
||||||
@@ -191,19 +245,20 @@ class FeatureModifierEditMixin(_ParametricEditMixinBase):
|
|||||||
return {"FINISHED"}
|
return {"FINISHED"}
|
||||||
|
|
||||||
|
|
||||||
class PathPreservingEditMixin(_ParametricEditMixinBase):
|
class PathPreservingEditMixin(ParametricEditMixinBase):
|
||||||
"""Lifecycle for railing- and roof-style parametric modifier operators.
|
"""Lifecycle for railing- and roof-style parametric modifier operators.
|
||||||
|
|
||||||
Distinctive: ``path_data`` is part of the BBIM_<Type> pset but is **not**
|
Distinctive: ``path_data`` is part of the BBIM_<Type> pset but is **not**
|
||||||
user-editable through this triad — it survives the edit untouched, only
|
user-editable through this lifecycle — it survives the edit untouched, only
|
||||||
general kwargs are diffed. (Path editing has its own separate operator
|
general kwargs are diffed. (Path editing has its own separate operator
|
||||||
pair, ``Enable/Finish/CancelEditing<Type>Path``, out of scope here.)
|
pair, ``Enable/Finish/CancelEditing<Type>Path``, out of scope here.)
|
||||||
|
|
||||||
Enable:
|
Enable:
|
||||||
Fetch pset data via ``tool.Model.get_modeling_bbim_pset_data`` → set
|
Fetch pset data via ``tool.Model.get_modeling_bbim_pset_data`` → set
|
||||||
draft props → ``is_editing = True``. The subclass post-load hook
|
draft props → ``is_editing = True``. The subclass post-load hook
|
||||||
lets railing JSON-serialise ``path_data`` for the PropertyGroup
|
can reshape the dict to fit the PropertyGroup's storage layout
|
||||||
string field.
|
(e.g., pre-serialise a structured pset value to JSON for a
|
||||||
|
``StringProperty`` field).
|
||||||
|
|
||||||
Finish:
|
Finish:
|
||||||
Read fresh pset → keep ``path_data`` → gather ``general`` kwargs
|
Read fresh pset → keep ``path_data`` → gather ``general`` kwargs
|
||||||
@@ -213,16 +268,18 @@ class PathPreservingEditMixin(_ParametricEditMixinBase):
|
|||||||
|
|
||||||
Cancel:
|
Cancel:
|
||||||
Read fresh pset → restore draft props → call
|
Read fresh pset → restore draft props → call
|
||||||
``_update_modifier_bmesh`` (per-type bmesh preview) →
|
``_restore_viewport_after_cancel`` (per-type viewport restore — typically
|
||||||
``is_editing = False``."""
|
rebuilds the bmesh preview, but subclasses may load a different
|
||||||
|
representation entirely) → ``is_editing = False``."""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _post_load_data(cls, data: dict) -> dict:
|
def _post_load_data(cls, data: dict) -> dict:
|
||||||
"""Hook: optionally transform the pset data dict after loading and before
|
"""Hook: optionally transform the pset data dict after loading and before
|
||||||
passing to ``set_props_kwargs_from_ifc_data``. Default: pass-through.
|
passing to ``set_props_kwargs_from_ifc_data``. Default: pass-through.
|
||||||
|
|
||||||
Railing overrides to JSON-serialise ``path_data`` (its
|
Override when the PropertyGroup stores a structured pset field as a
|
||||||
BIMRailingProperties.path_data is a ``StringProperty`` holding JSON)."""
|
serialised primitive — e.g., a list/dict value mapped onto a
|
||||||
|
``StringProperty`` requires JSON-encoding here."""
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -238,9 +295,12 @@ class PathPreservingEditMixin(_ParametricEditMixinBase):
|
|||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _update_modifier_bmesh(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
|
def _restore_viewport_after_cancel(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
|
||||||
"""Hook: per-type ``update_<type>_modifier_bmesh`` — rebuilds the
|
"""Hook: restore the viewport mesh to match the just-restored draft props.
|
||||||
bmesh preview to match the current draft props (used by Cancel)."""
|
|
||||||
|
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
|
raise NotImplementedError
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -249,6 +309,7 @@ class PathPreservingEditMixin(_ParametricEditMixinBase):
|
|||||||
if resolved is None:
|
if resolved is None:
|
||||||
return
|
return
|
||||||
_element, props = resolved
|
_element, props = resolved
|
||||||
|
cls._handle_drift_on_enable(obj)
|
||||||
data = tool.Model.get_modeling_bbim_pset_data(obj, cls.pset_name)["data_dict"]
|
data = tool.Model.get_modeling_bbim_pset_data(obj, cls.pset_name)["data_dict"]
|
||||||
data = cls._post_load_data(data)
|
data = cls._post_load_data(data)
|
||||||
props.set_props_kwargs_from_ifc_data(data)
|
props.set_props_kwargs_from_ifc_data(data)
|
||||||
@@ -261,11 +322,18 @@ class PathPreservingEditMixin(_ParametricEditMixinBase):
|
|||||||
return
|
return
|
||||||
element, props = resolved
|
element, props = resolved
|
||||||
pset_data = tool.Model.get_modeling_bbim_pset_data(obj, cls.pset_name)
|
pset_data = tool.Model.get_modeling_bbim_pset_data(obj, cls.pset_name)
|
||||||
path_data = pset_data["data_dict"]["path_data"]
|
stored = pset_data["data_dict"]
|
||||||
data = props.get_general_kwargs(convert_to_project_units=True)
|
data = props.get_general_kwargs(convert_to_project_units=True)
|
||||||
data["path_data"] = path_data
|
data["path_data"] = stored["path_data"]
|
||||||
cls._update_pset(element, data)
|
# Skip the pset commit when the draft is identical to the stored pset:
|
||||||
cls._update_modifier_ifc_data(obj, context)
|
# 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.
|
# Set only on success: if any IFC op above raised, the user's draft survives for retry.
|
||||||
props.is_editing = False
|
props.is_editing = False
|
||||||
|
|
||||||
@@ -274,12 +342,26 @@ class PathPreservingEditMixin(_ParametricEditMixinBase):
|
|||||||
resolved = cls._resolve(obj)
|
resolved = cls._resolve(obj)
|
||||||
if resolved is None:
|
if resolved is None:
|
||||||
return
|
return
|
||||||
_element, props = resolved
|
element, props = resolved
|
||||||
data = tool.Model.get_modeling_bbim_pset_data(obj, cls.pset_name)["data_dict"]
|
try:
|
||||||
data = cls._post_load_data(data)
|
pset_data = tool.Model.get_modeling_bbim_pset_data(obj, cls.pset_name)
|
||||||
props.set_props_kwargs_from_ifc_data(data)
|
stored = pset_data["data_dict"]
|
||||||
cls._update_modifier_bmesh(obj, context)
|
draft = props.get_general_kwargs(convert_to_project_units=True)
|
||||||
props.is_editing = False
|
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]:
|
def _enable_targets(self, context: bpy.types.Context) -> set[str]:
|
||||||
for obj in self._iter_targets(context):
|
for obj in self._iter_targets(context):
|
||||||
@@ -295,3 +377,86 @@ class PathPreservingEditMixin(_ParametricEditMixinBase):
|
|||||||
for obj in self._iter_targets(context):
|
for obj in self._iter_targets(context):
|
||||||
self._cancel_one(obj, context)
|
self._cancel_one(obj, context)
|
||||||
return {"FINISHED"}
|
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
|
||||||
|
|||||||
@@ -680,9 +680,13 @@ class Blender(bonsai.core.tool.Blender):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def update_all_viewports(cls, context: bpy.types.Context | None = None) -> None:
|
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
|
context = context or bpy.context
|
||||||
assert context.screen
|
screen = getattr(context, "screen", None)
|
||||||
for area in context.screen.areas:
|
if screen is None:
|
||||||
|
return
|
||||||
|
for area in screen.areas:
|
||||||
if area.type == "VIEW_3D":
|
if area.type == "VIEW_3D":
|
||||||
area.tag_redraw()
|
area.tag_redraw()
|
||||||
|
|
||||||
|
|||||||
@@ -187,11 +187,7 @@ class Parametric(bonsai.core.tool.Parametric):
|
|||||||
|
|
||||||
cls._geom_generation += 1
|
cls._geom_generation += 1
|
||||||
bonsai.bim.handler.update_bim_tool_props()
|
bonsai.bim.handler.update_bim_tool_props()
|
||||||
screen = getattr(bpy.context, "screen", None)
|
tool.Blender.update_all_viewports()
|
||||||
if screen is not None:
|
|
||||||
for area in screen.areas:
|
|
||||||
if area.type == "VIEW_3D":
|
|
||||||
area.tag_redraw()
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def find_by_name(cls, name: str) -> Optional[ParametricObject]:
|
def find_by_name(cls, name: str) -> Optional[ParametricObject]:
|
||||||
|
|||||||
@@ -299,15 +299,29 @@ class System(bonsai.core.tool.System):
|
|||||||
system_props = cls.get_system_props()
|
system_props = cls.get_system_props()
|
||||||
return tool.Ifc.get_entity_by_id(system_props.active_system_id)
|
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
|
@classmethod
|
||||||
def get_decoration_data(cls) -> dict[str, Any]:
|
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
|
from bonsai.bim.module.system.data import ObjectSystemData, SystemDecorationData
|
||||||
|
|
||||||
if not ObjectSystemData.is_loaded:
|
if not ObjectSystemData.is_loaded:
|
||||||
ObjectSystemData.load()
|
ObjectSystemData.load()
|
||||||
if not SystemDecorationData.is_loaded:
|
if not SystemDecorationData.is_loaded:
|
||||||
SystemDecorationData.load()
|
SystemDecorationData.load()
|
||||||
return cls._build_decoration_data()
|
|
||||||
|
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
|
@classmethod
|
||||||
def _build_decoration_data(cls) -> dict[str, Any]:
|
def _build_decoration_data(cls) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -285,6 +285,7 @@ Scenario: Split a wall which has a flipped door
|
|||||||
And the object "IfcWall/Wall" is selected
|
And the object "IfcWall/Wall" is selected
|
||||||
And I press "bim.hotkey(hotkey='S_K')"
|
And I press "bim.hotkey(hotkey='S_K')"
|
||||||
Then the object "IfcDoor/Door" is at "8.01,0.1,0"
|
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
|
Scenario: Offset walls
|
||||||
Given an empty IFC project
|
Given an empty IFC project
|
||||||
|
|||||||
@@ -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,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,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.
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
# Bonsai - OpenBIM Blender Add-on
|
|
||||||
# Copyright (C) 2026
|
|
||||||
#
|
|
||||||
# This file is part of Bonsai.
|
|
||||||
#
|
|
||||||
# Bonsai is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# Bonsai is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
#
|
|
||||||
# This file was generated with the assistance of an AI coding tool.
|
|
||||||
|
|
||||||
"""Addon-load smoke for ``bonsai``.
|
|
||||||
|
|
||||||
Pins the registration/unregistration cycle as a runnable contract. The cycle
|
|
||||||
exercises every ``register()`` site across ``bim/__init__.py``'s modules dict,
|
|
||||||
every ``PointerProperty`` attachment, every gizmo-prefs auto-registration, and
|
|
||||||
every ``bpy.app.handlers`` install. A regression in any of those surfaces here
|
|
||||||
as an exception with a traceback that points at the failing site, instead of
|
|
||||||
the silent ``addon failed to enable`` users see in a fresh Blender."""
|
|
||||||
|
|
||||||
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 test_addon_unregister_then_register_does_not_raise():
|
|
||||||
"""Running the suite has already enabled the addon. Cycle through one
|
|
||||||
unregister + register to exercise both halves, then leave the addon
|
|
||||||
enabled so downstream tests in the same Blender session keep working."""
|
|
||||||
import bonsai
|
|
||||||
|
|
||||||
bonsai.unregister()
|
|
||||||
try:
|
|
||||||
bonsai.register()
|
|
||||||
except Exception:
|
|
||||||
# Re-raise after attempting to leave the session in a usable state for
|
|
||||||
# any tests that run after this one.
|
|
||||||
try:
|
|
||||||
bonsai.register()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
raise
|
|
||||||
@@ -198,10 +198,12 @@ def test_feature_modifier_finish_one_clears_is_editing_and_writes_pset(patched_t
|
|||||||
|
|
||||||
assert props.is_editing is False
|
assert props.is_editing is False
|
||||||
assert obj in cls.representations_called
|
assert obj in cls.representations_called
|
||||||
# edit_pset is called exactly once; properties key is "Data" wrapping JSON.
|
# tool.Pset.write_bbim_data is called exactly once with the merged dict.
|
||||||
patched_tool_and_ifc["ifc"].api.pset.edit_pset.assert_called_once()
|
patched_tool_and_ifc["tool"].Pset.write_bbim_data.assert_called_once()
|
||||||
kwargs = patched_tool_and_ifc["ifc"].api.pset.edit_pset.call_args.kwargs
|
call_args = patched_tool_and_ifc["tool"].Pset.write_bbim_data.call_args
|
||||||
assert "properties" in kwargs and "Data" in kwargs["properties"]
|
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):
|
def test_feature_modifier_finish_one_exception_leaves_draft_in_progress(patched_tool_and_ifc):
|
||||||
@@ -302,7 +304,7 @@ def _path_mixin_cls(match=True):
|
|||||||
cls.ifc_data_updates.append(obj)
|
cls.ifc_data_updates.append(obj)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _update_modifier_bmesh(cls, obj, context):
|
def _restore_viewport_after_cancel(cls, obj, context):
|
||||||
cls.bmesh_updates.append(obj)
|
cls.bmesh_updates.append(obj)
|
||||||
|
|
||||||
return _TestPathMixin
|
return _TestPathMixin
|
||||||
@@ -344,7 +346,7 @@ def test_path_preserving_finish_one_preserves_path_data_and_clears_is_editing(pa
|
|||||||
assert obj in cls.ifc_data_updates
|
assert obj in cls.ifc_data_updates
|
||||||
|
|
||||||
|
|
||||||
def test_path_preserving_cancel_one_calls_update_modifier_bmesh(patched_tool_and_ifc):
|
def test_path_preserving_cancel_one_calls_restore_viewport_after_cancel(patched_tool_and_ifc):
|
||||||
props = _FakePathProps()
|
props = _FakePathProps()
|
||||||
props.is_editing = True
|
props.is_editing = True
|
||||||
obj = _make_obj(props)
|
obj = _make_obj(props)
|
||||||
|
|||||||
Reference in New Issue
Block a user