mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-19 06:39:13 +00:00
Polish tool.Model + tool.Pset + add tool.Slab service
tool.Model gains: * get_pipe_segment_props / get_duct_segment_props — typed prop accessors for the MEP-segment edit lifecycle. * resolve_active_props_for_edit — picks the right BIM*Properties to drive a parametric edit triad based on the active object's IFC class. * mirror_parent_void_fillings_to_children — when an array parent has hosted fillings (door/window in a wall), replicate the same fill rels onto each array child. Uses tool.Array.get_parametric_propagation_ targets so the propagation stays within the array family (the old get_all_element_occurrences over-propagated to standalone occurrences of the same type, which silently mutated unrelated arrays). * unshare_opening_representation — fork a shared IfcShapeRepresentation so editing one opening doesn't mutate its array sibling. * duplicate_ifc_objects gains a post-condition select-restore on the array parent so callers don't get a deselected parent for N>=2 arrays. sync_object_ifc_position is kept as a thin delegate to tool.Geometry.commit_placement_if_moved (the new home, added in C8) so the 6 v0.8.0 callers in mep / product / system don't AttributeError; PR4 migrates each caller and removes the delegate. tool.Pset gains: * upsert_pset — get-or-add-or-edit in one call. * write_bbim_data — JSON-encode + write BBIM_* metadata in one call. tool.Slab is new — slab-specific reads (active extrusion, axis direction) used by the slab gizmos, pure-IFC, no PropertyGroup mutation. Generated with the assistance of an AI coding tool.
This commit is contained in:
@@ -66,6 +66,7 @@ from bonsai.tool.resource import Resource
|
|||||||
from bonsai.tool.root import Root
|
from bonsai.tool.root import Root
|
||||||
from bonsai.tool.search import Search
|
from bonsai.tool.search import Search
|
||||||
from bonsai.tool.sequence import Sequence
|
from bonsai.tool.sequence import Sequence
|
||||||
|
from bonsai.tool.slab import Slab
|
||||||
from bonsai.tool.snap import Snap
|
from bonsai.tool.snap import Snap
|
||||||
from bonsai.tool.spatial import Spatial
|
from bonsai.tool.spatial import Spatial
|
||||||
from bonsai.tool.structural import Structural
|
from bonsai.tool.structural import Structural
|
||||||
|
|||||||
+220
-24
@@ -22,7 +22,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import collections.abc
|
import collections.abc
|
||||||
import json
|
import json
|
||||||
from collections.abc import Iterable, Sequence
|
from collections.abc import Callable, Iterable, Sequence
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from math import atan, cos, degrees, pi, radians
|
from math import atan, cos, degrees, pi, radians
|
||||||
from typing import (
|
from typing import (
|
||||||
@@ -39,9 +39,11 @@ from typing import (
|
|||||||
import bmesh
|
import bmesh
|
||||||
import bpy
|
import bpy
|
||||||
import ifcopenshell
|
import ifcopenshell
|
||||||
|
import ifcopenshell.api.feature
|
||||||
import ifcopenshell.api.geometry
|
import ifcopenshell.api.geometry
|
||||||
import ifcopenshell.api.grid
|
import ifcopenshell.api.grid
|
||||||
import ifcopenshell.api.pset
|
import ifcopenshell.api.pset
|
||||||
|
import ifcopenshell.api.root
|
||||||
import ifcopenshell.geom
|
import ifcopenshell.geom
|
||||||
import ifcopenshell.ifcopenshell_wrapper as W
|
import ifcopenshell.ifcopenshell_wrapper as W
|
||||||
import ifcopenshell.util.element
|
import ifcopenshell.util.element
|
||||||
@@ -60,6 +62,7 @@ import bonsai.core.geometry
|
|||||||
import bonsai.core.tool
|
import bonsai.core.tool
|
||||||
import bonsai.tool as tool
|
import bonsai.tool as tool
|
||||||
from bonsai.bim import import_ifc
|
from bonsai.bim import import_ifc
|
||||||
|
from bonsai.tool.cad import VTX_PRECISION, WELD_TOLERANCE
|
||||||
|
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
V_ = tool.Blender.V_
|
V_ = tool.Blender.V_
|
||||||
@@ -72,8 +75,10 @@ if TYPE_CHECKING:
|
|||||||
from bonsai.bim.module.model.prop import (
|
from bonsai.bim.module.model.prop import (
|
||||||
BIMArrayProperties,
|
BIMArrayProperties,
|
||||||
BIMDoorProperties,
|
BIMDoorProperties,
|
||||||
|
BIMDuctSegmentProperties,
|
||||||
BIMExternalParametricGeometryProperties,
|
BIMExternalParametricGeometryProperties,
|
||||||
BIMModelProperties,
|
BIMModelProperties,
|
||||||
|
BIMPipeSegmentProperties,
|
||||||
BIMPolylineProperties,
|
BIMPolylineProperties,
|
||||||
BIMRailingProperties,
|
BIMRailingProperties,
|
||||||
BIMRoofProperties,
|
BIMRoofProperties,
|
||||||
@@ -113,6 +118,14 @@ class Model(bonsai.core.tool.Model):
|
|||||||
def get_railing_props(cls, obj: bpy.types.Object) -> BIMRailingProperties:
|
def get_railing_props(cls, obj: bpy.types.Object) -> BIMRailingProperties:
|
||||||
return obj.BIMRailingProperties # pyright: ignore[reportAttributeAccessIssue]
|
return obj.BIMRailingProperties # pyright: ignore[reportAttributeAccessIssue]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_pipe_segment_props(cls, obj: bpy.types.Object) -> BIMPipeSegmentProperties:
|
||||||
|
return obj.BIMPipeSegmentProperties # pyright: ignore[reportAttributeAccessIssue]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_duct_segment_props(cls, obj: bpy.types.Object) -> BIMDuctSegmentProperties:
|
||||||
|
return obj.BIMDuctSegmentProperties # pyright: ignore[reportAttributeAccessIssue]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_sverchok_props(cls, obj: bpy.types.Object) -> BIMSverchokProperties:
|
def get_sverchok_props(cls, obj: bpy.types.Object) -> BIMSverchokProperties:
|
||||||
return obj.BIMSverchokProperties # pyright: ignore[reportAttributeAccessIssue]
|
return obj.BIMSverchokProperties # pyright: ignore[reportAttributeAccessIssue]
|
||||||
@@ -130,6 +143,35 @@ class Model(bonsai.core.tool.Model):
|
|||||||
assert (scene := bpy.context.scene)
|
assert (scene := bpy.context.scene)
|
||||||
return scene.BIMPolylineProperties # pyright: ignore[reportAttributeAccessIssue]
|
return scene.BIMPolylineProperties # pyright: ignore[reportAttributeAccessIssue]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def resolve_active_props_for_edit(
|
||||||
|
cls,
|
||||||
|
context: bpy.types.Context,
|
||||||
|
props_getter: Callable[[bpy.types.Object], Any],
|
||||||
|
*,
|
||||||
|
subtype: Optional[tuple[str, Any]] = None,
|
||||||
|
) -> Optional[tuple[bpy.types.Object, Any]]:
|
||||||
|
"""Resolve ``(obj, props)`` for an operator that acts on the active
|
||||||
|
object only while a parametric edit is active.
|
||||||
|
|
||||||
|
Returns ``None`` (the operator should ``return {"CANCELLED"}``) when
|
||||||
|
any of these fail:
|
||||||
|
- no active object,
|
||||||
|
- ``props.is_editing`` is False,
|
||||||
|
- ``subtype`` is given as ``(attr, value)`` and ``props.<attr> != value``.
|
||||||
|
"""
|
||||||
|
obj = context.active_object
|
||||||
|
if not obj:
|
||||||
|
return None
|
||||||
|
props = props_getter(obj)
|
||||||
|
if not getattr(props, "is_editing", False):
|
||||||
|
return None
|
||||||
|
if subtype is not None:
|
||||||
|
attr, value = subtype
|
||||||
|
if getattr(props, attr, None) != value:
|
||||||
|
return None
|
||||||
|
return obj, props
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def convert_si_to_unit(cls, value: T) -> T:
|
def convert_si_to_unit(cls, value: T) -> T:
|
||||||
if isinstance(value, (tuple, list)):
|
if isinstance(value, (tuple, list)):
|
||||||
@@ -799,7 +841,7 @@ class Model(bonsai.core.tool.Model):
|
|||||||
assert element or representation, "Either element or representation must be provided."
|
assert element or representation, "Either element or representation must be provided."
|
||||||
if representation is None:
|
if representation is None:
|
||||||
assert element
|
assert element
|
||||||
representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
|
representation = tool.Geometry.get_body_representation(element)
|
||||||
if not representation:
|
if not representation:
|
||||||
return []
|
return []
|
||||||
booleans = []
|
booleans = []
|
||||||
@@ -820,7 +862,7 @@ class Model(bonsai.core.tool.Model):
|
|||||||
return []
|
return []
|
||||||
boolean_ids = json.loads(pset["Data"])
|
boolean_ids = json.loads(pset["Data"])
|
||||||
if representation is None:
|
if representation is None:
|
||||||
representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
|
representation = tool.Geometry.get_body_representation(element)
|
||||||
if not representation:
|
if not representation:
|
||||||
return []
|
return []
|
||||||
booleans = [b for b in cls.get_booleans(element, representation) if b.id() in boolean_ids]
|
booleans = [b for b in cls.get_booleans(element, representation) if b.id() in boolean_ids]
|
||||||
@@ -909,7 +951,7 @@ class Model(bonsai.core.tool.Model):
|
|||||||
# Revolved area check should happen inside bim.enable_editing_extrusion_axis
|
# Revolved area check should happen inside bim.enable_editing_extrusion_axis
|
||||||
# but keep it here to trigger import_representation_items,
|
# but keep it here to trigger import_representation_items,
|
||||||
# so users will be able to at least move IfcRevolvedAreaSolid, until there will be a full support.
|
# so users will be able to at least move IfcRevolvedAreaSolid, until there will be a full support.
|
||||||
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
|
body = tool.Geometry.get_body_representation(element)
|
||||||
if body and any(
|
if body and any(
|
||||||
i.is_a("IfcRevolvedAreaSolid") for i in ifcopenshell.util.representation.resolve_base_items(body)
|
i.is_a("IfcRevolvedAreaSolid") for i in ifcopenshell.util.representation.resolve_base_items(body)
|
||||||
):
|
):
|
||||||
@@ -1022,7 +1064,14 @@ class Model(bonsai.core.tool.Model):
|
|||||||
def handle_array_on_copied_element(
|
def handle_array_on_copied_element(
|
||||||
cls, element: ifcopenshell.entity_instance, array_data: Optional[dict[str, Any]] = None
|
cls, element: ifcopenshell.entity_instance, array_data: Optional[dict[str, Any]] = None
|
||||||
) -> None:
|
) -> None:
|
||||||
"""if no `array_data` is provided then an array will be removed from the element"""
|
"""Post-copy hook: decide what to do with the BBIM_Array pset a copy
|
||||||
|
inherits from its source.
|
||||||
|
|
||||||
|
- ``array_data=None`` — detach the copy from any array. Removes the
|
||||||
|
inherited BBIM_Array pset and any CHILD_OF constraint.
|
||||||
|
- ``array_data`` provided — promote the copy to a fresh array parent
|
||||||
|
with an empty children list, using the provided layer config.
|
||||||
|
"""
|
||||||
|
|
||||||
if array_data is None:
|
if array_data is None:
|
||||||
array_pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
|
array_pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
|
||||||
@@ -1066,8 +1115,8 @@ class Model(bonsai.core.tool.Model):
|
|||||||
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=array_pset, properties={"Data": json_data})
|
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=array_pset, properties={"Data": json_data})
|
||||||
|
|
||||||
for i in range(len(array_data)):
|
for i in range(len(array_data)):
|
||||||
tool.Blender.Modifier.Array.set_children_lock_state(element, i, True)
|
tool.Array.set_children_lock_state(element, i, True)
|
||||||
tool.Blender.Modifier.Array.constrain_children_to_parent(element)
|
tool.Array.constrain_children_to_parent(element)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def regenerate_array(
|
def regenerate_array(
|
||||||
@@ -1104,12 +1153,17 @@ class Model(bonsai.core.tool.Model):
|
|||||||
offset = base_offset * i
|
offset = base_offset * i
|
||||||
|
|
||||||
for obj in obj_stack:
|
for obj in obj_stack:
|
||||||
|
# IndexError when child_i is past the recorded children list
|
||||||
|
# (count grew); RuntimeError when by_guid finds no entity (the
|
||||||
|
# child was deleted outside the array op); AssertionError when
|
||||||
|
# the IFC entity exists but its Blender object was unlinked.
|
||||||
|
# All three fall through to duplication.
|
||||||
try:
|
try:
|
||||||
global_id = array["children"][child_i]
|
global_id = array["children"][child_i]
|
||||||
child_element = tool.Ifc.get().by_guid(global_id)
|
child_element = tool.Ifc.get().by_guid(global_id)
|
||||||
child_obj = tool.Ifc.get_object(child_element)
|
child_obj = tool.Ifc.get_object(child_element)
|
||||||
assert child_obj
|
assert child_obj
|
||||||
except:
|
except (IndexError, RuntimeError, AssertionError):
|
||||||
old_to_new, _ = tool.Geometry.duplicate_ifc_objects([parent_obj])
|
old_to_new, _ = tool.Geometry.duplicate_ifc_objects([parent_obj])
|
||||||
child_element = next(iter(old_to_new.values()))[0]
|
child_element = next(iter(old_to_new.values()))[0]
|
||||||
child_obj = tool.Ifc.get_object(child_element)
|
child_obj = tool.Ifc.get_object(child_element)
|
||||||
@@ -1146,14 +1200,24 @@ class Model(bonsai.core.tool.Model):
|
|||||||
removed_children = set(existing_children) - set(array["children"])
|
removed_children = set(existing_children) - set(array["children"])
|
||||||
for removed_child in removed_children:
|
for removed_child in removed_children:
|
||||||
element = tool.Ifc.get().by_guid(removed_child)
|
element = tool.Ifc.get().by_guid(removed_child)
|
||||||
|
# Strip any wall/slab opening cut by this child before deletion,
|
||||||
|
# so the host's HasOpenings shrinks symmetrically with count.
|
||||||
|
if getattr(element, "FillsVoids", None):
|
||||||
|
ifcopenshell.api.feature.remove_feature(
|
||||||
|
tool.Ifc.get(), feature=element.FillsVoids[0].RelatingOpeningElement
|
||||||
|
)
|
||||||
obj = tool.Ifc.get_object(element)
|
obj = tool.Ifc.get_object(element)
|
||||||
if obj:
|
if obj:
|
||||||
tool.Geometry.delete_ifc_object(obj)
|
tool.Geometry.delete_ifc_object(obj)
|
||||||
|
|
||||||
|
if array.get("per_child_opening", array.get("mirror_to_host", True)) and children_elements:
|
||||||
|
cls.mirror_parent_void_fillings_to_children(parent_element, children_elements)
|
||||||
|
|
||||||
if array_i in array_layers_to_apply:
|
if array_i in array_layers_to_apply:
|
||||||
for child_element in children_elements:
|
for child_element in children_elements:
|
||||||
pset = tool.Pset.get_element_pset(child_element, "BBIM_Array")
|
pset = tool.Pset.get_element_pset(child_element, "BBIM_Array")
|
||||||
ifcopenshell.api.pset.remove_pset(tool.Ifc.get(), product=child_element, pset=pset)
|
ifcopenshell.api.pset.remove_pset(tool.Ifc.get(), product=child_element, pset=pset)
|
||||||
|
cls.unshare_opening_representation(child_element)
|
||||||
|
|
||||||
array["children"] = []
|
array["children"] = []
|
||||||
array["count"] = 1
|
array["count"] = 1
|
||||||
@@ -1166,6 +1230,112 @@ class Model(bonsai.core.tool.Model):
|
|||||||
tool.Ifc.get(), pset=pset, properties={"Data": json_data, "Parent": parent_element.GlobalId}
|
tool.Ifc.get(), pset=pset, properties={"Data": json_data, "Parent": parent_element.GlobalId}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Post-condition: parent is selected on return. duplicate_ifc_objects
|
||||||
|
# deselects the source on every call inside the regen loop; without
|
||||||
|
# this restore, callers get a deselected parent for arrays with N >= 2.
|
||||||
|
# TODO: batch the per-child duplicate_ifc_objects([parent]) calls into
|
||||||
|
# a single N-way duplicate — N depsgraph churns + N select/deselect
|
||||||
|
# flips is wasteful, and a batched duplicate would also remove the
|
||||||
|
# need for this restore.
|
||||||
|
parent_obj.select_set(True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def mirror_parent_void_fillings_to_children(
|
||||||
|
cls,
|
||||||
|
parent_element: ifcopenshell.entity_instance,
|
||||||
|
children_elements: Sequence[ifcopenshell.entity_instance],
|
||||||
|
) -> None:
|
||||||
|
"""Replicate the parent's FillsVoids → host chain onto each array child.
|
||||||
|
|
||||||
|
For each child, tears down any stale opening, creates a new
|
||||||
|
IfcOpeningElement at the child's current placement, reuses the parent's
|
||||||
|
opening representation as a MappedRepresentation, and adds the
|
||||||
|
void + filling pair so the host element is cut once per child.
|
||||||
|
|
||||||
|
No-op when the parent is not a filling, when the host element cannot
|
||||||
|
be resolved, or when the children list is empty. Opt out via the
|
||||||
|
per-layer ``per_child_opening`` flag on ``BBIM_Array.Data`` (legacy
|
||||||
|
key ``mirror_to_host`` still honoured for round-trip with older files).
|
||||||
|
"""
|
||||||
|
host = tool.Spatial.get_host_element(parent_element)
|
||||||
|
if host is None or not children_elements:
|
||||||
|
return
|
||||||
|
|
||||||
|
ifc_file = tool.Ifc.get()
|
||||||
|
parent_opening = parent_element.FillsVoids[0].RelatingOpeningElement
|
||||||
|
parent_opening_rep = ifcopenshell.util.representation.get_representation(
|
||||||
|
parent_opening, "Model", "Body", "MODEL_VIEW"
|
||||||
|
)
|
||||||
|
if parent_opening_rep is None:
|
||||||
|
return
|
||||||
|
parent_opening_rep = ifcopenshell.util.representation.resolve_representation(parent_opening_rep)
|
||||||
|
|
||||||
|
for child in children_elements:
|
||||||
|
if getattr(child, "FillsVoids", None):
|
||||||
|
ifcopenshell.api.feature.remove_feature(ifc_file, feature=child.FillsVoids[0].RelatingOpeningElement)
|
||||||
|
child_obj = tool.Ifc.get_object(child)
|
||||||
|
if child_obj is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_opening = ifcopenshell.api.root.create_entity(
|
||||||
|
ifc_file,
|
||||||
|
ifc_class="IfcOpeningElement",
|
||||||
|
predefined_type="OPENING",
|
||||||
|
name="Opening",
|
||||||
|
)
|
||||||
|
ifcopenshell.api.geometry.edit_object_placement(
|
||||||
|
ifc_file,
|
||||||
|
product=new_opening,
|
||||||
|
matrix=np.array(child_obj.matrix_world),
|
||||||
|
is_si=True,
|
||||||
|
)
|
||||||
|
mapped_representation = ifcopenshell.api.geometry.map_representation(
|
||||||
|
ifc_file, representation=parent_opening_rep
|
||||||
|
)
|
||||||
|
ifcopenshell.api.geometry.assign_representation(
|
||||||
|
ifc_file, product=new_opening, representation=mapped_representation
|
||||||
|
)
|
||||||
|
ifcopenshell.api.feature.add_feature(ifc_file, feature=new_opening, element=host)
|
||||||
|
ifcopenshell.api.feature.add_filling(ifc_file, opening=new_opening, element=child)
|
||||||
|
|
||||||
|
# Openings affect every sub-element of an aggregate, not just the named host.
|
||||||
|
voided_objs: list[bpy.types.Object] = []
|
||||||
|
host_obj = tool.Ifc.get_object(host)
|
||||||
|
if host_obj is not None:
|
||||||
|
voided_objs.append(host_obj)
|
||||||
|
for subelement in tool.Aggregate.get_parts_recursively(host):
|
||||||
|
subobj = tool.Ifc.get_object(subelement)
|
||||||
|
if subobj is not None:
|
||||||
|
voided_objs.append(subobj)
|
||||||
|
|
||||||
|
for voided_obj in voided_objs:
|
||||||
|
if not voided_obj.data:
|
||||||
|
continue
|
||||||
|
voided_element = tool.Ifc.get_entity(voided_obj)
|
||||||
|
if voided_element is None:
|
||||||
|
continue
|
||||||
|
context = tool.Geometry.get_active_representation_context(voided_obj)
|
||||||
|
representation = tool.Geometry.get_representation_by_context(voided_element, context)
|
||||||
|
if representation is None:
|
||||||
|
continue
|
||||||
|
bonsai.core.geometry.switch_representation(
|
||||||
|
tool.Ifc, tool.Geometry, obj=voided_obj, representation=representation
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def unshare_opening_representation(cls, filling: ifcopenshell.entity_instance) -> None:
|
||||||
|
"""Detach a filling's opening representation from any shared mapped body.
|
||||||
|
|
||||||
|
Required when a Bonsai array child is promoted to an independent
|
||||||
|
object: the array's per-child opening mirror builds each child's
|
||||||
|
opening representation as an ``IfcMappedRepresentation`` over the
|
||||||
|
parent opening's body. Without this detach, a later edit replacing
|
||||||
|
the parent body rewrites the shared ``IfcRepresentationMap`` and
|
||||||
|
reshapes the former-child's opening too."""
|
||||||
|
if not getattr(filling, "FillsVoids", None):
|
||||||
|
return
|
||||||
|
tool.Geometry.detach_representation(filling.FillsVoids[0].RelatingOpeningElement)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def replace_object_ifc_representation(
|
def replace_object_ifc_representation(
|
||||||
cls,
|
cls,
|
||||||
@@ -1362,8 +1532,7 @@ class Model(bonsai.core.tool.Model):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def sync_object_ifc_position(cls, obj: bpy.types.Object) -> None:
|
def sync_object_ifc_position(cls, obj: bpy.types.Object) -> None:
|
||||||
"""make sure IFC position will be in sync with the Blender object position, if object was moved in Blender"""
|
"""make sure IFC position will be in sync with the Blender object position, if object was moved in Blender"""
|
||||||
if tool.Ifc.is_moved(obj):
|
tool.Geometry.commit_placement_if_moved(obj)
|
||||||
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_element_matrix(cls, element: ifcopenshell.entity_instance, keep_local: bool = False) -> Matrix:
|
def get_element_matrix(cls, element: ifcopenshell.entity_instance, keep_local: bool = False) -> Matrix:
|
||||||
@@ -1395,7 +1564,7 @@ class Model(bonsai.core.tool.Model):
|
|||||||
if not obj.data:
|
if not obj.data:
|
||||||
continue
|
continue
|
||||||
element = tool.Ifc.get_entity(obj)
|
element = tool.Ifc.get_entity(obj)
|
||||||
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
|
body = tool.Geometry.get_body_representation(element)
|
||||||
bonsai.core.geometry.switch_representation(
|
bonsai.core.geometry.switch_representation(
|
||||||
tool.Ifc,
|
tool.Ifc,
|
||||||
tool.Geometry,
|
tool.Geometry,
|
||||||
@@ -1512,6 +1681,10 @@ class Model(bonsai.core.tool.Model):
|
|||||||
"TRIPLE_PANEL_VERTICAL",
|
"TRIPLE_PANEL_VERTICAL",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
RoofGenerationMethod = Literal["HEIGHT", "ANGLE"]
|
||||||
|
|
||||||
|
RailingType = Literal["FRAMELESS_PANEL", "WALL_MOUNTED_HANDRAIL"]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def generate_stair_2d_profile(
|
def generate_stair_2d_profile(
|
||||||
cls,
|
cls,
|
||||||
@@ -1763,7 +1936,7 @@ class Model(bonsai.core.tool.Model):
|
|||||||
from bonsai.bim.module.model.opening import FilledOpeningGenerator
|
from bonsai.bim.module.model.opening import FilledOpeningGenerator
|
||||||
|
|
||||||
ifc_file = tool.Ifc.get()
|
ifc_file = tool.Ifc.get()
|
||||||
fillings = {e: tool.Ifc.get_object(e) for e in tool.Ifc.get_all_element_occurrences(element)}
|
fillings = {e: tool.Ifc.get_object(e) for e in tool.Array.get_parametric_propagation_targets(element)}
|
||||||
|
|
||||||
voided_objs = set()
|
voided_objs = set()
|
||||||
has_replaced_opening_representation = False
|
has_replaced_opening_representation = False
|
||||||
@@ -1905,7 +2078,9 @@ class Model(bonsai.core.tool.Model):
|
|||||||
|
|
||||||
bm = bmesh.new()
|
bm = bmesh.new()
|
||||||
bm.from_mesh(mesh)
|
bm.from_mesh(mesh)
|
||||||
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-4)
|
# Looser than auto_detect_curves' VTX_PRECISION: profiles must close into
|
||||||
|
# a single loop, so nearly-coincident endpoints should snap together.
|
||||||
|
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=WELD_TOLERANCE)
|
||||||
bmesh.ops.delete(bm, geom=bm.faces, context="FACES_ONLY")
|
bmesh.ops.delete(bm, geom=bm.faces, context="FACES_ONLY")
|
||||||
|
|
||||||
# https://docs.blender.org/api/blender_python_api_2_63_8/bmesh.html#CustomDataAccess
|
# https://docs.blender.org/api/blender_python_api_2_63_8/bmesh.html#CustomDataAccess
|
||||||
@@ -2133,7 +2308,7 @@ class Model(bonsai.core.tool.Model):
|
|||||||
|
|
||||||
bm = bmesh.new()
|
bm = bmesh.new()
|
||||||
bm.from_mesh(mesh)
|
bm.from_mesh(mesh)
|
||||||
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-5)
|
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=VTX_PRECISION)
|
||||||
bmesh.ops.delete(bm, geom=bm.faces, context="FACES_ONLY")
|
bmesh.ops.delete(bm, geom=bm.faces, context="FACES_ONLY")
|
||||||
|
|
||||||
# https://docs.blender.org/api/blender_python_api_2_63_8/bmesh.html#CustomDataAccess
|
# https://docs.blender.org/api/blender_python_api_2_63_8/bmesh.html#CustomDataAccess
|
||||||
@@ -2352,6 +2527,12 @@ class Model(bonsai.core.tool.Model):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_existing_x_angle(cls, extrusion: ifcopenshell.entity_instance) -> float:
|
def get_existing_x_angle(cls, extrusion: ifcopenshell.entity_instance) -> float:
|
||||||
|
"""Signed slope of the extrusion's direction in the y-z plane (radians).
|
||||||
|
|
||||||
|
Assumes extrusion directions lie in the y-z plane (LAYER2 wall and
|
||||||
|
LAYER3 slab convention). For inverted extrusions (z ≤ 0), adds π to
|
||||||
|
preserve angular continuity for callers consuming the angle via
|
||||||
|
cos/sin."""
|
||||||
x, y, z = extrusion.ExtrudedDirection.DirectionRatios
|
x, y, z = extrusion.ExtrudedDirection.DirectionRatios
|
||||||
vector = Vector((0, 1))
|
vector = Vector((0, 1))
|
||||||
x_angle = vector.angle_signed(Vector((y, z)))
|
x_angle = vector.angle_signed(Vector((y, z)))
|
||||||
@@ -2700,6 +2881,20 @@ class Model(bonsai.core.tool.Model):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def recreate_wall(cls, element: ifcopenshell.entity_instance, obj: bpy.types.Object) -> None:
|
def recreate_wall(cls, element: ifcopenshell.entity_instance, obj: bpy.types.Object) -> None:
|
||||||
|
# Curved fillet-corner walls own a hand-built banana body that
|
||||||
|
# ``regenerate_wall_representation`` would flatten — it reads the
|
||||||
|
# axis as a 2-point reference line and builds a straight extrusion.
|
||||||
|
# Instead rebuild the curve in place: ``regenerate_fillet_corner_wall``
|
||||||
|
# keeps radius + placement from the pset / current ``ObjectPlacement``
|
||||||
|
# while picking up new thickness / height from the wall type, which
|
||||||
|
# is what we want when a type-property edit triggered this call.
|
||||||
|
if ifcopenshell.util.element.get_pset(element, "BBIM_Wall", "IsFilletCorner"):
|
||||||
|
# Lazy import: ``tool.Model`` loads before ``bim/module/model``
|
||||||
|
# at addon enable; a module-level import would cycle.
|
||||||
|
from bonsai.bim.module.model.wall import regenerate_fillet_corner_wall
|
||||||
|
|
||||||
|
regenerate_fillet_corner_wall(element, obj)
|
||||||
|
return
|
||||||
rep = ifcopenshell.api.geometry.regenerate_wall_representation(tool.Ifc.get(), element)
|
rep = ifcopenshell.api.geometry.regenerate_wall_representation(tool.Ifc.get(), element)
|
||||||
bonsai.core.geometry.switch_representation(
|
bonsai.core.geometry.switch_representation(
|
||||||
tool.Ifc,
|
tool.Ifc,
|
||||||
@@ -2720,28 +2915,29 @@ class Model(bonsai.core.tool.Model):
|
|||||||
queue: set[tuple[ifcopenshell.entity_instance, bpy.types.Object]] = set()
|
queue: set[tuple[ifcopenshell.entity_instance, bpy.types.Object]] = set()
|
||||||
for wall in walls:
|
for wall in walls:
|
||||||
element = tool.Ifc.get_entity(wall)
|
element = tool.Ifc.get_entity(wall)
|
||||||
if tool.Ifc.is_moved(wall):
|
tool.Geometry.commit_placement_if_moved(wall)
|
||||||
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=wall)
|
|
||||||
queue.add((element, wall))
|
queue.add((element, wall))
|
||||||
for rel in getattr(element, "ConnectedTo", []):
|
for rel in getattr(element, "ConnectedTo", []):
|
||||||
obj = tool.Ifc.get_object(rel.RelatedElement)
|
obj = tool.Ifc.get_object(rel.RelatedElement)
|
||||||
if tool.Ifc.is_moved(obj):
|
tool.Geometry.commit_placement_if_moved(obj)
|
||||||
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
|
|
||||||
queue.add((rel.RelatedElement, obj))
|
queue.add((rel.RelatedElement, obj))
|
||||||
for rel in getattr(element, "ConnectedFrom", []):
|
for rel in getattr(element, "ConnectedFrom", []):
|
||||||
obj = tool.Ifc.get_object(rel.RelatingElement)
|
obj = tool.Ifc.get_object(rel.RelatingElement)
|
||||||
if tool.Ifc.is_moved(obj):
|
tool.Geometry.commit_placement_if_moved(obj)
|
||||||
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
|
|
||||||
queue.add((rel.RelatingElement, obj))
|
queue.add((rel.RelatingElement, obj))
|
||||||
for element, wall in queue:
|
for element, wall in queue:
|
||||||
if tool.Model.get_usage_type(element) == "LAYER2" and wall:
|
if not wall:
|
||||||
# Use layer custom offset
|
continue
|
||||||
|
is_layer2_usage = tool.Model.get_usage_type(element) == "LAYER2"
|
||||||
|
is_fillet_corner = bool(ifcopenshell.util.element.get_pset(element, "BBIM_Wall", "IsFilletCorner"))
|
||||||
|
if not (is_layer2_usage or is_fillet_corner):
|
||||||
|
continue
|
||||||
|
if is_layer2_usage:
|
||||||
custom_offset = tool.Model.get_material_layer_custom_offset(element, wall)
|
custom_offset = tool.Model.get_material_layer_custom_offset(element, wall)
|
||||||
material = ifcopenshell.util.element.get_material(element)
|
material = ifcopenshell.util.element.get_material(element)
|
||||||
if material.is_a("IfcMaterialLayerSetUsage") and custom_offset is not None:
|
if material.is_a("IfcMaterialLayerSetUsage") and custom_offset is not None:
|
||||||
material.OffsetFromReferenceLine = custom_offset
|
material.OffsetFromReferenceLine = custom_offset
|
||||||
|
cls.recreate_wall(element, wall)
|
||||||
cls.recreate_wall(element, wall)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def regenerate_slab(cls, obj: bpy.types.Object) -> None:
|
def regenerate_slab(cls, obj: bpy.types.Object) -> None:
|
||||||
|
|||||||
@@ -18,10 +18,12 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from typing import TYPE_CHECKING, Any, Literal, Union, assert_never
|
from typing import TYPE_CHECKING, Any, Literal, Union, assert_never
|
||||||
|
|
||||||
import bpy
|
import bpy
|
||||||
import ifcopenshell
|
import ifcopenshell
|
||||||
|
import ifcopenshell.api.pset
|
||||||
import ifcopenshell.util.attribute
|
import ifcopenshell.util.attribute
|
||||||
import ifcopenshell.util.element
|
import ifcopenshell.util.element
|
||||||
|
|
||||||
@@ -74,6 +76,34 @@ class Pset(bonsai.core.tool.Pset):
|
|||||||
if pset:
|
if pset:
|
||||||
return tool.Ifc.get().by_id(pset["id"])
|
return tool.Ifc.get().by_id(pset["id"])
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def upsert_pset(
|
||||||
|
cls,
|
||||||
|
element: ifcopenshell.entity_instance,
|
||||||
|
pset_name: str,
|
||||||
|
properties: dict[str, Any],
|
||||||
|
) -> ifcopenshell.entity_instance:
|
||||||
|
"""Get or create ``pset_name`` on ``element``, write ``properties``, return the pset.
|
||||||
|
Centralises the get-element-pset → add-pset-if-missing → edit-pset idiom."""
|
||||||
|
ifc_file = tool.Ifc.get()
|
||||||
|
pset = cls.get_element_pset(element, pset_name)
|
||||||
|
if not pset:
|
||||||
|
pset = ifcopenshell.api.pset.add_pset(ifc_file, product=element, name=pset_name)
|
||||||
|
ifcopenshell.api.pset.edit_pset(ifc_file, pset=pset, properties=properties)
|
||||||
|
return pset
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def write_bbim_data(
|
||||||
|
cls,
|
||||||
|
element: ifcopenshell.entity_instance,
|
||||||
|
pset_name: str,
|
||||||
|
data: dict[str, Any],
|
||||||
|
) -> ifcopenshell.entity_instance:
|
||||||
|
"""Get or create the BBIM_<Type> pset and write ``data`` as the IfcText-serialised
|
||||||
|
JSON ``Data`` property. Canonical writer for parametric-modifier pset state."""
|
||||||
|
data_text = tool.Ifc.get().createIfcText(json.dumps(data, default=list))
|
||||||
|
return cls.upsert_pset(element, pset_name, {"Data": data_text})
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_pset_props(cls, obj: str, obj_type: tool.Ifc.OBJECT_TYPE) -> PsetProperties:
|
def get_pset_props(cls, obj: str, obj_type: tool.Ifc.OBJECT_TYPE) -> PsetProperties:
|
||||||
if obj_type == "Object":
|
if obj_type == "Object":
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Bonsai - OpenBIM Blender Add-on
|
||||||
|
# Copyright (C) 2026
|
||||||
|
#
|
||||||
|
# This file is part of Bonsai.
|
||||||
|
#
|
||||||
|
# Bonsai is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# Bonsai is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
# This file was generated with the assistance of an AI coding tool.
|
||||||
|
|
||||||
|
"""Side-effect-free slab helpers — IFC reads for LAYER3 extrusions.
|
||||||
|
|
||||||
|
Exposes ``read_geometry``: a single live read of the parametric attributes
|
||||||
|
(extrusion depth and slope) that drive icon placement and dimension display
|
||||||
|
on a LAYER3 slab. Lives in ``tool/`` so bim-layer callers can stay
|
||||||
|
declarative — they get a dict, not an IFC walk."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, TypedDict
|
||||||
|
|
||||||
|
import ifcopenshell.util.unit
|
||||||
|
|
||||||
|
import bonsai.core.tool
|
||||||
|
import bonsai.tool as tool
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import bpy
|
||||||
|
|
||||||
|
|
||||||
|
class SlabGeometry(TypedDict):
|
||||||
|
depth: float
|
||||||
|
x_angle: float
|
||||||
|
|
||||||
|
|
||||||
|
class Slab(bonsai.core.tool.Slab):
|
||||||
|
@classmethod
|
||||||
|
def read_geometry(cls, obj: bpy.types.Object) -> SlabGeometry | None:
|
||||||
|
"""Live-read slab parametric geometry as a dict, or ``None`` if the
|
||||||
|
object is not a LAYER3 extruded slab.
|
||||||
|
|
||||||
|
Returned keys (all SI units): ``depth`` (extrusion thickness along the
|
||||||
|
slab's local Z), ``x_angle`` (slope in radians; zero for level slabs).
|
||||||
|
|
||||||
|
The slope is encoded in ``obj.matrix_world`` as a post-rotation, so
|
||||||
|
callers projecting world points into slab-local space via
|
||||||
|
``mw.inverted()`` will see a level frame whose Z runs along the slab
|
||||||
|
thickness — ``x_angle`` is reported for callers that need the slope
|
||||||
|
as a scalar but is already applied by the placement."""
|
||||||
|
element = tool.Ifc.get_entity(obj)
|
||||||
|
if not element or not tool.Blender.Modifier.is_slab(element):
|
||||||
|
return None
|
||||||
|
representation = tool.Geometry.get_body_representation(element)
|
||||||
|
if not representation:
|
||||||
|
return None
|
||||||
|
extrusion = tool.Model.get_extrusion(representation)
|
||||||
|
if not extrusion:
|
||||||
|
return None
|
||||||
|
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||||
|
x_angle = tool.Model.get_existing_x_angle(extrusion)
|
||||||
|
return {
|
||||||
|
"depth": extrusion.Depth * unit_scale,
|
||||||
|
"x_angle": x_angle,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user