mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-27 02:31:09 +00:00
Merge pull request #8234 from Gorgious56/fix-apply-opening-crash-on-non-fillings
Bonsai: fix Apply Opening crash on non-fillings
This commit is contained in:
@@ -17,9 +17,9 @@
|
|||||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import bpy
|
import bpy
|
||||||
|
import ifcopenshell.util.unit
|
||||||
from bpy.types import Menu, Panel, UIList
|
from bpy.types import Menu, Panel, UIList
|
||||||
|
|
||||||
import ifcopenshell.util.unit
|
|
||||||
import bonsai.bim
|
import bonsai.bim
|
||||||
import bonsai.tool as tool
|
import bonsai.tool as tool
|
||||||
from bonsai.bim.helper import prop_with_search
|
from bonsai.bim.helper import prop_with_search
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ from mathutils import Vector
|
|||||||
|
|
||||||
import bonsai.tool as tool
|
import bonsai.tool as tool
|
||||||
from bonsai.bim.module.drawing import gizmos as gizmo
|
from bonsai.bim.module.drawing import gizmos as gizmo
|
||||||
|
from bonsai.bim.module.model.opening import is_filling_supported
|
||||||
from bonsai.bim.module.model.wall import (
|
from bonsai.bim.module.model.wall import (
|
||||||
_get_wall_geom_cached,
|
_get_wall_geom_cached,
|
||||||
_wall_camera_facing_icon_y,
|
_wall_camera_facing_icon_y,
|
||||||
@@ -52,6 +53,20 @@ def is_supported_host(element) -> bool:
|
|||||||
return tool.Parametric.is_path_connectable_wall(element) or element.is_a("IfcSlab") or element.is_a("IfcRoof")
|
return tool.Parametric.is_path_connectable_wall(element) or element.is_a("IfcSlab") or element.is_a("IfcRoof")
|
||||||
|
|
||||||
|
|
||||||
|
def is_supported_filling_or_opening(element) -> bool:
|
||||||
|
"""Total predicate for the add-opening gizmo poll. ``None`` (raw Blender
|
||||||
|
mesh) is accepted because the operator converts unclassified meshes
|
||||||
|
into ``IfcOpeningElement`` instances. ``IfcOpeningElement`` is accepted
|
||||||
|
because reassigning an existing opening to a new host is a legal path
|
||||||
|
through the operator. Otherwise defer to the generator's own
|
||||||
|
supported-filling predicate."""
|
||||||
|
if element is None:
|
||||||
|
return True
|
||||||
|
if element.is_a("IfcOpeningElement"):
|
||||||
|
return True
|
||||||
|
return is_filling_supported(element)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_active_host(context: bpy.types.Context, n_selected: int):
|
def _resolve_active_host(context: bpy.types.Context, n_selected: int):
|
||||||
"""Shared poll prologue: gizmo gate + selection cardinality + active-in-
|
"""Shared poll prologue: gizmo gate + selection cardinality + active-in-
|
||||||
selected + IFC entity lookup + supported-host predicate. Returns the
|
selected + IFC entity lookup + supported-host predicate. Returns the
|
||||||
@@ -72,12 +87,14 @@ def _resolve_active_host(context: bpy.types.Context, n_selected: int):
|
|||||||
|
|
||||||
|
|
||||||
class GizmoHostAddOpening(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin):
|
class GizmoHostAddOpening(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin):
|
||||||
"""Activates when a host element (wall / slab / roof) is the active object
|
"""Activates when exactly two objects are selected and one is a fillable
|
||||||
and exactly one other selected object is *not* itself a host.
|
host (wall / slab / roof) while the other is a valid filling (door /
|
||||||
|
window / existing opening, or a plain Blender mesh).
|
||||||
|
|
||||||
Renders a single ``VIEW3D_GT_add_opening`` icon at the void object's
|
Selection-order independent: the host role is identified by class, not
|
||||||
projected location on the host. A click dispatches ``bim.add_opening``,
|
by active state. The "+" icon anchors on the host's surface regardless
|
||||||
which handles any element exposing the ``HasOpenings`` inverse.
|
of which object was clicked first. The dispatched ``bim.add_opening``
|
||||||
|
operator also handles either order.
|
||||||
|
|
||||||
Per-frame positioning keeps the icon facing the camera as the viewport
|
Per-frame positioning keeps the icon facing the camera as the viewport
|
||||||
orbits."""
|
orbits."""
|
||||||
@@ -90,22 +107,29 @@ class GizmoHostAddOpening(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def poll(cls, context: bpy.types.Context) -> bool:
|
def poll(cls, context: bpy.types.Context) -> bool:
|
||||||
element = _resolve_active_host(context, n_selected=2)
|
if not _wall_gizmo_poll_gate(context):
|
||||||
if element is None:
|
|
||||||
return False
|
return False
|
||||||
# The operator itself filters on HasOpenings, but checking here keeps
|
selected = list(tool.Blender.get_selected_objects())
|
||||||
# the icon from appearing on host classes that can't accept openings
|
if len(selected) != 2:
|
||||||
# in the active IFC schema.
|
|
||||||
if not hasattr(element, "HasOpenings"):
|
|
||||||
return False
|
return False
|
||||||
active = context.active_object
|
active = context.active_object
|
||||||
other = next(o for o in tool.Blender.get_selected_objects() if o is not active)
|
if active is None or active not in selected:
|
||||||
# Host + host pairings are claimed by host-specific gizmos (wall-join,
|
|
||||||
# extend-vertical, …) — suppress here so the add-opening icon never
|
|
||||||
# stacks on top of them.
|
|
||||||
if is_supported_host(tool.Ifc.get_entity(other)):
|
|
||||||
return False
|
return False
|
||||||
return True
|
a_element = tool.Ifc.get_entity(selected[0])
|
||||||
|
b_element = tool.Ifc.get_entity(selected[1])
|
||||||
|
return cls._is_apply_opening_pair(a_element, b_element) or cls._is_apply_opening_pair(b_element, a_element)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_apply_opening_pair(host_element, filling_element) -> bool:
|
||||||
|
"""``host_element`` qualifies as a fillable host AND ``filling_element``
|
||||||
|
qualifies as a filling. Used twice with the operands swapped so the
|
||||||
|
gizmo polls true regardless of which of the two selected objects is
|
||||||
|
active."""
|
||||||
|
if not is_supported_host(host_element):
|
||||||
|
return False
|
||||||
|
if not hasattr(host_element, "HasOpenings"):
|
||||||
|
return False
|
||||||
|
return is_supported_filling_or_opening(filling_element)
|
||||||
|
|
||||||
def setup(self, context: bpy.types.Context) -> None:
|
def setup(self, context: bpy.types.Context) -> None:
|
||||||
default_color, highlight_color = self.get_decoration_colors()
|
default_color, highlight_color = self.get_decoration_colors()
|
||||||
@@ -114,18 +138,20 @@ class GizmoHostAddOpening(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin
|
|||||||
)
|
)
|
||||||
|
|
||||||
def position_gizmos(self, context: bpy.types.Context) -> None:
|
def position_gizmos(self, context: bpy.types.Context) -> None:
|
||||||
host_obj = context.active_object
|
selected = list(tool.Blender.get_selected_objects())
|
||||||
if not host_obj:
|
if len(selected) != 2:
|
||||||
return
|
return
|
||||||
selected = tool.Blender.get_selected_objects()
|
a, b = selected[0], selected[1]
|
||||||
other = next((o for o in selected if o is not host_obj), None)
|
a_element = tool.Ifc.get_entity(a)
|
||||||
if not other:
|
b_element = tool.Ifc.get_entity(b)
|
||||||
return
|
if is_supported_host(a_element):
|
||||||
element = tool.Ifc.get_entity(host_obj)
|
host_obj, host_element, other = a, a_element, b
|
||||||
if not element:
|
elif is_supported_host(b_element):
|
||||||
|
host_obj, host_element, other = b, b_element, a
|
||||||
|
else:
|
||||||
return
|
return
|
||||||
|
|
||||||
if tool.Parametric.is_path_connectable_wall(element):
|
if tool.Parametric.is_path_connectable_wall(host_element):
|
||||||
world_pos = wall_anchor(context, self, host_obj, other)
|
world_pos = wall_anchor(context, self, host_obj, other)
|
||||||
else:
|
else:
|
||||||
world_pos = layer3_anchor(host_obj, other)
|
world_pos = layer3_anchor(host_obj, other)
|
||||||
|
|||||||
@@ -240,6 +240,15 @@ def _store_batch_in_cache(cache_key: tuple[int, str], batch: "gpu.types.GPUBatch
|
|||||||
_batch_cache[cache_key] = (epoch, batch)
|
_batch_cache[cache_key] = (epoch, batch)
|
||||||
|
|
||||||
|
|
||||||
|
def is_filling_supported(element) -> bool:
|
||||||
|
"""True when Bonsai's opening generator can derive an opening from this
|
||||||
|
element. IFC's schema permits any IfcElement as a filling; Bonsai
|
||||||
|
currently supports only IfcDoor and IfcWindow because those are the
|
||||||
|
classes with OverallWidth/OverallHeight attributes (or their types'
|
||||||
|
ELEVATION_VIEW profiles) that the generator can consume."""
|
||||||
|
return element is not None and element.is_a() in ("IfcDoor", "IfcWindow")
|
||||||
|
|
||||||
|
|
||||||
class FilledOpeningGenerator:
|
class FilledOpeningGenerator:
|
||||||
def generate(
|
def generate(
|
||||||
self,
|
self,
|
||||||
@@ -608,6 +617,25 @@ class RecalculateFill(bpy.types.Operator, tool.Ifc.Operator):
|
|||||||
return self._recalculate_fills(context)
|
return self._recalculate_fills(context)
|
||||||
|
|
||||||
def _recalculate_fills(self, context):
|
def _recalculate_fills(self, context):
|
||||||
|
# Refresh each selected filling's mapped opening source before
|
||||||
|
# recutting the host. Dedup by source id covers the common shared-
|
||||||
|
# source case in one rewrite while leaving unrelated sibling sources
|
||||||
|
# untouched.
|
||||||
|
seen_source_ids: set[int] = set()
|
||||||
|
for obj in context.selected_objects:
|
||||||
|
element = tool.Ifc.get_entity(obj)
|
||||||
|
if not element or not element.FillsVoids:
|
||||||
|
continue
|
||||||
|
opening = element.FillsVoids[0].RelatingOpeningElement
|
||||||
|
body = tool.Geometry.get_body_representation(opening)
|
||||||
|
if body is None:
|
||||||
|
continue
|
||||||
|
source = tool.Geometry.resolve_mapped_representation(body)
|
||||||
|
if source.id() in seen_source_ids:
|
||||||
|
continue
|
||||||
|
seen_source_ids.add(source.id())
|
||||||
|
tool.Model.regenerate_filling_opening_body(element)
|
||||||
|
|
||||||
for obj in context.selected_objects:
|
for obj in context.selected_objects:
|
||||||
element = tool.Ifc.get_entity(obj)
|
element = tool.Ifc.get_entity(obj)
|
||||||
if not element or not element.FillsVoids:
|
if not element or not element.FillsVoids:
|
||||||
@@ -958,27 +986,29 @@ class EditOpenings(Operator, tool.Ifc.Operator):
|
|||||||
for opening_element in opening_elements:
|
for opening_element in opening_elements:
|
||||||
opening_obj = tool.Ifc.get_object(opening_element)
|
opening_obj = tool.Ifc.get_object(opening_element)
|
||||||
|
|
||||||
similar_openings = bonsai.core.geometry.get_similar_openings(tool.Ifc, opening_element)
|
|
||||||
similar_openings_building_objs = bonsai.core.geometry.get_similar_openings_building_objs(
|
|
||||||
tool.Ifc, similar_openings
|
|
||||||
)
|
|
||||||
building_objs.update(similar_openings_building_objs)
|
|
||||||
|
|
||||||
if opening_obj:
|
if opening_obj:
|
||||||
if tool.Ifc.is_edited(opening_obj):
|
opening_edited = tool.Ifc.is_edited(opening_obj)
|
||||||
tool.Geometry.run_geometry_update_representation(obj=opening_obj)
|
opening_moved = tool.Ifc.is_moved(opening_obj)
|
||||||
bonsai.core.geometry.edit_similar_opening_placement(
|
# Sibling walls only need a viewport-level refresh when the
|
||||||
tool.Geometry, opening_element, similar_openings
|
# opening's shape or placement actually changed — a pure
|
||||||
)
|
# show/hide toggle leaves them in their existing state.
|
||||||
elif tool.Ifc.is_moved(opening_obj):
|
if opening_edited or opening_moved:
|
||||||
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=opening_obj)
|
similar_openings = bonsai.core.geometry.get_similar_openings(tool.Ifc, opening_element)
|
||||||
|
similar_openings_building_objs = bonsai.core.geometry.get_similar_openings_building_objs(
|
||||||
|
tool.Ifc, similar_openings
|
||||||
|
)
|
||||||
|
building_objs.update(similar_openings_building_objs)
|
||||||
|
if opening_edited:
|
||||||
|
tool.Geometry.run_geometry_update_representation(obj=opening_obj)
|
||||||
|
else:
|
||||||
|
bonsai.core.geometry.edit_object_placement(
|
||||||
|
tool.Ifc, tool.Geometry, tool.Surveyor, obj=opening_obj
|
||||||
|
)
|
||||||
bonsai.core.geometry.edit_similar_opening_placement(
|
bonsai.core.geometry.edit_similar_opening_placement(
|
||||||
tool.Geometry, opening_element, similar_openings
|
tool.Geometry, opening_element, similar_openings
|
||||||
)
|
)
|
||||||
|
building_objs.update(self.get_all_building_objects_of_similar_openings(opening_element))
|
||||||
|
|
||||||
building_objs.update(
|
|
||||||
self.get_all_building_objects_of_similar_openings(opening_element)
|
|
||||||
) # NB this has nothing to do with clone similar_opening
|
|
||||||
tool.Ifc.unlink(element=opening_element)
|
tool.Ifc.unlink(element=opening_element)
|
||||||
if props.representation_obj == opening_obj:
|
if props.representation_obj == opening_obj:
|
||||||
props.representation_obj = None
|
props.representation_obj = None
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ from collections.abc import Iterable
|
|||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
import bpy
|
import bpy
|
||||||
|
import ifcopenshell.util.unit
|
||||||
from bpy.types import Panel
|
from bpy.types import Panel
|
||||||
|
|
||||||
import ifcopenshell.util.unit
|
|
||||||
import bonsai.bim
|
import bonsai.bim
|
||||||
import bonsai.tool as tool
|
import bonsai.tool as tool
|
||||||
from bonsai.bim.helper import prop_with_search
|
from bonsai.bim.helper import prop_with_search
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import bonsai.bim.handler
|
|||||||
import bonsai.core.geometry
|
import bonsai.core.geometry
|
||||||
import bonsai.core.root
|
import bonsai.core.root
|
||||||
import bonsai.tool as tool
|
import bonsai.tool as tool
|
||||||
from bonsai.bim.module.model.opening import FilledOpeningGenerator
|
from bonsai.bim.module.model.opening import FilledOpeningGenerator, is_filling_supported
|
||||||
|
|
||||||
|
|
||||||
class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
|
class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
|
||||||
@@ -34,11 +34,13 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
|
|||||||
bl_label = "Apply Opening"
|
bl_label = "Apply Opening"
|
||||||
bl_options = {"REGISTER", "UNDO"}
|
bl_options = {"REGISTER", "UNDO"}
|
||||||
bl_description = (
|
bl_description = (
|
||||||
"Apply opening objects to an Element.\n\n"
|
"Cuts openings in a wall, slab, or roof using selected shape objects — "
|
||||||
"The Element and the openings to be applied should be selected. The order of selection is not important.\n"
|
"doors, windows, existing openings, or plain (non-IFC) meshes. "
|
||||||
"Opening can be just a Blender mesh object.\n\n"
|
"Selection order doesn't matter.\n\n"
|
||||||
"Shift+click: keep the filling at its current matrix_world — skip the wall-axis snap "
|
"Doors and windows also fill the opening. Other IFC classes are currently "
|
||||||
"and the rl1/rl2 Z-elevation default that the regular click applies."
|
"unsupported by the opening generator and get skipped with a warning.\n\n"
|
||||||
|
"Shift+click: keep each opening at its shape object's current position "
|
||||||
|
"instead of snapping to the wall."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Toggled by ``invoke`` when the user holds SHIFT during a gizmo / hotkey
|
# Toggled by ``invoke`` when the user holds SHIFT during a gizmo / hotkey
|
||||||
@@ -84,8 +86,14 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
|
|||||||
self.report({"INFO"}, "You can't add an opening to another opening.")
|
self.report({"INFO"}, "You can't add an opening to another opening.")
|
||||||
continue
|
continue
|
||||||
elif not element1.is_a("IfcOpeningElement") and not element2.is_a("IfcOpeningElement"):
|
elif not element1.is_a("IfcOpeningElement") and not element2.is_a("IfcOpeningElement"):
|
||||||
if element1.is_a("IfcWindow") or element1.is_a("IfcDoor"): # Add a fill to an element.
|
if is_filling_supported(element1): # Add a fill to an element.
|
||||||
obj1, obj2 = obj2, obj1
|
obj1, obj2 = obj2, obj1
|
||||||
|
elif not is_filling_supported(element2):
|
||||||
|
self.report(
|
||||||
|
{"INFO"},
|
||||||
|
f"Cannot apply {element2.is_a()} as an opening — Bonsai currently supports only IfcDoor and IfcWindow as parametric fillings.",
|
||||||
|
)
|
||||||
|
continue
|
||||||
FilledOpeningGenerator().generate(
|
FilledOpeningGenerator().generate(
|
||||||
obj2,
|
obj2,
|
||||||
obj1,
|
obj1,
|
||||||
|
|||||||
@@ -2060,47 +2060,86 @@ class Model(bonsai.core.tool.Model):
|
|||||||
return (vertices, edges, faces)
|
return (vertices, edges, faces)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def update_simple_openings(cls, element: ifcopenshell.entity_instance) -> None:
|
def regenerate_filling_opening_body(cls, filling: ifcopenshell.entity_instance) -> Optional[bpy.types.Object]:
|
||||||
|
"""Regenerate only the mapped source used by ``filling``'s opening so
|
||||||
|
it matches ``filling``'s current parametric dimensions.
|
||||||
|
|
||||||
|
Returns the voided host Blender object so the caller can recut it,
|
||||||
|
or ``None`` if ``filling`` has no opening to refresh or the host is
|
||||||
|
an aggregate (no mesh data to recut against)."""
|
||||||
from bonsai.bim.module.model.opening import FilledOpeningGenerator
|
from bonsai.bim.module.model.opening import FilledOpeningGenerator
|
||||||
|
|
||||||
ifc_file = tool.Ifc.get()
|
if not filling.FillsVoids:
|
||||||
fillings = {e: tool.Ifc.get_object(e) for e in tool.Array.get_parametric_propagation_targets(element)}
|
return None
|
||||||
|
|
||||||
voided_objs = set()
|
ifc_file = tool.Ifc.get()
|
||||||
has_replaced_opening_representation = False
|
opening = filling.FillsVoids[0].RelatingOpeningElement
|
||||||
|
voided_obj = tool.Ifc.get_object(opening.VoidsElements[0].RelatingBuildingElement)
|
||||||
|
if voided_obj is None or voided_obj.data is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
old_representation = tool.Geometry.get_body_representation(opening)
|
||||||
|
if old_representation is None:
|
||||||
|
return voided_obj
|
||||||
|
old_representation = tool.Geometry.resolve_mapped_representation(old_representation)
|
||||||
|
|
||||||
|
ifcopenshell.api.geometry.unassign_representation(ifc_file, product=opening, representation=old_representation)
|
||||||
|
|
||||||
|
filling_obj = tool.Ifc.get_object(filling)
|
||||||
|
new_representation = FilledOpeningGenerator().generate_opening_from_filling(
|
||||||
|
filling, filling_obj, voided_obj.dimensions[1]
|
||||||
|
)
|
||||||
|
|
||||||
|
for inverse in ifc_file.get_inverse(old_representation):
|
||||||
|
ifcopenshell.util.element.replace_attribute(inverse, old_representation, new_representation)
|
||||||
|
|
||||||
|
ifcopenshell.api.geometry.remove_representation(ifc_file, representation=old_representation)
|
||||||
|
|
||||||
|
return voided_obj
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def regenerate_simple_opening_bodies(cls, element: ifcopenshell.entity_instance) -> set:
|
||||||
|
"""Regenerate every distinct mapped opening source within ``element``'s
|
||||||
|
type-occurrence family so each one matches the family's current
|
||||||
|
parametric dimensions.
|
||||||
|
|
||||||
|
Most occurrences share a single mapped source — refreshing it once
|
||||||
|
propagates to every filling via inverse-substitution. Some families,
|
||||||
|
especially those imported from foreign authoring tools, fragment into
|
||||||
|
several mapped sources for the same type; dedup is by source id so
|
||||||
|
every distinct source gets one refresh. Returns the set of Blender
|
||||||
|
objects whose host representation needs a viewport-level recut
|
||||||
|
(callers handle the recut themselves)."""
|
||||||
|
ifc_file = tool.Ifc.get()
|
||||||
|
fillings = list(tool.Array.get_parametric_propagation_targets(element))
|
||||||
|
|
||||||
|
voided_objs: set = set()
|
||||||
|
seen_source_ids: set[int] = set()
|
||||||
for filling in fillings:
|
for filling in fillings:
|
||||||
if not filling.FillsVoids:
|
if not filling.FillsVoids:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
opening = filling.FillsVoids[0].RelatingOpeningElement
|
opening = filling.FillsVoids[0].RelatingOpeningElement
|
||||||
voided_obj = tool.Ifc.get_object(opening.VoidsElements[0].RelatingBuildingElement)
|
voided_obj = tool.Ifc.get_object(opening.VoidsElements[0].RelatingBuildingElement)
|
||||||
voided_objs.add(voided_obj)
|
if voided_obj is not None:
|
||||||
|
voided_objs.add(voided_obj)
|
||||||
|
|
||||||
# We assume all occurrences of the same element type (e.g. a window)
|
body = tool.Geometry.get_body_representation(opening)
|
||||||
# will use openings of the same thickness.
|
if body is None:
|
||||||
# Generator we use by default will create a really thick opening representation
|
|
||||||
# to make sure it will fit for walls with different thickness.
|
|
||||||
if has_replaced_opening_representation:
|
|
||||||
continue
|
continue
|
||||||
|
source = tool.Geometry.resolve_mapped_representation(body)
|
||||||
|
if source.id() in seen_source_ids:
|
||||||
|
continue
|
||||||
|
seen_source_ids.add(source.id())
|
||||||
|
|
||||||
old_representation = ifcopenshell.util.representation.get_representation(
|
cls.regenerate_filling_opening_body(filling)
|
||||||
opening, "Model", "Body", "MODEL_VIEW"
|
|
||||||
)
|
|
||||||
old_representation = tool.Geometry.resolve_mapped_representation(old_representation)
|
|
||||||
ifcopenshell.api.geometry.unassign_representation(
|
|
||||||
ifc_file, product=opening, representation=old_representation
|
|
||||||
)
|
|
||||||
|
|
||||||
new_representation = FilledOpeningGenerator().generate_opening_from_filling(
|
return voided_objs
|
||||||
filling, fillings[filling], voided_obj.dimensions[1]
|
|
||||||
)
|
|
||||||
|
|
||||||
for inverse in ifc_file.get_inverse(old_representation):
|
@classmethod
|
||||||
ifcopenshell.util.element.replace_attribute(inverse, old_representation, new_representation)
|
def update_simple_openings(cls, element: ifcopenshell.entity_instance) -> None:
|
||||||
|
voided_objs = cls.regenerate_simple_opening_bodies(element)
|
||||||
ifcopenshell.api.geometry.remove_representation(ifc_file, representation=old_representation)
|
fillings = {e: tool.Ifc.get_object(e) for e in tool.Array.get_parametric_propagation_targets(element)}
|
||||||
|
|
||||||
has_replaced_opening_representation = True
|
|
||||||
|
|
||||||
tool.Model.reload_body_representation(voided_objs)
|
tool.Model.reload_body_representation(voided_objs)
|
||||||
if fillings:
|
if fillings:
|
||||||
@@ -3108,6 +3147,26 @@ class Model(bonsai.core.tool.Model):
|
|||||||
obj = tool.Ifc.get_object(rel.RelatingElement)
|
obj = tool.Ifc.get_object(rel.RelatingElement)
|
||||||
tool.Geometry.commit_placement_if_moved(obj)
|
tool.Geometry.commit_placement_if_moved(obj)
|
||||||
queue.add((rel.RelatingElement, obj))
|
queue.add((rel.RelatingElement, obj))
|
||||||
|
|
||||||
|
# Sync filling and opening placements so subsequent wall recuts
|
||||||
|
# operate on the up-to-date opening positions — a filling moved
|
||||||
|
# along the wall's reference line otherwise stays cut at its old
|
||||||
|
# spot.
|
||||||
|
for element, wall in queue:
|
||||||
|
if not wall:
|
||||||
|
continue
|
||||||
|
for rel in getattr(element, "HasOpenings", []) or []:
|
||||||
|
opening = rel.RelatedOpeningElement
|
||||||
|
for fill_rel in getattr(opening, "HasFillings", []) or []:
|
||||||
|
filling = fill_rel.RelatedBuildingElement
|
||||||
|
filling_obj = tool.Ifc.get_object(filling)
|
||||||
|
if filling_obj is None or not tool.Ifc.is_moved(filling_obj):
|
||||||
|
continue
|
||||||
|
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=filling_obj)
|
||||||
|
ifcopenshell.api.geometry.edit_object_placement(
|
||||||
|
tool.Ifc.get(), product=opening, matrix=filling_obj.matrix_world
|
||||||
|
)
|
||||||
|
|
||||||
for element, wall in queue:
|
for element, wall in queue:
|
||||||
if not wall:
|
if not wall:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -51,22 +51,29 @@ _IFC_CLASS_BY_KIND = {
|
|||||||
"slab": "IfcSlab",
|
"slab": "IfcSlab",
|
||||||
"roof": "IfcRoof",
|
"roof": "IfcRoof",
|
||||||
"plain": "IfcDiscreteAccessory",
|
"plain": "IfcDiscreteAccessory",
|
||||||
|
"door": "IfcDoor",
|
||||||
|
"window": "IfcWindow",
|
||||||
|
"opening": "IfcOpeningElement",
|
||||||
|
"covering": "IfcCovering",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class _FakeIfcEntity:
|
class _FakeIfcEntity:
|
||||||
"""Minimal stand-in for an ``ifcopenshell.entity_instance`` in poll tests.
|
"""Minimal stand-in for an ``ifcopenshell.entity_instance`` in poll tests.
|
||||||
|
|
||||||
Provides the two surfaces the gizmo's poll consults: ``is_a(type_name)``
|
Mirrors ``ifcopenshell.entity_instance.is_a``'s two call shapes:
|
||||||
(used directly by ``is_supported_host`` for slab/roof) and an optional
|
``is_a("Foo")`` returns True when the entity's class is ``Foo``, and
|
||||||
``HasOpenings`` attribute (probed by the poll's ``hasattr`` guard)."""
|
``is_a()`` returns the class name as a string. ``HasOpenings`` is
|
||||||
|
optional so the poll's ``hasattr`` guard branch is reachable."""
|
||||||
|
|
||||||
def __init__(self, ifc_class: str, has_openings: bool = True):
|
def __init__(self, ifc_class: str, has_openings: bool = True):
|
||||||
self._ifc_class = ifc_class
|
self._ifc_class = ifc_class
|
||||||
if has_openings:
|
if has_openings:
|
||||||
self.HasOpenings = ()
|
self.HasOpenings = ()
|
||||||
|
|
||||||
def is_a(self, type_name: str) -> bool:
|
def is_a(self, type_name: str | None = None):
|
||||||
|
if type_name is None:
|
||||||
|
return self._ifc_class
|
||||||
return self._ifc_class == type_name
|
return self._ifc_class == type_name
|
||||||
|
|
||||||
|
|
||||||
@@ -168,6 +175,40 @@ def test_poll_rejects_host_host_pairs(active_kind, other_kind, patched_tool):
|
|||||||
assert _run_poll(patched_tool, active_kind=active_kind, other_kind=other_kind) is False
|
assert _run_poll(patched_tool, active_kind=active_kind, other_kind=other_kind) is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("filling_kind", ["door", "window", "opening", "mesh"])
|
||||||
|
def test_poll_accepts_host_with_supported_filling(filling_kind, patched_tool):
|
||||||
|
"""The apply-opening gizmo must activate when the secondary selection
|
||||||
|
is a class the operator can dispatch on: ``IfcDoor`` / ``IfcWindow``
|
||||||
|
(filled openings), ``IfcOpeningElement`` (existing opening reassigned
|
||||||
|
to a new host), or a raw Blender mesh (converted to an opening)."""
|
||||||
|
assert _run_poll(patched_tool, active_kind="wall", other_kind=filling_kind) is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("non_filling_kind", ["covering", "plain"])
|
||||||
|
def test_poll_rejects_host_with_non_filling(non_filling_kind, patched_tool):
|
||||||
|
"""An IFC entity whose class the apply-opening operator can't dispatch
|
||||||
|
on must keep the gizmo hidden — clicking it would otherwise dispatch
|
||||||
|
the operator on a class whose geometry the opening generator can't
|
||||||
|
derive, causing a deep traceback in the geometry kernel."""
|
||||||
|
assert _run_poll(patched_tool, active_kind="wall", other_kind=non_filling_kind) is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("filling_kind", ["door", "window", "opening", "mesh"])
|
||||||
|
def test_poll_accepts_filling_active_with_host_other(filling_kind, patched_tool):
|
||||||
|
"""The poll must be selection-order independent: the icon should appear
|
||||||
|
whether the user clicked the host first or the filling first. The
|
||||||
|
operator handles either order, so the gizmo should match."""
|
||||||
|
assert _run_poll(patched_tool, active_kind=filling_kind, other_kind="wall") is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("non_filling_kind", ["covering", "plain"])
|
||||||
|
def test_poll_rejects_non_filling_active_with_host_other(non_filling_kind, patched_tool):
|
||||||
|
"""The selection-order independence must not loosen the filling
|
||||||
|
predicate — covering + wall stays rejected regardless of which is
|
||||||
|
active."""
|
||||||
|
assert _run_poll(patched_tool, active_kind=non_filling_kind, other_kind="wall") is False
|
||||||
|
|
||||||
|
|
||||||
def test_poll_rejects_active_host_without_has_openings(patched_tool):
|
def test_poll_rejects_active_host_without_has_openings(patched_tool):
|
||||||
# Real-world equivalent: an IFC class that the active schema strips
|
# Real-world equivalent: an IFC class that the active schema strips
|
||||||
# ``HasOpenings`` from (e.g., a non-element subtype). The active sentinel
|
# ``HasOpenings`` from (e.g., a non-element subtype). The active sentinel
|
||||||
@@ -265,12 +306,16 @@ def _run_position_layer3_branch(
|
|||||||
icon = SimpleNamespace(matrix_basis=None, hide=True)
|
icon = SimpleNamespace(matrix_basis=None, hide=True)
|
||||||
self_stub = SimpleNamespace(add_opening_icon=icon)
|
self_stub = SimpleNamespace(add_opening_icon=icon)
|
||||||
|
|
||||||
host_element = object()
|
# Host identification in the gizmo branches on the entity's class, so
|
||||||
|
# the sentinel must respond to ``is_a``. The non-host selection has no
|
||||||
|
# IFC entity (mesh-like) and is accepted as a filling.
|
||||||
|
host_element = _FakeIfcEntity("IfcSlab")
|
||||||
|
entity_map = {id(host_obj): host_element, id(other): None}
|
||||||
with contextlib.ExitStack() as stack:
|
with contextlib.ExitStack() as stack:
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patched_tool(
|
patched_tool(
|
||||||
selected_list=selected,
|
selected_list=selected,
|
||||||
entity=host_element,
|
entity=lambda o: entity_map.get(id(o)),
|
||||||
modifier_predicates={"is_path_connectable_wall": is_path_connectable_wall},
|
modifier_predicates={"is_path_connectable_wall": is_path_connectable_wall},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -297,6 +342,50 @@ def test_layer3_branch_always_parks_above_top_face(patched_tool, other_z):
|
|||||||
assert pos.z == pytest.approx(0.2 + BaseParametricGizmoGroup.ICON_Z_OFFSET)
|
assert pos.z == pytest.approx(0.2 + BaseParametricGizmoGroup.ICON_Z_OFFSET)
|
||||||
|
|
||||||
|
|
||||||
|
def test_position_gizmos_identifies_host_by_class_when_selected_second(patched_tool):
|
||||||
|
"""Host role in ``position_gizmos`` is resolved by IFC class, not by
|
||||||
|
active-object position — so a slab clicked SECOND (filling first,
|
||||||
|
host active or not) still anchors the icon correctly on the slab.
|
||||||
|
This pins the selection-order independence of the positioner (the
|
||||||
|
poll's independence is covered separately by the poll parametrize)."""
|
||||||
|
from bonsai.bim.module.drawing import gizmos as gizmo_module
|
||||||
|
from bonsai.bim.module.model.host_add_opening_gizmo import GizmoHostAddOpening
|
||||||
|
|
||||||
|
other = SimpleNamespace(matrix_world=Matrix.Translation(Vector((0.7, 0.4, 1.0))))
|
||||||
|
host_obj = SimpleNamespace(matrix_world=Matrix.Identity(4), bound_box=[(0.0, 0.0, 0.0), (0.0, 0.0, 0.2)] * 4)
|
||||||
|
# Host at index 1; the filling (no IFC entity) sits at index 0 as active.
|
||||||
|
selected = [other, host_obj]
|
||||||
|
context = SimpleNamespace(active_object=other)
|
||||||
|
icon = SimpleNamespace(matrix_basis=None, hide=True)
|
||||||
|
self_stub = SimpleNamespace(add_opening_icon=icon)
|
||||||
|
|
||||||
|
entity_map = {id(host_obj): _FakeIfcEntity("IfcSlab"), id(other): None}
|
||||||
|
with contextlib.ExitStack() as stack:
|
||||||
|
stack.enter_context(
|
||||||
|
patched_tool(
|
||||||
|
selected_list=selected,
|
||||||
|
entity=lambda o: entity_map.get(id(o)),
|
||||||
|
modifier_predicates={"is_path_connectable_wall": False},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stack.enter_context(patch.object(gizmo_module, "get_billboard_rotation", return_value=Matrix.Identity(4)))
|
||||||
|
stack.enter_context(
|
||||||
|
patch.object(
|
||||||
|
gizmo_module, "billboarded_at", side_effect=lambda pos, rot, scale=0.5: Matrix.Translation(pos)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
GizmoHostAddOpening.position_gizmos(self_stub, context)
|
||||||
|
|
||||||
|
# Icon anchors on the host's top face (slab bound_box top-Z = 0.2) at
|
||||||
|
# the void's XY — same result as when the host was at index 0.
|
||||||
|
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
|
||||||
|
|
||||||
|
pos = icon.matrix_basis.translation
|
||||||
|
assert pos.x == pytest.approx(0.7)
|
||||||
|
assert pos.y == pytest.approx(0.4)
|
||||||
|
assert pos.z == pytest.approx(0.2 + BaseParametricGizmoGroup.ICON_Z_OFFSET)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# is_supported_host() — predicate totality
|
# is_supported_host() — predicate totality
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
"""AST contract: ``RecalculateFill`` must invoke
|
||||||
|
``regenerate_simple_opening_bodies`` before recutting hosts.
|
||||||
|
|
||||||
|
Hosts recut with a surgical mesh-only path don't refresh the shared mapped
|
||||||
|
opening source — so any change to a parametric filling's dimensions stays
|
||||||
|
invisible at the opening boundary until the body representation is
|
||||||
|
regenerated. Pinning the call site forces future refactors to keep the
|
||||||
|
regen step in place."""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.model
|
||||||
|
|
||||||
|
|
||||||
|
def _recalculate_fill_body_source() -> str:
|
||||||
|
from bonsai.bim.module.model import opening as opening_module
|
||||||
|
|
||||||
|
source = Path(opening_module.__file__).read_text(encoding="utf-8")
|
||||||
|
tree = ast.parse(source)
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.ClassDef) and node.name == "RecalculateFill":
|
||||||
|
for child in node.body:
|
||||||
|
if isinstance(child, ast.FunctionDef) and child.name == "_recalculate_fills":
|
||||||
|
return ast.unparse(child)
|
||||||
|
raise AssertionError("RecalculateFill._recalculate_fills was not found in opening.py")
|
||||||
|
|
||||||
|
|
||||||
|
def test_recalculate_fill_regenerates_opening_bodies_before_recut():
|
||||||
|
body = _recalculate_fill_body_source()
|
||||||
|
assert "regenerate_filling_opening_body" in body, (
|
||||||
|
"RecalculateFill._recalculate_fills must call "
|
||||||
|
"tool.Model.regenerate_filling_opening_body for each selected "
|
||||||
|
"filling before recutting the host. Without that call the host is "
|
||||||
|
"recut against a stale shared mapped opening source, so changes "
|
||||||
|
"to filling dimensions never surface."
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user