mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-12 14:33:28 +00:00
c3abe0b3c7
Fix three issues in generate_space: 1. Z location drift: z was derived from the Blender bounding box, which changes after every regeneration. Use active_obj.location.z instead. 2. Cache invalidation for moved roofs/slabs: commit placements for HEIGHT_DETECTION_CLASSES in addition to BOUNDING_CLASSES so the geometry cache reflects recent moves. 3. Non-deterministic regeneration: the old Body representation was still present in the IFC file when get_space_volume_strategy built the geometry tree, so ray hits from get_vertical_bounding_planes hit the space's own body. Since each regeneration produced a different Body (BooleanClippingResult/FacetedBrep), the strategy alternated between EXTRUDE_CLIP and BREP. Remove all Body representations before strategy detection so the tree only contains bounding elements. Also clean up stale IfcRelSpaceBoundary relationships before each regeneration to prevent old boundary references from contaminating subsequent runs. Remove ALL existing Body representations (not just the first one found) to prevent duplicate half-space clipping chains. Add regression tests including a 5-iteration stability check. Generated with the assistance of an AI coding tool.
1812 lines
77 KiB
Python
1812 lines
77 KiB
Python
# Bonsai - OpenBIM Blender Add-on
|
|
# Copyright (C) 2021 Dion Moult <dion@thinkmoult.com>
|
|
#
|
|
# This file is part of Bonsai.
|
|
#
|
|
# Bonsai is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License as published by
|
|
# the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# Bonsai is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import multiprocessing
|
|
from collections import defaultdict
|
|
from collections.abc import Generator, Iterable
|
|
from typing import TYPE_CHECKING, Any, Literal, Optional, Union
|
|
|
|
import bmesh
|
|
import bpy
|
|
import ifcopenshell
|
|
import ifcopenshell.api.attribute
|
|
import ifcopenshell.api.geometry
|
|
import ifcopenshell.api.type
|
|
import ifcopenshell.geom
|
|
import ifcopenshell.util.classification
|
|
import ifcopenshell.util.element
|
|
import ifcopenshell.util.placement
|
|
import ifcopenshell.util.representation
|
|
import ifcopenshell.util.shape
|
|
import ifcopenshell.util.shape_builder
|
|
import ifcopenshell.util.space
|
|
import ifcopenshell.util.type
|
|
import ifcopenshell.util.unit
|
|
import numpy as np
|
|
import shapely
|
|
import shapely.affinity
|
|
import shapely.ops
|
|
from mathutils import Matrix, Vector
|
|
from natsort import natsorted
|
|
from shapely import Polygon
|
|
|
|
import bonsai.core.geometry
|
|
import bonsai.core.root
|
|
import bonsai.core.spatial
|
|
import bonsai.core.tool
|
|
import bonsai.core.type
|
|
import bonsai.tool as tool
|
|
|
|
if TYPE_CHECKING:
|
|
from bonsai.bim.module.spatial.prop import (
|
|
BIMGridProperties,
|
|
BIMObjectSpatialProperties,
|
|
BIMSpatialDecompositionProperties,
|
|
)
|
|
|
|
_GEOM_CACHE_TOKEN = 0
|
|
|
|
|
|
@bpy.app.handlers.persistent
|
|
def _bump_geom_cache_token(*args) -> None:
|
|
global _GEOM_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
|
|
_GEOM_CACHE_TOKEN += 1
|
|
|
|
|
|
def install_geom_cache_handlers() -> None:
|
|
for hook in (
|
|
bpy.app.handlers.depsgraph_update_post,
|
|
bpy.app.handlers.undo_post,
|
|
bpy.app.handlers.redo_post,
|
|
bpy.app.handlers.load_post,
|
|
):
|
|
if _bump_geom_cache_token not in hook:
|
|
hook.append(_bump_geom_cache_token)
|
|
|
|
|
|
def uninstall_geom_cache_handlers() -> None:
|
|
for hook in (
|
|
bpy.app.handlers.depsgraph_update_post,
|
|
bpy.app.handlers.undo_post,
|
|
bpy.app.handlers.redo_post,
|
|
bpy.app.handlers.load_post,
|
|
):
|
|
try:
|
|
hook.remove(_bump_geom_cache_token)
|
|
except ValueError:
|
|
pass
|
|
|
|
|
|
class Spatial(bonsai.core.tool.Spatial):
|
|
_geom_cache: dict = {}
|
|
|
|
@classmethod
|
|
def get_spatial_props(cls) -> BIMSpatialDecompositionProperties:
|
|
return bpy.context.scene.BIMSpatialDecompositionProperties
|
|
|
|
@classmethod
|
|
def get_object_spatial_props(cls, obj: bpy.types.Object) -> BIMObjectSpatialProperties:
|
|
return obj.BIMObjectSpatialProperties
|
|
|
|
@classmethod
|
|
def get_grid_props(cls) -> BIMGridProperties:
|
|
return bpy.context.scene.BIMGridProperties
|
|
|
|
@classmethod
|
|
def get_decomposition(cls, element: ifcopenshell.entity_instance) -> list(ifcopenshell.entity_instance):
|
|
return ifcopenshell.util.element.get_decomposition(element)
|
|
|
|
@classmethod
|
|
def get_root_element(cls, element: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance:
|
|
while True:
|
|
if parent := (
|
|
ifcopenshell.util.element.get_aggregate(element) or ifcopenshell.util.element.get_nest(element)
|
|
):
|
|
element = parent
|
|
else:
|
|
break
|
|
return element
|
|
|
|
@classmethod
|
|
def get_host_element(cls, filling: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance | None:
|
|
"""The building element that hosts a filling (door/window) via the
|
|
standard ``FillsVoids → RelatingOpeningElement → VoidsElements →
|
|
RelatingBuildingElement`` chain, with safety guards at each hop.
|
|
Returns ``None`` if any link is missing, or if the given entity is
|
|
not a fillable type (no ``FillsVoids`` inverse).
|
|
|
|
For the wall-only case (gizmos that only make sense on walls), use
|
|
`get_host_wall` which adds an ``IfcWall`` type filter on top of this."""
|
|
if not getattr(filling, "FillsVoids", None):
|
|
return None
|
|
opening = filling.FillsVoids[0].RelatingOpeningElement
|
|
if not opening.VoidsElements:
|
|
return None
|
|
return opening.VoidsElements[0].RelatingBuildingElement
|
|
|
|
@classmethod
|
|
def get_host_wall(cls, filling: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance | None:
|
|
"""The ``IfcWall`` that hosts a filling (door/window), or ``None``.
|
|
|
|
Walls only — fillings hosted in slabs / roofs / arbitrary elements
|
|
produce ``None`` so wall-offset callers stay opted out cleanly."""
|
|
host = cls.get_host_element(filling)
|
|
return host if host and host.is_a("IfcWall") else None
|
|
|
|
@classmethod
|
|
def can_contain(cls, container: ifcopenshell.entity_instance, element: ifcopenshell.entity_instance) -> bool:
|
|
if tool.Ifc.get_schema() == "IFC2X3":
|
|
if not container.is_a("IfcSpatialStructureElement"):
|
|
return False
|
|
else:
|
|
if not container.is_a("IfcSpatialStructureElement") and not container.is_a(
|
|
"IfcExternalSpatialStructureElement"
|
|
):
|
|
return False
|
|
if not hasattr(element, "ContainedInStructure"):
|
|
return False
|
|
return True
|
|
|
|
@classmethod
|
|
def can_reference(
|
|
cls, structure: Union[ifcopenshell.entity_instance, None], element: Union[ifcopenshell.entity_instance, None]
|
|
) -> bool:
|
|
if not structure or not element:
|
|
return False
|
|
if tool.Ifc.get_schema() == "IFC2X3":
|
|
if not structure.is_a("IfcSpatialStructureElement"):
|
|
return False
|
|
else:
|
|
if not structure.is_a("IfcSpatialElement"):
|
|
return False
|
|
if not hasattr(element, "ReferencedInStructures"):
|
|
return False
|
|
return True
|
|
|
|
@classmethod
|
|
def disable_editing(cls, obj: bpy.types.Object) -> None:
|
|
props = cls.get_object_spatial_props(obj)
|
|
props.is_editing = False
|
|
|
|
@classmethod
|
|
def duplicate_object_and_data(cls, obj: bpy.types.Object) -> bpy.types.Object:
|
|
new_obj = obj.copy()
|
|
if obj.data:
|
|
new_obj.data = obj.data.copy()
|
|
return new_obj
|
|
|
|
@classmethod
|
|
def enable_editing(cls, obj: bpy.types.Object) -> None:
|
|
props = cls.get_object_spatial_props(obj)
|
|
props.is_editing = True
|
|
props.relating_container_object = None
|
|
|
|
@classmethod
|
|
def get_container(cls, element: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]:
|
|
return ifcopenshell.util.element.get_container(element)
|
|
|
|
@classmethod
|
|
def get_decomposed_elements(
|
|
cls, container: ifcopenshell.entity_instance, is_recursive=True
|
|
) -> list[ifcopenshell.entity_instance]:
|
|
return ifcopenshell.util.element.get_decomposition(container, is_recursive=is_recursive)
|
|
|
|
@classmethod
|
|
def get_object_matrix(cls, obj: bpy.types.Object) -> Matrix:
|
|
return obj.matrix_world
|
|
|
|
@classmethod
|
|
def get_relative_object_matrix(cls, target_obj: bpy.types.Object, relative_to_obj: bpy.types.Object) -> Matrix:
|
|
return relative_to_obj.matrix_world.inverted() @ target_obj.matrix_world
|
|
|
|
@classmethod
|
|
def run_root_copy_class(cls, obj: bpy.types.Object) -> ifcopenshell.entity_instance:
|
|
return bonsai.core.root.copy_class(tool.Ifc, tool.Collector, tool.Geometry, tool.Root, obj=obj)
|
|
|
|
@classmethod
|
|
def run_spatial_assign_container(
|
|
cls, container: ifcopenshell.entity_instance, objs: list[bpy.types.Object]
|
|
) -> Union[ifcopenshell.entity_instance, None]:
|
|
return bonsai.core.spatial.assign_container(
|
|
tool.Ifc, tool.Collector, tool.Spatial, container=container, objs=objs
|
|
)
|
|
|
|
@classmethod
|
|
def run_spatial_import_spatial_decomposition(cls) -> None:
|
|
return bonsai.core.spatial.import_spatial_decomposition(tool.Spatial)
|
|
|
|
@classmethod
|
|
def select_object(cls, obj: bpy.types.Object) -> None:
|
|
tool.Blender.select_object(obj)
|
|
|
|
@classmethod
|
|
def set_active_object(cls, obj: bpy.types.Object, selection_mode: str = "ADD") -> None:
|
|
if selection_mode == "ADD":
|
|
tool.Blender.set_active_object(obj)
|
|
elif selection_mode == "REMOVE":
|
|
tool.Blender.deselect_object(obj)
|
|
else:
|
|
tool.Blender.select_and_activate_single_object(bpy.context, obj)
|
|
|
|
@classmethod
|
|
def set_relative_object_matrix(
|
|
cls, target_obj: bpy.types.Object, relative_to_obj: bpy.types.Object, matrix: Matrix
|
|
) -> None:
|
|
target_obj.matrix_world = relative_to_obj.matrix_world @ matrix
|
|
|
|
@classmethod
|
|
def select_products(cls, products: Iterable[ifcopenshell.entity_instance], unhide: bool = False) -> None:
|
|
assert (view_layer := bpy.context.view_layer)
|
|
# Update view layer, otherwise `objects` might be missing just created objects.
|
|
view_layer.update()
|
|
for product in products:
|
|
obj = tool.Ifc.get_object(product)
|
|
if obj and view_layer.objects.get(obj.name):
|
|
if unhide:
|
|
obj.hide_set(False)
|
|
obj.select_set(True)
|
|
|
|
@classmethod
|
|
def filter_products(
|
|
cls, products: list[ifcopenshell.entity_instance], action: Literal["select", "isolate", "unhide", "hide"]
|
|
) -> None:
|
|
objects = [obj for product in products if (obj := tool.Ifc.get_object(product))]
|
|
if action == "select":
|
|
[obj.select_set(True) for obj in objects]
|
|
elif action == "isolate":
|
|
[obj.hide_set(False) for obj in objects if bpy.context.view_layer.objects.get(obj.name)]
|
|
[
|
|
obj.hide_set(True)
|
|
for obj in bpy.context.visible_objects
|
|
if not obj in objects and bpy.context.view_layer.objects.get(obj.name)
|
|
] # this is slow
|
|
elif action == "unhide":
|
|
[obj.hide_set(False) for obj in objects if bpy.context.view_layer.objects.get(obj.name)]
|
|
elif action == "hide":
|
|
[obj.hide_set(True) for obj in objects if bpy.context.view_layer.objects.get(obj.name)]
|
|
|
|
@classmethod
|
|
def deselect_objects(cls) -> None:
|
|
[obj.select_set(False) for obj in bpy.context.selected_objects]
|
|
|
|
@classmethod
|
|
def show_scene_objects(cls) -> None:
|
|
[
|
|
obj.hide_set(False)
|
|
for obj in bpy.data.scenes["Scene"].objects
|
|
if bpy.context.view_layer.objects.get(obj.name)
|
|
]
|
|
|
|
@classmethod
|
|
def get_selected_products(cls) -> Generator[ifcopenshell.entity_instance, None, None]:
|
|
for obj in bpy.context.selected_objects:
|
|
entity = tool.Ifc.get_entity(obj)
|
|
if entity and entity.is_a("IfcProduct"):
|
|
yield entity
|
|
|
|
@classmethod
|
|
def get_selected_product_types(cls) -> Generator[ifcopenshell.entity_instance, None, None]:
|
|
for obj in tool.Blender.get_selected_objects():
|
|
entity = tool.Ifc.get_entity(obj)
|
|
if entity and entity.is_a("IfcTypeProduct"):
|
|
yield entity
|
|
|
|
@classmethod
|
|
def copy_xy(cls, src_obj: bpy.types.Object, destination_obj: bpy.types.Object) -> None:
|
|
src_obj.location.xy = destination_obj.location.xy
|
|
|
|
@classmethod
|
|
def get_container_elements_grouped_by_classification(cls, container: ifcopenshell.entity_instance) -> dict:
|
|
props = cls.get_spatial_props()
|
|
results = {}
|
|
if props.should_include_children:
|
|
elements = ifcopenshell.util.element.get_decomposition(container, is_recursive=True)
|
|
else:
|
|
elements = set(ifcopenshell.util.element.get_contained(container))
|
|
for e in elements:
|
|
elements.update(ifcopenshell.util.element.get_decomposition(e))
|
|
flat_results: dict[str, Any] = {}
|
|
reference_names: dict[str, str] = {}
|
|
for element in elements:
|
|
if element.is_a("IfcOpeningElement") or tool.Root.is_spatial_element(element):
|
|
continue
|
|
references = ifcopenshell.util.classification.get_references(element)
|
|
if not references:
|
|
flat_results.setdefault("Unclassified", []).append(element)
|
|
else:
|
|
for reference in references:
|
|
identification = reference[1] or ""
|
|
reference_names[identification] = reference[2]
|
|
flat_results.setdefault(identification, []).append(element)
|
|
|
|
for flat_key in sorted(flat_results.keys()):
|
|
current_results = results
|
|
|
|
while True:
|
|
has_parent = None
|
|
new_current_results = None
|
|
for key in current_results:
|
|
if flat_key.startswith(key):
|
|
has_parent = True
|
|
new_current_results = current_results[key]["children"]
|
|
break
|
|
if has_parent:
|
|
assert new_current_results is not None
|
|
current_results = new_current_results
|
|
else:
|
|
break
|
|
|
|
current_results[flat_key] = {
|
|
"Name": "Unclassified" if flat_key == "Unclassified" else reference_names[flat_key],
|
|
"elements": flat_results[flat_key],
|
|
"children": {},
|
|
}
|
|
|
|
return results
|
|
|
|
@classmethod
|
|
def get_container_elements_grouped_by_type(cls, container: ifcopenshell.entity_instance) -> dict:
|
|
props = cls.get_spatial_props()
|
|
results: defaultdict[str, dict[int, Any]] = defaultdict(dict)
|
|
if props.should_include_children:
|
|
elements = ifcopenshell.util.element.get_decomposition(container, is_recursive=True)
|
|
else:
|
|
queue = list(set(ifcopenshell.util.element.get_contained(container)))
|
|
elements = set()
|
|
while queue:
|
|
item = queue.pop()
|
|
elements.add(item)
|
|
queue.extend(ifcopenshell.util.element.get_decomposition(item))
|
|
for element in elements:
|
|
if element.is_a("IfcOpeningElement") or tool.Root.is_spatial_element(element):
|
|
continue
|
|
element_type = ifcopenshell.util.element.get_type(element)
|
|
ifc_class = element.is_a()
|
|
ifc_definition_id = element_type.id() if element_type else 0
|
|
type_name = (
|
|
element_type.is_a() + "/" + (element_type.Name or "Unnamed")
|
|
if element_type
|
|
else f"Untyped {element.is_a()}"
|
|
)
|
|
class_data = results.setdefault(ifc_class, {})
|
|
type_data = class_data.setdefault(ifc_definition_id, {"type_name": type_name, "elements": []})
|
|
type_data["elements"].append(element)
|
|
return results
|
|
|
|
@classmethod
|
|
def load_contained_elements(cls) -> None:
|
|
props = cls.get_spatial_props()
|
|
props.elements.clear()
|
|
if not (container := props.active_container):
|
|
return
|
|
|
|
container = tool.Ifc.get().by_id(container.ifc_definition_id)
|
|
if props.element_mode == "TYPE":
|
|
cls.load_contained_elements_by_type(container)
|
|
elif props.element_mode == "DECOMPOSITION":
|
|
cls.load_contained_elements_by_decomposition(container)
|
|
elif props.element_mode == "CLASSIFICATION":
|
|
cls.load_contained_elements_by_classification(container)
|
|
|
|
@classmethod
|
|
def load_contained_elements_by_type(cls, container: ifcopenshell.entity_instance) -> None:
|
|
props = cls.get_spatial_props()
|
|
results = cls.get_container_elements_grouped_by_type(container)
|
|
|
|
expanded_elements = json.loads(props.expanded_elements)
|
|
expanded_classes = expanded_elements.get("CLASS", [])
|
|
expanded_ifc_ids = expanded_elements.get("IFC_ID", [])
|
|
expanded_untyped = expanded_elements.get("UNTYPED_CLASSES", [])
|
|
expanded_classes_r = expanded_elements.get("CLASS_R", [])
|
|
|
|
total_elements = 0
|
|
for ifc_class in sorted(results.keys()):
|
|
new = props.elements.add()
|
|
new.name = ifc_class
|
|
new.type = "CLASS"
|
|
new.has_children = True
|
|
class_is_expanded = ifc_class in expanded_classes
|
|
class_is_expanded_r = ifc_class in expanded_classes_r
|
|
new.is_expanded = class_is_expanded or class_is_expanded_r
|
|
total = 0
|
|
for ifc_definition_id in sorted(
|
|
results[ifc_class].keys(), key=lambda x: results[ifc_class][x]["type_name"]
|
|
):
|
|
type_data = results[ifc_class][ifc_definition_id]
|
|
total2 = len(type_data["elements"])
|
|
if new.is_expanded:
|
|
new2 = props.elements.add()
|
|
new2.type = "TYPE"
|
|
new2.has_children = True
|
|
new2.level = 1
|
|
new2.name = type_data["type_name"]
|
|
new2.ifc_class = ifc_class
|
|
new2.total = total2
|
|
new2.ifc_definition_id = ifc_definition_id
|
|
if ifc_definition_id == 0:
|
|
type_is_expanded = ifc_class in expanded_untyped
|
|
else:
|
|
type_is_expanded = ifc_definition_id in expanded_ifc_ids
|
|
new2.is_expanded = type_is_expanded or class_is_expanded_r
|
|
|
|
if new2.is_expanded:
|
|
for element in type_data["elements"]:
|
|
occurrence = props.elements.add()
|
|
occurrence.name = element.Name or "Unnamed"
|
|
occurrence.level = 2
|
|
occurrence.ifc_definition_id = element.id()
|
|
occurrence.type = "OCCURRENCE"
|
|
|
|
total += total2
|
|
new.total = total
|
|
total_elements += total
|
|
|
|
props.total_elements = total_elements
|
|
|
|
@classmethod
|
|
def load_contained_elements_by_decomposition(cls, container: ifcopenshell.entity_instance) -> None:
|
|
props = cls.get_spatial_props()
|
|
expanded_elements = json.loads(props.expanded_elements)
|
|
expanded_ifc_ids = expanded_elements.get("IFC_ID", [])
|
|
|
|
def add_elements(elements, level=0):
|
|
for element in sorted(elements, key=lambda x: f"{x.is_a()}/{x.Name or 'Unnamed'}"):
|
|
if not props.should_include_children and tool.Root.is_spatial_element(element):
|
|
continue
|
|
ifc_definition_id = element.id()
|
|
new = props.elements.add()
|
|
new.name = f"{element.is_a()}/{element.Name or 'Unnamed'}"
|
|
new.level = level
|
|
new.ifc_definition_id = ifc_definition_id
|
|
new.type = "OCCURRENCE"
|
|
children = [
|
|
e
|
|
for e in ifcopenshell.util.element.get_decomposition(element, is_recursive=False)
|
|
if not e.is_a("IfcFeatureElement")
|
|
]
|
|
if children:
|
|
new.has_children = True
|
|
new.total = len(children)
|
|
if ifc_definition_id in expanded_ifc_ids:
|
|
new.is_expanded = True
|
|
add_elements(children, level=level + 1)
|
|
|
|
elements = ifcopenshell.util.element.get_decomposition(container, is_recursive=False)
|
|
add_elements(elements)
|
|
|
|
@classmethod
|
|
def load_contained_elements_by_classification(cls, container: ifcopenshell.entity_instance) -> None:
|
|
props = cls.get_spatial_props()
|
|
expanded_elements = json.loads(props.expanded_elements)
|
|
expanded_classifications = expanded_elements.get("CLASSIFICATION", [])
|
|
expanded_classifications_r = expanded_elements.get("CLASSIFICATION_R", [])
|
|
|
|
def add_elements(results, level=0):
|
|
for identification in sorted(results.keys()):
|
|
data = results[identification]
|
|
new = props.elements.add()
|
|
new.name = f"{identification}:{data['Name']}"
|
|
new.type = "CLASSIFICATION"
|
|
new.identification = identification
|
|
new.has_children = True
|
|
new.level = level
|
|
|
|
is_expanded = identification in expanded_classifications
|
|
is_expanded_r = any([c for c in expanded_classifications_r if identification.startswith(c)])
|
|
|
|
if is_expanded or is_expanded_r:
|
|
new.is_expanded = True
|
|
add_elements(data["children"], level=level + 1)
|
|
|
|
for element in sorted(data["elements"], key=lambda x: f"{x.is_a()}/{x.Name or 'Unnamed'}"):
|
|
new2 = props.elements.add()
|
|
new2.name = f"{element.is_a()}/{element.Name or 'Unnamed'}"
|
|
new2.type = "OCCURRENCE"
|
|
new2.level = level + 1
|
|
new2.ifc_definition_id = element.id()
|
|
|
|
results = cls.get_container_elements_grouped_by_classification(container)
|
|
add_elements(results)
|
|
|
|
@classmethod
|
|
def filter_elements(
|
|
cls,
|
|
elements: Iterable[ifcopenshell.entity_instance],
|
|
ifc_class: str | None,
|
|
relating_type: ifcopenshell.entity_instance | None,
|
|
is_untyped: bool,
|
|
keyword: str | None,
|
|
) -> filter[ifcopenshell.entity_instance]:
|
|
keyword = keyword.lower() if keyword else keyword
|
|
|
|
def filter_element(element: ifcopenshell.entity_instance) -> bool:
|
|
if ifc_class:
|
|
if not element.is_a(ifc_class):
|
|
return False
|
|
element_type = ifcopenshell.util.element.get_type(element)
|
|
if relating_type:
|
|
if relating_type != element_type:
|
|
return False
|
|
if is_untyped:
|
|
if element_type is not None:
|
|
return False
|
|
if keyword:
|
|
type_name = getattr(element_type, "Name", "") or ""
|
|
if keyword not in f"{element.is_a()} {type_name}".lower():
|
|
return False
|
|
return True
|
|
|
|
return filter(filter_element, elements)
|
|
|
|
@classmethod
|
|
def import_spatial_decomposition(cls) -> None:
|
|
props = cls.get_spatial_props()
|
|
previous_container_index = props.active_container_index
|
|
props.containers.clear()
|
|
cls.contracted_containers = json.loads(props.contracted_containers)
|
|
cls.import_spatial_element(tool.Ifc.get().by_type("IfcProject")[0], 0)
|
|
props.active_container_index = tool.Blender.get_valid_uilist_index(previous_container_index, props.containers)
|
|
|
|
@classmethod
|
|
def import_spatial_element(cls, element: ifcopenshell.entity_instance, level_index: int) -> None:
|
|
if not element.is_a("IfcProject") and not tool.Root.is_spatial_element(element):
|
|
return
|
|
props = cls.get_spatial_props()
|
|
new = props.containers.add()
|
|
new.ifc_class = element.is_a()
|
|
new["name"] = element.Name or "Unnamed"
|
|
new.description = element.Description or ""
|
|
new.long_name = element.LongName or ""
|
|
if not element.is_a("IfcProject"):
|
|
elevation = ifcopenshell.util.placement.get_storey_elevation(element)
|
|
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
|
elevation_in_meters = elevation * unit_scale
|
|
new.elevation = tool.Unit.format_distance(elevation_in_meters)
|
|
new.is_expanded = element.id() not in cls.contracted_containers
|
|
new.level_index = level_index
|
|
children = ifcopenshell.util.element.get_parts(element)
|
|
if children:
|
|
children = natsorted(
|
|
children,
|
|
key=lambda element: (ifcopenshell.util.placement.get_storey_elevation(element), element.Name),
|
|
)
|
|
new.has_children = bool(children)
|
|
new.ifc_definition_id = element.id()
|
|
if new.is_expanded:
|
|
for child in children or []:
|
|
cls.import_spatial_element(child, level_index + 1)
|
|
|
|
@classmethod
|
|
def create_orientation_slot(cls, container: ifcopenshell.entity_instance) -> None:
|
|
active_slot = bpy.context.scene.transform_orientation_slots[0]
|
|
placement = container.ObjectPlacement
|
|
combined_matrix = ifcopenshell.util.placement.get_local_placement(placement)[:3, :3]
|
|
|
|
if np.allclose(combined_matrix, np.eye(3), atol=1e-6):
|
|
# this spatial element has global orientation
|
|
active_slot.type = "GLOBAL"
|
|
return
|
|
elif (
|
|
hasattr(container, "Decomposes")
|
|
and container.Decomposes
|
|
and hasattr(container.Decomposes[0].RelatingObject, "ObjectPlacement")
|
|
):
|
|
# this spatial element is part of a decomposition
|
|
parent_placement = container.Decomposes[0].RelatingObject.ObjectPlacement
|
|
parent_matrix = ifcopenshell.util.placement.get_local_placement(parent_placement)[:3, :3]
|
|
if np.allclose(combined_matrix, parent_matrix, atol=1e-6):
|
|
# this spatial element has the same orientation as its parent
|
|
cls.create_orientation_slot(container=container.Decomposes[0].RelatingObject)
|
|
return
|
|
|
|
# this spatial element has a unique orientation
|
|
orientation_name = container.is_a() + "/" + container.Name
|
|
|
|
# stash selected objects
|
|
active_object = bpy.context.view_layer.objects.active
|
|
selected_objects = list(bpy.context.view_layer.objects.selected)
|
|
|
|
# bpy.ops.transform.create_orientation() requires a dummy object
|
|
bpy.ops.object.empty_add(type="PLAIN_AXES")
|
|
bpy.ops.transform.create_orientation(name=orientation_name, overwrite=True)
|
|
active_slot.type = orientation_name
|
|
active_slot.custom_orientation.matrix = np.linalg.inv(combined_matrix)
|
|
|
|
# delete dummy object
|
|
bpy.ops.object.delete()
|
|
|
|
# reinstate selected objects
|
|
for obj in selected_objects:
|
|
obj.select_set(True)
|
|
if active_object:
|
|
bpy.context.view_layer.objects.active = active_object
|
|
|
|
@classmethod
|
|
def edit_container_name(cls, container: ifcopenshell.entity_instance, name: str) -> None:
|
|
ifcopenshell.api.attribute.edit_attributes(tool.Ifc.get(), product=container, attributes={"Name": name})
|
|
|
|
@classmethod
|
|
def get_active_container(cls) -> Union[ifcopenshell.entity_instance, None]:
|
|
props = cls.get_spatial_props()
|
|
if active_container := props.active_container:
|
|
container = tool.Ifc.get().by_id(active_container.ifc_definition_id)
|
|
return container
|
|
|
|
@classmethod
|
|
def contract_container(cls, container: ifcopenshell.entity_instance, is_recursive: bool) -> None:
|
|
props = cls.get_spatial_props()
|
|
contracted_containers = set(json.loads(props.contracted_containers))
|
|
queue = [container]
|
|
while queue:
|
|
item = queue.pop()
|
|
if is_recursive and (children := ifcopenshell.util.element.get_parts(item)):
|
|
queue.extend(children)
|
|
contracted_containers.add(item.id())
|
|
props.contracted_containers = json.dumps(list(contracted_containers))
|
|
|
|
@classmethod
|
|
def expand_container(cls, container: ifcopenshell.entity_instance, is_recursive: bool) -> None:
|
|
props = cls.get_spatial_props()
|
|
contracted_containers = set(json.loads(props.contracted_containers))
|
|
queue = [container]
|
|
while queue:
|
|
item = queue.pop()
|
|
if is_recursive and (children := ifcopenshell.util.element.get_parts(item)):
|
|
queue.extend(children)
|
|
contracted_containers.discard(item.id())
|
|
props.contracted_containers = json.dumps(list(contracted_containers))
|
|
|
|
@classmethod
|
|
def toggle_container_element(cls, element_index: int, is_recursive: bool) -> None:
|
|
props = cls.get_spatial_props()
|
|
if props.element_mode == "TYPE":
|
|
cls.toggle_container_element_by_type(element_index, is_recursive)
|
|
elif props.element_mode == "DECOMPOSITION":
|
|
cls.toggle_container_element_by_decomposition(element_index, is_recursive)
|
|
elif props.element_mode == "CLASSIFICATION":
|
|
cls.toggle_container_element_by_classification(element_index, is_recursive)
|
|
|
|
@classmethod
|
|
def toggle_container_element_by_type(cls, element_index: int, is_recursive: bool) -> None:
|
|
props = cls.get_spatial_props()
|
|
expanded_elements: dict[str, list[Union[str, int]]] = json.loads(props.expanded_elements)
|
|
|
|
element = props.elements[element_index]
|
|
if element.type == "CLASS":
|
|
element_type = "CLASS"
|
|
filtered_item = element.name
|
|
elif element.type == "TYPE":
|
|
if element.ifc_definition_id == 0:
|
|
element_type = "UNTYPED_CLASSES"
|
|
filtered_item = element.ifc_class
|
|
else:
|
|
element_type = "IFC_ID"
|
|
filtered_item = element.ifc_definition_id
|
|
else:
|
|
return
|
|
|
|
expanded_elements_list: list[Union[str, int]] = expanded_elements.setdefault(element_type, [])
|
|
if filtered_item in expanded_elements_list:
|
|
expanded_elements_list.remove(filtered_item)
|
|
should_expand = False
|
|
else:
|
|
expanded_elements_list.append(filtered_item)
|
|
should_expand = True
|
|
|
|
if is_recursive and element.type == "CLASS":
|
|
container = tool.Ifc.get().by_id(props.active_container.ifc_definition_id)
|
|
results = cls.get_container_elements_grouped_by_type(container)
|
|
for ifc_class in results.keys():
|
|
if ifc_class != element.name:
|
|
continue
|
|
for ifc_definition_id in results[ifc_class].keys():
|
|
if ifc_definition_id == 0:
|
|
element_type = "UNTYPED_CLASSES"
|
|
filtered_item = ifc_class
|
|
else:
|
|
element_type = "IFC_ID"
|
|
filtered_item = ifc_definition_id
|
|
|
|
expanded_elements_list = expanded_elements.setdefault(element_type, [])
|
|
if should_expand is False and filtered_item in expanded_elements_list:
|
|
expanded_elements_list.remove(filtered_item)
|
|
elif should_expand is True and filtered_item not in expanded_elements_list:
|
|
expanded_elements_list.append(filtered_item)
|
|
|
|
props.expanded_elements = json.dumps(expanded_elements)
|
|
|
|
@classmethod
|
|
def toggle_container_element_by_decomposition(cls, element_index: int, is_recursive: bool) -> None:
|
|
props = cls.get_spatial_props()
|
|
element = props.elements[element_index]
|
|
expanded_elements: dict[str, list[Union[str, int]]] = json.loads(props.expanded_elements)
|
|
expanded_elements_list: list[Union[str, int]] = expanded_elements.setdefault("IFC_ID", [])
|
|
ifc_definition_id = element.ifc_definition_id
|
|
if ifc_definition_id in expanded_elements_list:
|
|
expanded_elements_list.remove(ifc_definition_id)
|
|
should_expand = False
|
|
else:
|
|
expanded_elements_list.append(ifc_definition_id)
|
|
should_expand = True
|
|
|
|
if is_recursive:
|
|
queue = [tool.Ifc.get().by_id(ifc_definition_id)]
|
|
while queue:
|
|
element = queue.pop()
|
|
children = [
|
|
e
|
|
for e in ifcopenshell.util.element.get_decomposition(element, is_recursive=False)
|
|
if not e.is_a("IfcFeatureElement")
|
|
]
|
|
ifc_definition_id = element.id()
|
|
if children:
|
|
if should_expand is False and ifc_definition_id in expanded_elements_list:
|
|
expanded_elements_list.remove(ifc_definition_id)
|
|
elif should_expand is True and ifc_definition_id not in expanded_elements_list:
|
|
expanded_elements_list.append(ifc_definition_id)
|
|
queue.extend(children)
|
|
|
|
props.expanded_elements = json.dumps(expanded_elements)
|
|
|
|
@classmethod
|
|
def toggle_container_element_by_classification(cls, element_index: int, is_recursive: bool) -> None:
|
|
props = cls.get_spatial_props()
|
|
expanded_elements: dict[str, list[str]] = json.loads(props.expanded_elements)
|
|
expanded_elements_list: list[str] = expanded_elements.setdefault("CLASSIFICATION", [])
|
|
|
|
element = props.elements[element_index]
|
|
identification = element.identification
|
|
|
|
if identification in expanded_elements_list:
|
|
expanded_elements_list.remove(identification)
|
|
should_expand = False
|
|
else:
|
|
expanded_elements_list.append(identification)
|
|
should_expand = True
|
|
|
|
if is_recursive:
|
|
expanded_elements_list = expanded_elements.setdefault("CLASSIFICATION_R", [])
|
|
if should_expand is False and identification in expanded_elements_list:
|
|
expanded_elements_list.remove(identification)
|
|
elif should_expand is True and identification not in expanded_elements_list:
|
|
expanded_elements_list.append(identification)
|
|
|
|
props.expanded_elements = json.dumps(expanded_elements)
|
|
|
|
# HERE STARTS SPATIAL TOOL
|
|
|
|
@classmethod
|
|
def get_or_build_geom_cache(cls) -> dict:
|
|
"""Build or return a cached dict of IFC element shapes for space generation.
|
|
|
|
The cache is keyed on ``_GEOM_CACHE_TOKEN`` which is bumped by a
|
|
``depsgraph_update_post`` handler when any Object geometry or transform
|
|
changes, and on undo/redo/load. This means the cache survives space
|
|
generations (which don't change Object geometry) but is correctly
|
|
invalidated when a user moves or edits a wall, slab, etc.
|
|
|
|
:return: ``{"shapes": {id: {"verts": ndarray, "faces": ndarray, "bottom_z": float, "top_z": float}}, "token": int}``
|
|
"""
|
|
global _GEOM_CACHE_TOKEN
|
|
cached = cls._geom_cache.get("current")
|
|
if cached and cached["token"] == _GEOM_CACHE_TOKEN:
|
|
return cached
|
|
|
|
ifc_file = tool.Ifc.get()
|
|
include = []
|
|
for ifc_class in ifcopenshell.util.space.BOUNDING_CLASSES + ifcopenshell.util.space.HEIGHT_DETECTION_CLASSES:
|
|
include.extend(ifc_file.by_type(ifc_class))
|
|
|
|
settings = ifcopenshell.geom.settings()
|
|
settings.set("disable-opening-subtractions", True)
|
|
settings.set("use-world-coords", True)
|
|
|
|
shapes = {}
|
|
iterator = ifcopenshell.geom.iterator(settings, ifc_file, multiprocessing.cpu_count(), include=include)
|
|
if iterator.initialize():
|
|
while True:
|
|
shape = iterator.get()
|
|
verts = ifcopenshell.util.shape.get_shape_vertices(shape, shape.geometry)
|
|
faces = ifcopenshell.util.shape.get_faces(shape.geometry)
|
|
zs = verts[:, 2]
|
|
shapes[shape.id] = {
|
|
"verts": verts,
|
|
"faces": faces,
|
|
"bottom_z": float(zs.min()),
|
|
"top_z": float(zs.max()),
|
|
}
|
|
if not iterator.next():
|
|
break
|
|
|
|
cache = {"shapes": shapes, "token": _GEOM_CACHE_TOKEN}
|
|
cls._geom_cache["current"] = cache
|
|
return cache
|
|
|
|
@classmethod
|
|
def is_bounding_class(cls, visible_element: ifcopenshell.entity_instance) -> bool:
|
|
for ifc_class in ifcopenshell.util.space.BOUNDING_CLASSES:
|
|
if visible_element.is_a(ifc_class):
|
|
return True
|
|
return False
|
|
|
|
@classmethod
|
|
def get_boundary_lines_from_ifc_elements(
|
|
cls,
|
|
cut_z: float,
|
|
) -> tuple[list[shapely.LineString], list[ifcopenshell.entity_instance]]:
|
|
"""Generate boundary lines by bisecting IFC element geometry with a horizontal plane.
|
|
|
|
Uses the class-level geometry cache (parallel iterator) instead of
|
|
iterating Blender visible objects. Works without any Blender objects
|
|
being loaded.
|
|
|
|
:param cut_z: Z elevation of the cutting plane in world coordinates.
|
|
:return: (boundary_lines, bounding_elements)
|
|
"""
|
|
cache = cls.get_or_build_geom_cache()
|
|
return ifcopenshell.util.space.get_boundary_lines(tool.Ifc.get(), cache["shapes"], cut_z)
|
|
|
|
@classmethod
|
|
def get_space_polygon_from_context_visible_objects(
|
|
cls, x: float, y: float, container: Optional[ifcopenshell.entity_instance] = None
|
|
) -> tuple[
|
|
Union[shapely.Polygon, Literal["NO POLYGONS FOUND", "NO POLYGON FOR POINT"]],
|
|
list[ifcopenshell.entity_instance],
|
|
]:
|
|
props = tool.Model.get_model_props()
|
|
calculation_rl = props.rl3
|
|
if container is None:
|
|
container = tool.Root.get_default_container()
|
|
container_obj = tool.Ifc.get_object(container)
|
|
cut_z = container_obj.matrix_world.translation.z + calculation_rl
|
|
|
|
# Commit any moved visible bounding objects before reading IFC geometry,
|
|
# so the IFC-based cache uses the current Blender positions.
|
|
# Walls/roofs/slabs that affect the space footprint or height must be
|
|
# committed before the cache is rebuilt; otherwise the IFC geometry read by
|
|
# the iterator will be stale and a moved roof/slab will not be picked up.
|
|
affected_classes = ifcopenshell.util.space.BOUNDING_CLASSES + ifcopenshell.util.space.HEIGHT_DETECTION_CLASSES
|
|
for obj in bpy.context.visible_objects:
|
|
element = tool.Ifc.get_entity(obj)
|
|
if element is None or not any(element.is_a(c) for c in affected_classes):
|
|
continue
|
|
tool.Geometry.commit_placement_if_moved(obj)
|
|
cls._geom_cache.clear()
|
|
|
|
boundary_lines, bounding_elements = cls.get_boundary_lines_from_ifc_elements(cut_z)
|
|
polygon, _ = ifcopenshell.util.space.get_space_polygon(boundary_lines, x, y)
|
|
if isinstance(polygon, str):
|
|
return polygon, []
|
|
return polygon, bounding_elements
|
|
|
|
@classmethod
|
|
def get_auto_space_height(
|
|
cls,
|
|
space_polygon: shapely.Polygon,
|
|
base_z: float,
|
|
bounding_walls: list[ifcopenshell.entity_instance],
|
|
) -> Optional[float]:
|
|
"""Auto-detect space height from elements above using IFC geometry.
|
|
|
|
Delegates to :func:`ifcopenshell.util.space.get_auto_space_height`.
|
|
|
|
:param space_polygon: The space footprint polygon in world XY.
|
|
:param base_z: The space's base Z in world coordinates.
|
|
:param bounding_walls: List of IFC wall elements bounding the space.
|
|
:return: Detected height in SI (meters), or None if nothing found.
|
|
"""
|
|
cache = cls.get_or_build_geom_cache()
|
|
return ifcopenshell.util.space.get_auto_space_height(
|
|
tool.Ifc.get(), cache["shapes"], space_polygon, base_z, bounding_walls
|
|
)
|
|
|
|
@classmethod
|
|
def get_space_volume_strategy(
|
|
cls,
|
|
space_polygon: shapely.Polygon,
|
|
base_z: float,
|
|
bounding_walls: list[ifcopenshell.entity_instance],
|
|
container: Optional[ifcopenshell.entity_instance] = None,
|
|
) -> tuple[str, Optional[list], Optional[list]]:
|
|
"""Decide how to build the space volume (clipped extrusion or B-rep).
|
|
|
|
Rays are cast from the RL cut elevation (``container_z + props.rl3``), the
|
|
same level at which the space footprint polygon was found.
|
|
"""
|
|
ifc_file = tool.Ifc.get()
|
|
cache = cls.get_or_build_geom_cache()
|
|
start_z = None
|
|
if container is None:
|
|
container = tool.Root.get_default_container()
|
|
if container is not None:
|
|
container_obj = tool.Ifc.get_object(container)
|
|
props = tool.Model.get_model_props()
|
|
start_z = container_obj.matrix_world.translation.z + props.rl3
|
|
tree = ifcopenshell.geom.tree(ifc_file)
|
|
settings = ifcopenshell.geom.settings()
|
|
settings.set("disable-opening-subtractions", True)
|
|
settings.set("use-world-coords", True)
|
|
tree.add_file(ifc_file, settings)
|
|
return ifcopenshell.util.space.detect_space_volume_strategy(
|
|
ifc_file, cache["shapes"], tree, space_polygon, base_z, bounding_walls, start_z=start_z
|
|
)
|
|
|
|
@classmethod
|
|
def _get_or_create_body_context(cls, ifc_file: ifcopenshell.file) -> ifcopenshell.entity_instance:
|
|
"""Return the Model/Body/MODEL_VIEW context, creating one if absent."""
|
|
context = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW")
|
|
if context is not None:
|
|
return context
|
|
# Some subcontexts may not expose the inherited ContextType value, so also
|
|
# search by ContextIdentifier/TargetView directly.
|
|
for ctx in ifc_file.by_type("IfcGeometricRepresentationSubContext"):
|
|
if ctx.ContextIdentifier == "Body" and getattr(ctx, "TargetView", None) == "MODEL_VIEW":
|
|
return ctx
|
|
# Create a minimal context if none exists.
|
|
model_context = ifcopenshell.util.representation.get_context(ifc_file, "Model")
|
|
if model_context is None:
|
|
model_context = ifc_file.createIfcGeometricRepresentationContext(
|
|
ContextType="Model",
|
|
CoordinateSpaceDimension=3,
|
|
Precision=1e-5,
|
|
WorldCoordinateSystem=ifc_file.createIfcAxis2Placement3D(
|
|
ifc_file.createIfcCartesianPoint([0.0, 0.0, 0.0])
|
|
),
|
|
TrueNorth=ifc_file.createIfcDirection([0.0, 1.0, 0.0]),
|
|
)
|
|
return ifc_file.createIfcGeometricRepresentationSubContext(
|
|
ParentContext=model_context,
|
|
ContextIdentifier="Body",
|
|
TargetView="MODEL_VIEW",
|
|
ContextType="Model",
|
|
)
|
|
|
|
@classmethod
|
|
def _remove_existing_body_representations(
|
|
cls, element: ifcopenshell.entity_instance
|
|
) -> Optional[ifcopenshell.entity_instance]:
|
|
"""Remove every existing Body representation from an element.
|
|
|
|
Returns the context of the first removed representation, or None.
|
|
"""
|
|
ifc_file = tool.Ifc.get()
|
|
if element.Representation is None:
|
|
return None
|
|
body_reps = [r for r in element.Representation.Representations if r.RepresentationIdentifier == "Body"]
|
|
context = None
|
|
for rep in body_reps:
|
|
context = rep.ContextOfItems
|
|
ifcopenshell.api.geometry.unassign_representation(ifc_file, product=element, representation=rep)
|
|
ifcopenshell.api.geometry.remove_representation(ifc_file, representation=rep)
|
|
return context
|
|
|
|
@classmethod
|
|
def set_brep_representation_from_mesh(
|
|
cls,
|
|
obj: bpy.types.Object,
|
|
element: ifcopenshell.entity_instance,
|
|
item: ifcopenshell.entity_instance,
|
|
) -> None:
|
|
"""Assign a representation item (clipped solid or B-rep) to the element."""
|
|
ifc_file = tool.Ifc.get()
|
|
context = cls._remove_existing_body_representations(element)
|
|
if context is None:
|
|
context = cls._get_or_create_body_context(ifc_file)
|
|
|
|
builder = ifcopenshell.util.shape_builder.ShapeBuilder(ifc_file)
|
|
new_body = builder.get_representation(context, item)
|
|
ifcopenshell.api.geometry.assign_representation(ifc_file, product=element, representation=new_body)
|
|
bonsai.core.geometry.switch_representation(
|
|
tool.Ifc,
|
|
tool.Geometry,
|
|
obj=obj,
|
|
representation=new_body,
|
|
)
|
|
|
|
@classmethod
|
|
def debug_shape(cls, foo: shapely.Polygon) -> None:
|
|
coords = [(p[0], p[1], 0) for p in foo.exterior.coords]
|
|
mesh = bpy.data.meshes.new(name="NewMesh")
|
|
bm = bmesh.new()
|
|
for coord in coords:
|
|
bm.verts.new(coord)
|
|
bm.verts.ensure_lookup_table()
|
|
bm.faces.new(bm.verts)
|
|
bm.to_mesh(mesh)
|
|
bm.free()
|
|
obj = bpy.data.objects.new("NewObject", mesh)
|
|
bpy.context.collection.objects.link(obj)
|
|
bpy.context.view_layer.update()
|
|
|
|
@classmethod
|
|
def debug_line(cls, start: Vector, end: Vector) -> None:
|
|
coords = [start, end]
|
|
mesh = bpy.data.meshes.new(name="NewMesh")
|
|
bm = bmesh.new()
|
|
for coord in coords:
|
|
bm.verts.new(coord)
|
|
bm.verts.ensure_lookup_table()
|
|
bm.edges.new(bm.verts)
|
|
bm.to_mesh(mesh)
|
|
bm.free()
|
|
obj = bpy.data.objects.new("NewLine", mesh)
|
|
bpy.context.collection.objects.link(obj)
|
|
bpy.context.view_layer.update()
|
|
|
|
@classmethod
|
|
def get_boundary_lines_from_context_visible_objects(
|
|
cls,
|
|
) -> tuple[list[shapely.LineString], list[ifcopenshell.entity_instance]]:
|
|
props = tool.Model.get_model_props()
|
|
calculation_rl = props.rl3
|
|
container = tool.Root.get_default_container()
|
|
container_obj = tool.Ifc.get_object(container)
|
|
cut_point = container_obj.matrix_world.translation.copy() + Vector((0, 0, calculation_rl))
|
|
cut_normal = Vector((0, 0, 1))
|
|
boundary_lines = []
|
|
bounding_elements = []
|
|
|
|
for obj in bpy.context.visible_objects:
|
|
visible_element = tool.Ifc.get_entity(obj)
|
|
|
|
old_mesh = obj.data
|
|
if (
|
|
not visible_element
|
|
or not isinstance(old_mesh, bpy.types.Mesh)
|
|
or not cls.is_bounding_class(visible_element)
|
|
or not tool.Drawing.is_intersecting_plane(obj, cut_point, cut_normal)
|
|
):
|
|
continue
|
|
|
|
bounding_elements.append(visible_element)
|
|
old_mesh = obj.data
|
|
assert isinstance(old_mesh, bpy.types.Mesh)
|
|
if visible_element.HasOpenings:
|
|
new_mesh = cls.get_gross_mesh_from_element(visible_element)
|
|
else:
|
|
new_mesh = old_mesh.copy()
|
|
obj.data = new_mesh
|
|
|
|
# Boundary objects are likely triangulated. If a triangulated quad
|
|
# is bisected by our plane, we end up with two lines instead of
|
|
# one. This makes shapely's job much harder (since shapely is very
|
|
# exact with its coordinates). As a result, let's limited dissolve
|
|
# prior to bisecting.
|
|
bm = bmesh.new()
|
|
bm.from_mesh(new_mesh)
|
|
bmesh.ops.dissolve_limit(bm, angle_limit=0.02, verts=bm.verts, edges=bm.edges)
|
|
bm.to_mesh(new_mesh)
|
|
new_mesh.update()
|
|
bm.free()
|
|
|
|
local_cut_point = obj.matrix_world.inverted() @ cut_point
|
|
local_cut_normal = obj.matrix_world.inverted().to_quaternion() @ cut_normal
|
|
verts, edges = tool.Drawing.bisect_mesh_with_plane(obj, local_cut_point, local_cut_normal)
|
|
|
|
# Restore the original mesh
|
|
obj.data = old_mesh
|
|
bpy.data.meshes.remove(new_mesh)
|
|
|
|
for edge in edges or []:
|
|
# Rounding is necessary to ensure coincident points are coincident
|
|
start = [round(x, 3) for x in verts[edge[0]]]
|
|
end = [round(x, 3) for x in verts[edge[1]]]
|
|
if start == end:
|
|
continue
|
|
# Extension by 50mm is necessary to ensure lines overlap with other diagonal lines
|
|
# This also closes small but likely irrelevant gaps for space generation.
|
|
start, end = tool.Drawing.extend_line(start, end, 0.05)
|
|
boundary_lines.append(shapely.LineString([start, end]))
|
|
|
|
return boundary_lines, bounding_elements
|
|
|
|
@classmethod
|
|
def get_gross_mesh_from_element(cls, visible_element: ifcopenshell.entity_instance) -> bpy.types.Mesh:
|
|
gross_settings = ifcopenshell.geom.settings()
|
|
gross_settings.set("disable-opening-subtractions", True)
|
|
new_mesh = cls.create_mesh_from_shape(ifcopenshell.geom.create_shape(gross_settings, visible_element))
|
|
return new_mesh
|
|
|
|
@classmethod
|
|
def create_mesh_from_shape(cls, shape: ifcopenshell.geom.ShapeElementType) -> bpy.types.Mesh:
|
|
geometry = shape.geometry
|
|
return tool.Loader.create_mesh_from_shape(geometry)
|
|
|
|
@classmethod
|
|
def get_x_y_z_h_mat_from_obj(cls, obj: bpy.types.Object) -> tuple[float, float, float, float, Matrix]:
|
|
"""
|
|
`x`, `y` - object's center XY in world space;\n
|
|
`z` - object's local Z- in world space;\n
|
|
`h` - object's Z dimension;\n
|
|
`mat` - object's matrix
|
|
"""
|
|
mat = obj.matrix_world
|
|
local_bbox_center = 0.125 * sum((Vector(b) for b in obj.bound_box), Vector())
|
|
global_bbox_center = mat @ local_bbox_center
|
|
x = global_bbox_center.x
|
|
y = global_bbox_center.y
|
|
z = (mat @ Vector(obj.bound_box[0])).z
|
|
|
|
h = obj.dimensions.z
|
|
return x, y, z, h, mat
|
|
|
|
@classmethod
|
|
def get_x_y_z_h_mat_from_cursor(cls) -> tuple[float, float, float, float, Matrix]:
|
|
"""
|
|
`x`, `y` - from cursor;\n
|
|
`z` - from default container Z location (if set);\n
|
|
`h` - default value of 3;\n
|
|
`mat` - identity matrix
|
|
"""
|
|
x, y, z = bpy.context.scene.cursor.location.xyz
|
|
if tool.Root.get_default_container():
|
|
z = tool.Root.get_default_container_elevation()
|
|
mat = Matrix()
|
|
h = 3
|
|
return x, y, z, h, mat
|
|
|
|
@classmethod
|
|
def get_union_shape_from_selected_objects(cls) -> Polygon:
|
|
selected_objects = bpy.context.selected_objects
|
|
boundary_elements = cls.get_boundary_elements(selected_objects)
|
|
polys = cls.get_polygons(boundary_elements)
|
|
converted_tolerance = cls.get_converted_tolerance(tolerance_si=0.03)
|
|
union = shapely.ops.unary_union(polys).buffer(
|
|
converted_tolerance,
|
|
cap_style=shapely.constructive.BufferCapStyle.flat,
|
|
join_style=shapely.constructive.BufferJoinStyle.mitre,
|
|
)
|
|
union = cls.get_purged_inner_holes_poly(
|
|
union_geom=union, min_area=cls.get_converted_tolerance(tolerance_si=0.1)
|
|
)
|
|
return union
|
|
|
|
@classmethod
|
|
def get_boundary_elements(cls, selected_objects: list[bpy.types.Object]) -> list[ifcopenshell.entity_instance]:
|
|
boundary_elements = []
|
|
for obj in selected_objects:
|
|
subelement = tool.Ifc.get_entity(obj)
|
|
if subelement.is_a("IfcWall") or subelement.is_a("IfcColumn"):
|
|
boundary_elements.append(subelement)
|
|
return boundary_elements
|
|
|
|
@classmethod
|
|
def get_polygons(cls, boundary_elements: list[ifcopenshell.entity_instance]) -> list[Polygon]:
|
|
polys = []
|
|
for boundary_element in boundary_elements:
|
|
obj = tool.Ifc.get_object(boundary_element)
|
|
if not obj:
|
|
continue
|
|
points = []
|
|
base = cls.get_obj_base_points(obj)
|
|
for index in ["low_left", "low_right", "high_right", "high_left"]:
|
|
point = base[index]
|
|
points.append(point)
|
|
|
|
polys.append(Polygon(points))
|
|
return polys
|
|
|
|
@classmethod
|
|
def get_obj_base_points(cls, obj: bpy.types.Object) -> dict[str, tuple[float, float]]:
|
|
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
|
bbox_ws = [obj.matrix_world @ Vector(v) / si_conversion for v in obj.bound_box]
|
|
return {
|
|
"low_left": (bbox_ws[0].x, bbox_ws[0].y),
|
|
"high_left": (bbox_ws[3].x, bbox_ws[3].y),
|
|
"low_right": (bbox_ws[4].x, bbox_ws[4].y),
|
|
"high_right": (bbox_ws[7].x, bbox_ws[7].y),
|
|
}
|
|
|
|
@classmethod
|
|
def get_converted_tolerance(cls, tolerance_si: float) -> float:
|
|
model = tool.Ifc.get()
|
|
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(model)
|
|
return tolerance_si / si_conversion
|
|
|
|
@classmethod
|
|
def get_purged_inner_holes_poly(cls, union_geom: Polygon, min_area: float) -> Polygon:
|
|
interiors_list = []
|
|
|
|
new_poly = None
|
|
poly = None
|
|
if union_geom.geom_type == "MultiPolygon":
|
|
for poly in union_geom.geoms:
|
|
interiors_list = cls.get_poly_valid_interior_list(
|
|
poly=poly, min_area=min_area, interiors_list=interiors_list
|
|
)
|
|
|
|
assert poly is not None
|
|
new_poly = Polygon(poly.exterior.coords, holes=interiors_list)
|
|
|
|
if union_geom.geom_type == "Polygon":
|
|
interiors_list = cls.get_poly_valid_interior_list(
|
|
poly=union_geom, min_area=min_area, interiors_list=interiors_list
|
|
)
|
|
new_poly = Polygon(union_geom.exterior.coords, holes=interiors_list)
|
|
|
|
assert new_poly is not None
|
|
return new_poly
|
|
|
|
@classmethod
|
|
def get_poly_valid_interior_list(cls, poly: Polygon, min_area: float, interiors_list: list[shapely.LinearRing]):
|
|
for interior in poly.interiors:
|
|
p = Polygon(interior)
|
|
if p.area >= min_area:
|
|
interiors_list.append(interior)
|
|
return interiors_list
|
|
|
|
@classmethod
|
|
def get_buffered_poly_from_linear_ring(cls, linear_ring: shapely.LinearRing) -> Polygon:
|
|
poly = Polygon(linear_ring)
|
|
converted_tolerance = cls.get_converted_tolerance(tolerance_si=0.03)
|
|
poly = poly.buffer(
|
|
converted_tolerance,
|
|
# single_sided=True,
|
|
cap_style=shapely.BufferCapStyle.flat,
|
|
join_style=shapely.BufferJoinStyle.mitre,
|
|
)
|
|
return poly
|
|
|
|
@classmethod
|
|
def create_object(cls, name: str) -> bpy.types.Object:
|
|
mesh = bpy.data.meshes.new(name=name)
|
|
obj = bpy.data.objects.new(name, mesh)
|
|
return obj
|
|
|
|
@classmethod
|
|
def set_obj_origin_to_polygon_center(cls, obj: bpy.types.Object, poly: Polygon, polygon_is_si: bool = True) -> None:
|
|
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
|
centroid = poly.centroid
|
|
if polygon_is_si:
|
|
obj.location = Vector((centroid.x, centroid.y, 0))
|
|
else:
|
|
obj.location = Vector((centroid.x * unit_scale, centroid.y * unit_scale, 0))
|
|
|
|
@classmethod
|
|
def get_2d_vertices_from_polygon(
|
|
cls,
|
|
poly: Polygon,
|
|
obj: bpy.types.Object,
|
|
polygon_is_si: bool = True,
|
|
) -> list[list[float]]:
|
|
"""Convert a world-space shapely polygon to 2D vertices in obj's local space, in IFC file units.
|
|
|
|
:param poly: The polygon in world space.
|
|
:param obj: The Blender object whose local space is used.
|
|
:param polygon_is_si: True if polygon coords are in SI, False if in IFC file units.
|
|
:return: List of [x, y] coordinates (not closed).
|
|
"""
|
|
ifc_file = tool.Ifc.get()
|
|
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
|
|
bpy.context.view_layer.update()
|
|
mat_inv = obj.matrix_world.inverted()
|
|
coords_2d = []
|
|
for v in shapely.get_exterior_ring(poly).coords[:-1]:
|
|
world_si = Vector((v[0], v[1], 0))
|
|
if not polygon_is_si:
|
|
world_si = world_si * unit_scale
|
|
local_si = mat_inv @ world_si
|
|
coords_2d.append([local_si.x / unit_scale, local_si.y / unit_scale])
|
|
return coords_2d
|
|
|
|
@classmethod
|
|
def set_extrusion_representation_from_polygon(
|
|
cls,
|
|
obj: bpy.types.Object,
|
|
element: ifcopenshell.entity_instance,
|
|
poly: Polygon,
|
|
depth_ifc: float,
|
|
polygon_is_si: bool = True,
|
|
) -> None:
|
|
"""Create or replace the IFC body representation from a polygon extrusion.
|
|
|
|
:param obj: The Blender object.
|
|
:param element: The IFC product entity.
|
|
:param poly: The polygon in world space.
|
|
:param depth_ifc: The extrusion depth in IFC file units.
|
|
:param polygon_is_si: True if polygon coords are in SI, False if in IFC file units.
|
|
"""
|
|
ifc_file = tool.Ifc.get()
|
|
builder = ifcopenshell.util.shape_builder.ShapeBuilder(ifc_file)
|
|
|
|
coords_2d = cls.get_2d_vertices_from_polygon(poly, obj, polygon_is_si)
|
|
|
|
curve = builder.polyline(coords_2d, closed=True)
|
|
item = builder.extrude(curve, magnitude=depth_ifc)
|
|
|
|
context = cls._remove_existing_body_representations(element)
|
|
if context is None:
|
|
context = cls._get_or_create_body_context(ifc_file)
|
|
|
|
new_body = builder.get_representation(context, item)
|
|
ifcopenshell.api.geometry.assign_representation(ifc_file, product=element, representation=new_body)
|
|
bonsai.core.geometry.switch_representation(
|
|
tool.Ifc,
|
|
tool.Geometry,
|
|
obj=obj,
|
|
representation=new_body,
|
|
)
|
|
|
|
@classmethod
|
|
def set_space_representation_from_polygon(
|
|
cls,
|
|
obj: bpy.types.Object,
|
|
element: ifcopenshell.entity_instance,
|
|
poly: Polygon,
|
|
h: float,
|
|
polygon_is_si: bool = True,
|
|
bounding_walls: Optional[list[ifcopenshell.entity_instance]] = None,
|
|
container: Optional[ifcopenshell.entity_instance] = None,
|
|
) -> None:
|
|
"""Create or replace the IFC body representation of a space from a polygon.
|
|
|
|
:param h: The height in SI (meters).
|
|
"""
|
|
# Remove collinear points introduced by the mesh bisection so the
|
|
# footprint polygon has a minimal vertex count.
|
|
poly = poly.simplify(0, preserve_topology=True)
|
|
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
|
ifc_file = tool.Ifc.get()
|
|
x, y, z = obj.matrix_world.translation
|
|
origin = obj.matrix_world.translation # Blender SI
|
|
# The space builders expect base_z and polygon in SI (world) units.
|
|
base_z = z
|
|
poly_si = poly if polygon_is_si else shapely.affinity.scale(poly, unit_scale, unit_scale, origin=(0, 0))
|
|
|
|
# Ensure the IFC entity has an ObjectPlacement matching the Blender object,
|
|
# so the generated representation is in the correct local coordinate system.
|
|
bpy.context.view_layer.update()
|
|
matrix = np.array(obj.matrix_world)
|
|
ifcopenshell.api.geometry.edit_object_placement(
|
|
ifc_file,
|
|
product=element,
|
|
matrix=matrix,
|
|
is_si=True,
|
|
)
|
|
|
|
for b in list(element.BoundedBy or []):
|
|
ifcopenshell.api.boundary.remove_boundary(ifc_file, b)
|
|
|
|
cls._remove_existing_body_representations(element)
|
|
|
|
if cls.get_spatial_props().force_space_height:
|
|
cls.set_extrusion_representation_from_polygon(obj, element, poly, h / unit_scale, polygon_is_si)
|
|
return
|
|
if bounding_walls is None:
|
|
bounding_walls = []
|
|
if container is None:
|
|
container = ifcopenshell.util.element.get_container(element)
|
|
if container is not None:
|
|
for wall in ifc_file.by_type("IfcWall"):
|
|
if wall in ifcopenshell.util.element.get_decomposition(container):
|
|
bounding_walls.append(wall)
|
|
|
|
# Detect planes in world SI (same coordinate system as the geom cache).
|
|
strategy, top_planes, bottom_planes = cls.get_space_volume_strategy(poly_si, base_z, bounding_walls, container)
|
|
|
|
# Build the geometry in the space's local coordinate system so the IFC
|
|
# representation is relative to the object's ObjectPlacement.
|
|
# Use the full inverse of the object's placement matrix so rotated spaces
|
|
# keep the correct footprint orientation.
|
|
matrix_inv = np.array(obj.matrix_world.inverted())
|
|
# shapely.affine_transform expects [a, b, d, e, xoff, yoff]
|
|
# where x' = a*x + b*y + xoff, y' = d*x + e*y + yoff.
|
|
affine_params = [
|
|
matrix_inv[0, 0],
|
|
matrix_inv[0, 1],
|
|
matrix_inv[1, 0],
|
|
matrix_inv[1, 1],
|
|
matrix_inv[0, 3],
|
|
matrix_inv[1, 3],
|
|
]
|
|
local_poly_si = shapely.affinity.affine_transform(poly_si, affine_params)
|
|
local_base_z = base_z - origin.z
|
|
|
|
def localize_plane(plane):
|
|
point, normal = plane
|
|
local_point = matrix_inv @ np.array([*point, 1.0])
|
|
rotation_inv = matrix_inv[:3, :3]
|
|
local_normal = rotation_inv @ np.array(normal)
|
|
local_normal = local_normal / np.linalg.norm(local_normal)
|
|
return (local_point[:3], local_normal)
|
|
|
|
local_top_planes = [localize_plane(p) for p in (top_planes or [])]
|
|
local_bottom_planes = [localize_plane(p) for p in (bottom_planes or [])]
|
|
|
|
if strategy == "EXTRUDE_CLIP" and top_planes:
|
|
item = ifcopenshell.util.space.build_extruded_clipped_space(
|
|
ifc_file, local_poly_si, local_base_z, local_top_planes, local_bottom_planes
|
|
)
|
|
cls.set_brep_representation_from_mesh(obj, element, item)
|
|
else:
|
|
shapes = cls.get_or_build_geom_cache()["shapes"]
|
|
local_shapes = {}
|
|
for shape_id, shape_data in shapes.items():
|
|
local_shape_data = dict(shape_data)
|
|
local_shape_data["top_z"] = shape_data["top_z"] - origin.z
|
|
local_shape_data["bottom_z"] = shape_data["bottom_z"] - origin.z
|
|
local_shapes[shape_id] = local_shape_data
|
|
item = ifcopenshell.util.space.build_brep_space(
|
|
ifc_file, element, local_shapes, local_poly_si, local_base_z
|
|
)
|
|
if item is None:
|
|
cls.set_extrusion_representation_from_polygon(obj, element, poly, h / unit_scale, polygon_is_si)
|
|
else:
|
|
cls.set_brep_representation_from_mesh(obj, element, item)
|
|
|
|
@classmethod
|
|
def set_obj_origin_to_cursor_position_and_zero_elevation(cls, obj: bpy.types.Object) -> None:
|
|
mat = obj.matrix_world
|
|
inverted = mat.inverted()
|
|
|
|
x, y = bpy.context.scene.cursor.location.xy
|
|
z = 0
|
|
|
|
oldLoc = obj.location
|
|
newLoc = Vector((x, y, z))
|
|
diff = newLoc - oldLoc
|
|
for vert in obj.data.vertices:
|
|
aux_vector = mat @ vert.co
|
|
aux_vector = aux_vector - diff
|
|
vert.co = inverted @ aux_vector
|
|
obj.location = newLoc
|
|
|
|
@classmethod
|
|
def get_selected_objects(cls) -> list[bpy.types.Object]:
|
|
return bpy.context.selected_objects
|
|
|
|
@classmethod
|
|
def get_active_obj(cls) -> Union[bpy.types.Object, None]:
|
|
return bpy.context.active_object
|
|
|
|
@classmethod
|
|
def get_active_obj_z(cls) -> float:
|
|
return bpy.context.active_object.matrix_world.translation.z
|
|
|
|
@classmethod
|
|
def get_active_obj_height(cls) -> float:
|
|
height = bpy.context.active_object.dimensions.z
|
|
return height
|
|
|
|
@classmethod
|
|
def get_relating_type_id(cls) -> int:
|
|
props = tool.Model.get_model_props()
|
|
relating_type_id = props.relating_type_id
|
|
return relating_type_id
|
|
|
|
@classmethod
|
|
def translate_obj_to_z_location(cls, obj: bpy.types.Object, z: float) -> None:
|
|
if z != 0:
|
|
obj.location = obj.location + Vector((0, 0, z))
|
|
|
|
@classmethod
|
|
def assign_ifcspace_class_to_obj(cls, obj: bpy.types.Object) -> None:
|
|
bonsai.core.root.assign_class(
|
|
tool.Ifc,
|
|
tool.Collector,
|
|
tool.Root,
|
|
obj=obj,
|
|
ifc_class="IfcSpace",
|
|
should_add_representation=False,
|
|
)
|
|
|
|
@classmethod
|
|
def assign_type_to_obj(cls, obj: bpy.types.Object) -> None:
|
|
props = tool.Model.get_model_props()
|
|
ifc_file = tool.Ifc.get()
|
|
relating_type_id = props.relating_type_id
|
|
relating_type = ifc_file.by_id(int(relating_type_id))
|
|
ifc_class = relating_type.is_a()
|
|
instance_class = ifcopenshell.util.type.get_applicable_entities(ifc_class, ifc_file.schema)[0]
|
|
bonsai.core.root.assign_class(
|
|
tool.Ifc,
|
|
tool.Collector,
|
|
tool.Root,
|
|
obj=obj,
|
|
ifc_class=instance_class,
|
|
should_add_representation=False,
|
|
)
|
|
element = tool.Ifc.get_entity(obj)
|
|
assert element
|
|
ifcopenshell.api.type.assign_type(ifc_file, related_objects=[element], relating_type=relating_type)
|
|
|
|
@classmethod
|
|
def set_covering_representation_from_polygon(
|
|
cls,
|
|
obj: bpy.types.Object,
|
|
poly: Polygon,
|
|
polygon_is_si: bool = True,
|
|
) -> None:
|
|
"""Create the covering body representation from a polygon, extruded by the type's material layer thickness."""
|
|
element = tool.Ifc.get_entity(obj)
|
|
relating_type = ifcopenshell.util.element.get_type(element)
|
|
material = ifcopenshell.util.element.get_material(relating_type, should_skip_usage=True)
|
|
depth = 0.0
|
|
if material and material.is_a("IfcMaterialLayerSet"):
|
|
depth = sum(layer.LayerThickness for layer in material.MaterialLayers)
|
|
cls.set_extrusion_representation_from_polygon(obj, element, poly, depth, polygon_is_si)
|
|
|
|
@classmethod
|
|
def assign_relating_type_to_element(
|
|
cls,
|
|
ifc: tool.Ifc,
|
|
type: tool.Type,
|
|
element: ifcopenshell.entity_instance,
|
|
relating_type: ifcopenshell.entity_instance,
|
|
) -> None:
|
|
bonsai.core.type.assign_type(ifc, tool.Model, type, element=element, type=relating_type)
|
|
|
|
@classmethod
|
|
def set_space_visibility(cls, is_visible: bool) -> None:
|
|
if tool.Ifc.get().schema == "IFC2X3":
|
|
elements = tool.Ifc.get().by_type("IfcSpatialStructureElement")
|
|
else:
|
|
elements = tool.Ifc.get().by_type("IfcSpatialElement")
|
|
for element in elements:
|
|
if obj := tool.Ifc.get_object(element):
|
|
if obj.hide_viewport is True and is_visible:
|
|
obj.hide_viewport = False
|
|
elif obj.hide_viewport is False and not is_visible:
|
|
obj.hide_viewport = True
|
|
|
|
@classmethod
|
|
def set_grid_visibility(cls, is_visible: bool) -> None:
|
|
for element in tool.Ifc.get().by_type("IfcGrid") + tool.Ifc.get().by_type("IfcGridAxis"):
|
|
if obj := tool.Ifc.get_object(element):
|
|
if obj.hide_viewport is True and is_visible:
|
|
obj.hide_viewport = False
|
|
elif obj.hide_viewport is False and not is_visible:
|
|
obj.hide_viewport = True
|
|
|
|
@classmethod
|
|
def toggle_spaces_visibility_wired_and_textured(cls, spaces: list[ifcopenshell.entity_instance]) -> None:
|
|
first_obj = tool.Ifc.get_object(spaces[0])
|
|
assert isinstance(first_obj, bpy.types.Object)
|
|
obj: bpy.types.Object
|
|
if first_obj.display_type == "TEXTURED":
|
|
for space in spaces:
|
|
obj = tool.Ifc.get_object(space)
|
|
obj.show_wire = True
|
|
obj.display_type = "WIRE"
|
|
return
|
|
|
|
elif first_obj.display_type == "WIRE":
|
|
for space in spaces:
|
|
obj = tool.Ifc.get_object(space)
|
|
obj.show_wire = False
|
|
obj.display_type = "TEXTURED"
|
|
return
|
|
|
|
@classmethod
|
|
def toggle_hide_spaces(cls, spaces: list[ifcopenshell.entity_instance]) -> None:
|
|
first_obj = tool.Ifc.get_object(spaces[0])
|
|
assert isinstance(first_obj, bpy.types.Object)
|
|
obj: bpy.types.Object
|
|
if first_obj.hide_get() == False:
|
|
for space in spaces:
|
|
obj = tool.Ifc.get_object(space)
|
|
obj.hide_set(True)
|
|
return
|
|
|
|
elif first_obj.hide_get() == True:
|
|
for space in spaces:
|
|
obj = tool.Ifc.get_object(space)
|
|
obj.hide_set(False)
|
|
|
|
@classmethod
|
|
def set_default_container(cls, container: ifcopenshell.entity_instance) -> None:
|
|
from bonsai.bim.module.spatial.data import SpatialDecompositionData
|
|
|
|
props = cls.get_spatial_props()
|
|
props.default_container = container.id()
|
|
SpatialDecompositionData.data["default_container"] = SpatialDecompositionData.default_container()
|
|
|
|
project = tool.Ifc.get_object(tool.Ifc.get().by_type("IfcProject")[0])
|
|
project_collection = tool.Blender.get_object_bim_props(project).collection
|
|
obj = tool.Ifc.get_object(container)
|
|
if obj and (collection := tool.Blender.get_object_bim_props(obj).collection):
|
|
for layer_collection in bpy.context.view_layer.layer_collection.children:
|
|
if layer_collection.collection == project_collection:
|
|
for layer_collection2 in layer_collection.children:
|
|
if layer_collection2.collection == collection:
|
|
bpy.context.view_layer.active_layer_collection = layer_collection2
|
|
break
|
|
|
|
@classmethod
|
|
def guess_default_container(cls) -> Optional[ifcopenshell.entity_instance]:
|
|
project = tool.Ifc.get().by_type("IfcProject")[0]
|
|
subelement = None
|
|
# We try to priorise the first Site > Building > Storey as a convention for vertical projects
|
|
for subelement in ifcopenshell.util.element.get_parts(project):
|
|
if subelement.is_a("IfcSite"):
|
|
for subelement2 in ifcopenshell.util.element.get_parts(subelement):
|
|
if subelement2.is_a("IfcBuilding"):
|
|
for subelement3 in ifcopenshell.util.element.get_parts(subelement2):
|
|
if subelement3.is_a("IfcBuildingStorey"):
|
|
return subelement3
|
|
if subelement:
|
|
return subelement
|
|
return None
|
|
|
|
@classmethod
|
|
def get_selected_containers(cls) -> list[ifcopenshell.entity_instance]:
|
|
results = []
|
|
for obj in tool.Blender.get_selected_objects():
|
|
if (element := tool.Ifc.get_entity(obj)) and tool.Root.is_spatial_element(element):
|
|
results.append(element)
|
|
return results
|
|
|
|
@classmethod
|
|
def get_selected_objects_without_containers(cls) -> list[bpy.types.Object]:
|
|
"""Get selected objects skipping spatial elements.
|
|
|
|
Useful for operators that are using selected objects to identify selected containers.
|
|
Note that those operators are typically have a limitation since they can't tell
|
|
objects to operate on from containers that should be used in the operation.
|
|
|
|
E.g. we cannot bim.copy_to_container containers to other containers."""
|
|
results: list[bpy.types.Object] = []
|
|
for obj in tool.Blender.get_selected_objects():
|
|
if (element := tool.Ifc.get_entity(obj)) and not tool.Root.is_spatial_element(element):
|
|
results.append(obj)
|
|
return results
|
|
|
|
@classmethod
|
|
def set_target_container_as_default(cls) -> None:
|
|
if (
|
|
(container := tool.Root.get_default_container())
|
|
and (container_obj := tool.Ifc.get_object(container))
|
|
and (obj := bpy.context.active_object)
|
|
):
|
|
props = cls.get_object_spatial_props(obj)
|
|
props.container_obj = container_obj
|
|
|
|
@classmethod
|
|
def get_filtered_elements(
|
|
cls, should_filter: bool = True, is_recursive: bool = True
|
|
) -> Iterable[ifcopenshell.entity_instance]:
|
|
ifc_file = tool.Ifc.get()
|
|
props = cls.get_spatial_props()
|
|
container = ifc_file.by_id(props.active_container.ifc_definition_id)
|
|
element_filter = props.element_filter
|
|
active_element = props.active_element
|
|
|
|
if not should_filter:
|
|
if props.should_include_children:
|
|
elements = ifcopenshell.util.element.get_decomposition(container, is_recursive=is_recursive)
|
|
else:
|
|
queue = list(set(ifcopenshell.util.element.get_contained(container)))
|
|
elements = set()
|
|
while queue:
|
|
item = queue.pop()
|
|
elements.add(item)
|
|
queue.extend(ifcopenshell.util.element.get_decomposition(item))
|
|
if not element_filter:
|
|
return elements
|
|
|
|
keyword = element_filter.lower()
|
|
if props.element_mode == "TYPE":
|
|
filtered_occurrences = set()
|
|
filtered_classes = set()
|
|
filtered_types = set()
|
|
for item in props.elements:
|
|
if item.type == "CLASS" and not item.is_expanded and keyword in item.name.lower():
|
|
filtered_classes.add(item.name)
|
|
elif item.type == "TYPE" and not item.is_expanded and keyword in item.name.lower():
|
|
if item.ifc_definition_id:
|
|
filtered_types.add(ifc_file.by_id(item.ifc_definition_id))
|
|
else:
|
|
filtered_types.add(item.name.split(" ")[1])
|
|
elif item.type == "OCCURRENCE" and keyword in item.name.lower():
|
|
filtered_occurrences.add(ifc_file.by_id(item.ifc_definition_id))
|
|
return {
|
|
e
|
|
for e in elements
|
|
if e.is_a() in filtered_classes
|
|
or ((e_type := ifcopenshell.util.element.get_type(e)) and e_type in filtered_types)
|
|
or (not e_type and e.is_a() in filtered_types)
|
|
} | filtered_occurrences
|
|
elif props.element_mode == "DECOMPOSITION":
|
|
return [ifc_file.by_id(i.ifc_definition_id) for i in props.elements if keyword in i.name.lower()]
|
|
elif props.element_mode == "CLASSIFICATION":
|
|
filtered_classifications = set()
|
|
filtered_occurrences = set()
|
|
for item in props.elements:
|
|
if item.type == "CLASSIFICATION" and not item.is_expanded and keyword in item.name.lower():
|
|
filtered_classifications.add(item.identification)
|
|
elif item.type == "OCCURRENCE" and keyword in item.name.lower():
|
|
filtered_occurrences.add(ifc_file.by_id(item.ifc_definition_id))
|
|
for element in elements:
|
|
if refs := ifcopenshell.util.classification.get_references(element):
|
|
for ref in refs:
|
|
for filtered_classification in filtered_classifications:
|
|
if ref[1].startswith(filtered_classification):
|
|
filtered_occurrences.add(element)
|
|
elif "Unclassified" in filtered_classifications:
|
|
filtered_occurrences.add(element)
|
|
return filtered_occurrences
|
|
return elements
|
|
|
|
if not active_element:
|
|
return []
|
|
|
|
if props.element_mode == "TYPE":
|
|
if active_element.type == "OCCURRENCE":
|
|
return {ifc_file.by_id(active_element.ifc_definition_id)}
|
|
|
|
ifc_class = relating_type = None
|
|
is_untyped = False
|
|
|
|
if active_element.type == "CLASS":
|
|
ifc_class = active_element.name
|
|
elif active_element.type == "TYPE":
|
|
ifc_class = active_element.ifc_class
|
|
if ifc_id := active_element.ifc_definition_id:
|
|
relating_type = ifc_file.by_id(ifc_id)
|
|
|
|
if props.should_include_children:
|
|
elements = ifcopenshell.util.element.get_decomposition(container, is_recursive=is_recursive)
|
|
else:
|
|
elements = set(ifcopenshell.util.element.get_contained(container))
|
|
if is_recursive:
|
|
for e in list(elements):
|
|
elements.update(ifcopenshell.util.element.get_decomposition(e))
|
|
return cls.filter_elements(elements, ifc_class, relating_type, is_untyped, element_filter)
|
|
elif props.element_mode == "DECOMPOSITION":
|
|
occurrence = ifc_file.by_id(active_element.ifc_definition_id)
|
|
elements = ifcopenshell.util.element.get_decomposition(occurrence, is_recursive=is_recursive)
|
|
elements.add(occurrence)
|
|
return elements
|
|
elif props.element_mode == "CLASSIFICATION":
|
|
if active_element.type == "OCCURRENCE":
|
|
return {ifc_file.by_id(active_element.ifc_definition_id)}
|
|
|
|
if active_element.type == "CLASSIFICATION":
|
|
identification = active_element.identification
|
|
if props.should_include_children:
|
|
elements = ifcopenshell.util.element.get_decomposition(container, is_recursive=is_recursive)
|
|
else:
|
|
elements = set(ifcopenshell.util.element.get_contained(container))
|
|
if is_recursive:
|
|
for e in list(elements):
|
|
elements.update(ifcopenshell.util.element.get_decomposition(e))
|
|
|
|
def filter_element(element: ifcopenshell.entity_instance) -> bool:
|
|
references = ifcopenshell.util.classification.get_references(element)
|
|
if identification == "Unclassified":
|
|
if not references:
|
|
return True
|
|
elif any([r for r in references if r[1].startswith(identification)]):
|
|
return True
|
|
return False
|
|
|
|
return filter(filter_element, elements)
|