mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-11 02:02:22 +00:00
Merge remote-tracking branch 'origin/v0.8.0' into ifcviewer-wgpu
This commit is contained in:
@@ -329,6 +329,7 @@ class _ArrayEditMixin(ParametricEditMixinBase):
|
|||||||
# Unhide the (possibly newly-regenerated) children so the user sees
|
# Unhide the (possibly newly-regenerated) children so the user sees
|
||||||
# the committed result. Mirrors the hide in ``_enable_one``.
|
# the committed result. Mirrors the hide in ``_enable_one``.
|
||||||
cls._set_children_visibility(element, hidden=False)
|
cls._set_children_visibility(element, hidden=False)
|
||||||
|
tool.Array.select_only_parent(obj, context)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _cancel_one(cls, obj: bpy.types.Object) -> None:
|
def _cancel_one(cls, obj: bpy.types.Object) -> None:
|
||||||
@@ -421,9 +422,9 @@ class RegenerateArray(bpy.types.Operator, tool.Ifc.Operator):
|
|||||||
pset = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array")
|
pset = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array")
|
||||||
arrays = json.loads(pset["Data"])
|
arrays = json.loads(pset["Data"])
|
||||||
pset = tool.Ifc.get().by_id(pset["id"])
|
pset = tool.Ifc.get().by_id(pset["id"])
|
||||||
# Coalesce host recuts: the child-delete loop, the regenerate, and the
|
# Coalesce host recuts across the child-delete loop, the regenerate,
|
||||||
# per-child opening mirror all touch the same host body. Without batching,
|
# and the per-child opening mirror: each fans out its own host body
|
||||||
# an N-child wipe-then-regen costs N+1 recuts; this collapses to one.
|
# recut without the batch wrapper.
|
||||||
with tool.Geometry.batch_host_recut():
|
with tool.Geometry.batch_host_recut():
|
||||||
for array in arrays:
|
for array in arrays:
|
||||||
for child in set(array["children"]):
|
for child in set(array["children"]):
|
||||||
@@ -442,6 +443,8 @@ class RegenerateArray(bpy.types.Operator, tool.Ifc.Operator):
|
|||||||
tool.Model.regenerate_array(parent, arrays)
|
tool.Model.regenerate_array(parent, arrays)
|
||||||
tool.Array.constrain_children_to_parent(parent_element)
|
tool.Array.constrain_children_to_parent(parent_element)
|
||||||
|
|
||||||
|
tool.Array.select_only_parent(parent, context)
|
||||||
|
|
||||||
|
|
||||||
class RemoveArray(bpy.types.Operator, tool.Ifc.Operator):
|
class RemoveArray(bpy.types.Operator, tool.Ifc.Operator):
|
||||||
bl_idname = "bim.remove_array"
|
bl_idname = "bim.remove_array"
|
||||||
|
|||||||
@@ -1677,6 +1677,11 @@ def _n_mep_selected(n: int) -> bool:
|
|||||||
element = tool.Ifc.get_entity(selected_obj)
|
element = tool.Ifc.get_entity(selected_obj)
|
||||||
if element is None or not tool.System.is_mep_element(element):
|
if element is None or not tool.System.is_mep_element(element):
|
||||||
return False
|
return False
|
||||||
|
# Array children mirror their parent's port topology. Writable MEP
|
||||||
|
# actions on a child get wiped by the next array regen, so gate the
|
||||||
|
# icons out at the visibility layer.
|
||||||
|
if tool.Array.is_array_child(element):
|
||||||
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -2555,6 +2560,8 @@ def _active_is_flow_segment(obj: bpy.types.Object) -> bool:
|
|||||||
element = tool.Ifc.get_entity(obj)
|
element = tool.Ifc.get_entity(obj)
|
||||||
if element is None or not element.is_a("IfcFlowSegment"):
|
if element is None or not element.is_a("IfcFlowSegment"):
|
||||||
return False
|
return False
|
||||||
|
if tool.Array.is_array_child(element):
|
||||||
|
return False
|
||||||
return tool.System.has_parametric_body(element)
|
return tool.System.has_parametric_body(element)
|
||||||
|
|
||||||
|
|
||||||
@@ -2584,6 +2591,8 @@ def _active_is_bend_fitting(obj: bpy.types.Object) -> bool:
|
|||||||
element = tool.Ifc.get_entity(obj)
|
element = tool.Ifc.get_entity(obj)
|
||||||
if not _is_bend_fitting(element):
|
if not _is_bend_fitting(element):
|
||||||
return False
|
return False
|
||||||
|
if tool.Array.is_array_child(element):
|
||||||
|
return False
|
||||||
element_type = ifcopenshell.util.element.get_type(element)
|
element_type = ifcopenshell.util.element.get_type(element)
|
||||||
if element_type is None:
|
if element_type is None:
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -1294,6 +1294,11 @@ class LoadProjectElements(bpy.types.Operator):
|
|||||||
if element.IsDecomposedBy:
|
if element.IsDecomposedBy:
|
||||||
for subelement in element.IsDecomposedBy[0].RelatedObjects:
|
for subelement in element.IsDecomposedBy[0].RelatedObjects:
|
||||||
decomposed_elements.add(subelement)
|
decomposed_elements.add(subelement)
|
||||||
|
# IfcSurfaceFeature (e.g. road markings) adhere to a host element
|
||||||
|
# via IfcRelAdheresToElement, a [1:1] hierarchical relationship in
|
||||||
|
# the same family as aggregation, containment and nesting (IFC4.3).
|
||||||
|
for rel in getattr(element, "HasSurfaceFeatures", ()):
|
||||||
|
decomposed_elements.update(rel.RelatedSurfaceFeatures)
|
||||||
if decomposed_elements:
|
if decomposed_elements:
|
||||||
self.append_decomposed_elements(decomposed_elements)
|
self.append_decomposed_elements(decomposed_elements)
|
||||||
elements.update(decomposed_elements)
|
elements.update(decomposed_elements)
|
||||||
|
|||||||
@@ -178,6 +178,25 @@ class Array(bonsai.core.tool.Array):
|
|||||||
element_root = cls.get_array_root_guid(element)
|
element_root = cls.get_array_root_guid(element)
|
||||||
return [o for o in occurrences if cls.get_array_root_guid(o) == element_root]
|
return [o for o in occurrences if cls.get_array_root_guid(o) == element_root]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def select_only_parent(cls, parent_obj: bpy.types.Object, context: bpy.types.Context) -> None:
|
||||||
|
"""Post-condition for the user-facing regenerate and finish-edit paths:
|
||||||
|
only ``parent_obj`` is selected + active. Grow and shrink otherwise
|
||||||
|
diverge on which objects stay selected, surfacing an inconsistency."""
|
||||||
|
tool.Blender.select_and_activate_single_object(context, parent_obj)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_array_child(cls, element: entity_instance) -> bool:
|
||||||
|
"""True when ``element`` is a child of a parametric array — has a
|
||||||
|
BBIM_Array pset whose Parent GUID points to a different element.
|
||||||
|
Lighter than ``get_child_layer_index`` (no ``by_guid`` lookup, no
|
||||||
|
Data parse); suitable for per-element checks in draw handlers."""
|
||||||
|
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
|
||||||
|
if not pset:
|
||||||
|
return False
|
||||||
|
parent_guid = pset.get("Parent")
|
||||||
|
return bool(parent_guid) and parent_guid != element.GlobalId
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_child_layer_index(cls, child_element: entity_instance) -> int | None:
|
def get_child_layer_index(cls, child_element: entity_instance) -> int | None:
|
||||||
"""Index of the layer that produced ``child_element``, or ``None``
|
"""Index of the layer that produced ``child_element``, or ``None``
|
||||||
|
|||||||
@@ -248,32 +248,30 @@ class Duplicate(bonsai.core.tool.Duplicate):
|
|||||||
old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]],
|
old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]],
|
||||||
) -> None:
|
) -> None:
|
||||||
for element, data in relationship.items():
|
for element, data in relationship.items():
|
||||||
try:
|
new_relating_elements = old_to_new.get(data.relating_element) or []
|
||||||
new_relating_element = old_to_new.get(data.relating_element)[0]
|
new_related_elements = old_to_new.get(data.related_element) or []
|
||||||
new_related_element = old_to_new.get(data.related_element)[0]
|
|
||||||
except (KeyError, IndexError, TypeError):
|
|
||||||
continue
|
|
||||||
new_rel = tool.Ifc.run(
|
|
||||||
"geometry.connect_path",
|
|
||||||
relating_element=new_relating_element,
|
|
||||||
related_element=new_related_element,
|
|
||||||
relating_connection=data.relating_connection_type,
|
|
||||||
related_connection=data.related_connection_type,
|
|
||||||
)
|
|
||||||
# connect_path hardcodes priorities to []; restore them post-hoc.
|
# connect_path hardcodes priorities to []; restore them post-hoc.
|
||||||
priority_attrs: dict[str, Any] = {}
|
priority_attrs: dict[str, Any] = {}
|
||||||
if data.relating_priorities:
|
if data.relating_priorities:
|
||||||
priority_attrs["RelatingPriorities"] = data.relating_priorities
|
priority_attrs["RelatingPriorities"] = data.relating_priorities
|
||||||
if data.related_priorities:
|
if data.related_priorities:
|
||||||
priority_attrs["RelatedPriorities"] = data.related_priorities
|
priority_attrs["RelatedPriorities"] = data.related_priorities
|
||||||
if new_rel is not None and priority_attrs:
|
for new_relating_element, new_related_element in zip(new_relating_elements, new_related_elements):
|
||||||
try:
|
new_rel = tool.Ifc.run(
|
||||||
tool.Ifc.run("attribute.edit_attributes", product=new_rel, attributes=priority_attrs)
|
"geometry.connect_path",
|
||||||
except (RuntimeError, ifcopenshell.Error) as e:
|
relating_element=new_relating_element,
|
||||||
cls._emit_warning(
|
related_element=new_related_element,
|
||||||
f"connection priority restore failed for {new_rel}; "
|
relating_connection=data.relating_connection_type,
|
||||||
f"duplicate has empty RelatingPriorities/RelatedPriorities: {e}"
|
related_connection=data.related_connection_type,
|
||||||
)
|
)
|
||||||
|
if new_rel is not None and priority_attrs:
|
||||||
|
try:
|
||||||
|
tool.Ifc.run("attribute.edit_attributes", product=new_rel, attributes=priority_attrs)
|
||||||
|
except (RuntimeError, ifcopenshell.Error) as e:
|
||||||
|
cls._emit_warning(
|
||||||
|
f"connection priority restore failed for {new_rel}; "
|
||||||
|
f"duplicate has empty RelatingPriorities/RelatedPriorities: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def recreate_port_connections(
|
def recreate_port_connections(
|
||||||
@@ -283,46 +281,43 @@ class Duplicate(bonsai.core.tool.Duplicate):
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Recreate ``IfcRelConnectsPorts`` between duplicates; skip records whose duplicate's port count diverges from the snapshot."""
|
"""Recreate ``IfcRelConnectsPorts`` between duplicates; skip records whose duplicate's port count diverges from the snapshot."""
|
||||||
for relating_element, records in snapshot.by_element.items():
|
for relating_element, records in snapshot.by_element.items():
|
||||||
|
new_relatings = old_to_new.get(relating_element) or []
|
||||||
|
expected_relating = snapshot.port_counts.get(relating_element)
|
||||||
for record in records:
|
for record in records:
|
||||||
related_element = record.related_element
|
related_element = record.related_element
|
||||||
try:
|
new_relateds = old_to_new.get(related_element) or []
|
||||||
new_relating = old_to_new[relating_element][0]
|
|
||||||
new_related = old_to_new[related_element][0]
|
|
||||||
except (KeyError, IndexError):
|
|
||||||
continue
|
|
||||||
|
|
||||||
new_relating_ports = tool.System.get_ports(new_relating)
|
|
||||||
new_related_ports = tool.System.get_ports(new_related)
|
|
||||||
|
|
||||||
expected_relating = snapshot.port_counts.get(relating_element)
|
|
||||||
if expected_relating is not None and len(new_relating_ports) != expected_relating:
|
|
||||||
cls._emit_warning(
|
|
||||||
f"port reconnect skipped — duplicate has {len(new_relating_ports)} ports, "
|
|
||||||
f"snapshot had {expected_relating}"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
expected_related = snapshot.port_counts.get(related_element)
|
expected_related = snapshot.port_counts.get(related_element)
|
||||||
if expected_related is not None and len(new_related_ports) != expected_related:
|
for new_relating, new_related in zip(new_relatings, new_relateds):
|
||||||
cls._emit_warning(
|
new_relating_ports = tool.System.get_ports(new_relating)
|
||||||
f"port reconnect skipped — duplicate has {len(new_related_ports)} ports, "
|
new_related_ports = tool.System.get_ports(new_related)
|
||||||
f"snapshot had {expected_related}"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
if expected_relating is not None and len(new_relating_ports) != expected_relating:
|
||||||
new_port_a = new_relating_ports[record.relating_port_index]
|
cls._emit_warning(
|
||||||
new_port_b = new_related_ports[record.related_port_index]
|
f"port reconnect skipped — duplicate has {len(new_relating_ports)} ports, "
|
||||||
except IndexError:
|
f"snapshot had {expected_relating}"
|
||||||
cls._emit_warning(
|
)
|
||||||
f"port reconnect skipped — record references port index past the duplicate's port list"
|
continue
|
||||||
)
|
if expected_related is not None and len(new_related_ports) != expected_related:
|
||||||
continue
|
cls._emit_warning(
|
||||||
try:
|
f"port reconnect skipped — duplicate has {len(new_related_ports)} ports, "
|
||||||
tool.Ifc.run(
|
f"snapshot had {expected_related}"
|
||||||
"system.connect_port",
|
)
|
||||||
port1=new_port_a,
|
continue
|
||||||
port2=new_port_b,
|
|
||||||
direction=record.direction or "NOTDEFINED",
|
try:
|
||||||
)
|
new_port_a = new_relating_ports[record.relating_port_index]
|
||||||
except (RuntimeError, ifcopenshell.Error) as e:
|
new_port_b = new_related_ports[record.related_port_index]
|
||||||
cls._emit_warning(f"port reconnect failed between duplicates: {e}")
|
except IndexError:
|
||||||
|
cls._emit_warning(
|
||||||
|
f"port reconnect skipped — record references port index past the duplicate's port list"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
tool.Ifc.run(
|
||||||
|
"system.connect_port",
|
||||||
|
port1=new_port_a,
|
||||||
|
port2=new_port_b,
|
||||||
|
direction=record.direction or "NOTDEFINED",
|
||||||
|
)
|
||||||
|
except (RuntimeError, ifcopenshell.Error) as e:
|
||||||
|
cls._emit_warning(f"port reconnect failed between duplicates: {e}")
|
||||||
|
|||||||
@@ -173,13 +173,21 @@ class Geometry(bonsai.core.tool.Geometry):
|
|||||||
cls._host_update_queue = {}
|
cls._host_update_queue = {}
|
||||||
cls._host_recut_queue = {}
|
cls._host_recut_queue = {}
|
||||||
for voided_obj in update_queue.values():
|
for voided_obj in update_queue.values():
|
||||||
if not voided_obj or not voided_obj.data:
|
try:
|
||||||
|
if not voided_obj or not voided_obj.data:
|
||||||
|
continue
|
||||||
|
except ReferenceError:
|
||||||
|
# Blender object was deleted while the batch was open
|
||||||
|
# (e.g. user removed it via the outliner mid-op).
|
||||||
continue
|
continue
|
||||||
if tool.Ifc.get_entity(voided_obj) is None:
|
if tool.Ifc.get_entity(voided_obj) is None:
|
||||||
continue
|
continue
|
||||||
bpy.ops.bim.update_representation(obj=voided_obj.name)
|
bpy.ops.bim.update_representation(obj=voided_obj.name)
|
||||||
for voided_obj, _ in recut_queue.values():
|
for voided_obj, _ in recut_queue.values():
|
||||||
if not voided_obj or not voided_obj.data:
|
try:
|
||||||
|
if not voided_obj or not voided_obj.data:
|
||||||
|
continue
|
||||||
|
except ReferenceError:
|
||||||
continue
|
continue
|
||||||
if tool.Ifc.get_entity(voided_obj) is None:
|
if tool.Ifc.get_entity(voided_obj) is None:
|
||||||
continue
|
continue
|
||||||
@@ -2490,99 +2498,16 @@ class Geometry(bonsai.core.tool.Geometry):
|
|||||||
old_obj_name_to_new_obj_name: dict[str, str] = {}
|
old_obj_name_to_new_obj_name: dict[str, str] = {}
|
||||||
|
|
||||||
for obj in objects_to_duplicate:
|
for obj in objects_to_duplicate:
|
||||||
element = tool.Ifc.get_entity(obj)
|
new_active = cls._duplicate_ifc_object_once(
|
||||||
if element:
|
obj,
|
||||||
if element.is_a("IfcAnnotation") and element.ObjectType == "DRAWING":
|
active_object,
|
||||||
tool.Blender.deselect_object(obj)
|
linked,
|
||||||
continue # For now, don't copy drawings until we stabilise a bit more. It's tricky.
|
arrays_to_duplicate,
|
||||||
elif tool.Geometry.is_locked(element):
|
old_to_new,
|
||||||
tool.Blender.deselect_object(obj)
|
old_obj_name_to_new_obj_name,
|
||||||
continue
|
)
|
||||||
elif tool.Geometry.is_representation_item(obj):
|
if new_active is not None:
|
||||||
cls.duplicate_ifc_item(obj)
|
new_active_obj = new_active
|
||||||
continue
|
|
||||||
|
|
||||||
tracked_opening_type = tool.Model.get_tracked_opening_type(obj)
|
|
||||||
is_tracked_opening = bool(tracked_opening_type)
|
|
||||||
keep_data_linked = linked and not element and not is_tracked_opening
|
|
||||||
|
|
||||||
# Prior to duplicating, sync the object placement to make decomposition recreation more stable.
|
|
||||||
cls.commit_placement_if_moved(obj, apply_scale=False)
|
|
||||||
|
|
||||||
new_obj = obj.copy()
|
|
||||||
temp_data = None
|
|
||||||
|
|
||||||
# Currently for optimization we do not apply pending changes (scale or changed .data)
|
|
||||||
# to the original and duplicated objects.
|
|
||||||
# Keep new object edited if original is.
|
|
||||||
if tool.Ifc.is_edited(obj, ignore_scale=True):
|
|
||||||
tool.Ifc.edit(new_obj)
|
|
||||||
|
|
||||||
if obj.data and not keep_data_linked:
|
|
||||||
# assure root.copy_class won't replace the previous mesh globally
|
|
||||||
temp_data = obj.data.copy()
|
|
||||||
new_obj.data = temp_data
|
|
||||||
|
|
||||||
# Unlink from previous boolean element
|
|
||||||
# and keep object tracked for decorations.
|
|
||||||
if is_tracked_opening:
|
|
||||||
mprops = tool.Geometry.get_mesh_props(new_obj.data)
|
|
||||||
mprops.ifc_boolean_id = 0
|
|
||||||
tool.Root.add_tracked_opening(new_obj, tracked_opening_type)
|
|
||||||
|
|
||||||
if obj == active_object:
|
|
||||||
new_active_obj = new_obj
|
|
||||||
for collection in obj.users_collection:
|
|
||||||
collection.objects.link(new_obj)
|
|
||||||
obj.select_set(False)
|
|
||||||
new_obj.select_set(True)
|
|
||||||
old_obj_name_to_new_obj_name[obj.name] = new_obj.name
|
|
||||||
|
|
||||||
if not element:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# clear object's collection so it will be able to have it's own
|
|
||||||
tool.Blender.get_object_bim_props(new_obj).collection = None
|
|
||||||
# copy the actual class
|
|
||||||
new = bonsai.core.root.copy_class(tool.Ifc, tool.Collector, tool.Geometry, tool.Root, obj=new_obj)
|
|
||||||
|
|
||||||
# clean up the orphaned mesh with ifc id of the original object to avoid confusion
|
|
||||||
# IfcGridAxis keeps the same mesh data (it's pointing to ifc id 0, so it's not a problem)
|
|
||||||
if new and temp_data and not new.is_a("IfcGridAxis"):
|
|
||||||
if new.is_a("IfcRelSpaceBoundary"):
|
|
||||||
surface = new.ConnectionGeometry.SurfaceOnRelatingElement
|
|
||||||
temp_data.name = f"0/{surface.id()}"
|
|
||||||
tool.Ifc.link(surface, temp_data)
|
|
||||||
else:
|
|
||||||
tool.Blender.remove_data_block(temp_data)
|
|
||||||
|
|
||||||
if new:
|
|
||||||
# TODO: handle array data for other cases of duplication
|
|
||||||
array_data = arrays_to_duplicate.get(obj, None)
|
|
||||||
tool.Model.handle_array_on_copied_element(new, array_data)
|
|
||||||
if array_data:
|
|
||||||
for child in tool.Array.get_all_children_objects(new):
|
|
||||||
child.select_set(True)
|
|
||||||
|
|
||||||
# TODO: add new array children to recreate their decomposition too
|
|
||||||
old_to_new[element] = [new]
|
|
||||||
if new.is_a("IfcRelSpaceBoundary"):
|
|
||||||
tool.Boundary.decorate_boundary(new_obj)
|
|
||||||
# Slab-trim booleans (from extend_walls_to_underside) belong to
|
|
||||||
# the source wall's connection, not the copy. Strip them so the
|
|
||||||
# duplicate reverts to its pre-clip extrusion — mirrors the way
|
|
||||||
# filling rels are dropped while manual booleans persist on copy.
|
|
||||||
# Reload the body when something was stripped so the viewport
|
|
||||||
# immediately shows the unclipped geometry; otherwise the user
|
|
||||||
# sees a stale mesh until they Shift+G, which is easy to miss.
|
|
||||||
if new.is_a("IfcWall"):
|
|
||||||
if tool.Model.strip_underside_booleans(new):
|
|
||||||
tool.Model.reload_body_representation(new_obj)
|
|
||||||
# HasOpenings rels don't follow object duplication, so
|
|
||||||
# the duplicate's body must rebuild to match its current
|
|
||||||
# opening set.
|
|
||||||
else:
|
|
||||||
tool.Model.regenerate_wall(new_obj)
|
|
||||||
|
|
||||||
# Remap Blender parent relationships for duplicated objects
|
# Remap Blender parent relationships for duplicated objects
|
||||||
for old_obj_name, new_obj_name in old_obj_name_to_new_obj_name.items():
|
for old_obj_name, new_obj_name in old_obj_name_to_new_obj_name.items():
|
||||||
@@ -2610,10 +2535,211 @@ class Geometry(bonsai.core.tool.Geometry):
|
|||||||
# Recreate decompositions
|
# Recreate decompositions
|
||||||
tool.Duplicate.recreate_decompositions(decomposition_relationships, old_to_new)
|
tool.Duplicate.recreate_decompositions(decomposition_relationships, old_to_new)
|
||||||
cls.remove_linked_aggregate_data(old_to_new)
|
cls.remove_linked_aggregate_data(old_to_new)
|
||||||
|
|
||||||
|
# In-loop regenerate_wall runs before recreate_connections, so any new
|
||||||
|
# walls that just received an IfcRelConnectsPathElements have stale
|
||||||
|
# junction geometry — recalculate them now that their connection graph
|
||||||
|
# is complete.
|
||||||
|
cls._recalculate_walls_with_new_connections(old_to_new)
|
||||||
|
|
||||||
bonsai.bim.handler.refresh_ui_data()
|
bonsai.bim.handler.refresh_ui_data()
|
||||||
tool.Root.reload_grid_decorator()
|
tool.Root.reload_grid_decorator()
|
||||||
return old_to_new, new_active_obj or active_object
|
return old_to_new, new_active_obj or active_object
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def duplicate_ifc_object_n_times(
|
||||||
|
cls, source: bpy.types.Object, count: int
|
||||||
|
) -> dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]]:
|
||||||
|
"""N-way duplicate of a single source.
|
||||||
|
|
||||||
|
Same per-copy semantics as duplicate_ifc_objects (IFC class copy,
|
||||||
|
decomposition + connection recreation, body regen for walls), but
|
||||||
|
bypasses the set() dedupe and the arrays_to_duplicate pre-scan so
|
||||||
|
callers building a fresh array don't pay per-call overhead N times.
|
||||||
|
Returns the same old_to_new dict shape, with the source element
|
||||||
|
mapping to the N new entities."""
|
||||||
|
if count <= 0:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
sources = {source}
|
||||||
|
decomposition_relationships = tool.Duplicate.get_decomposition_relationships(sources)
|
||||||
|
connection_relationships = tool.Duplicate.get_connection_relationships(sources)
|
||||||
|
port_connection_snapshot = tool.Duplicate.get_port_connection_relationships(sources)
|
||||||
|
old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]] = {}
|
||||||
|
old_obj_name_to_new_obj_name: dict[str, str] = {}
|
||||||
|
|
||||||
|
for _ in range(count):
|
||||||
|
cls._duplicate_ifc_object_once(
|
||||||
|
source,
|
||||||
|
None,
|
||||||
|
False,
|
||||||
|
{},
|
||||||
|
old_to_new,
|
||||||
|
old_obj_name_to_new_obj_name,
|
||||||
|
keep_source_selected=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
for old_obj_name, new_obj_name in old_obj_name_to_new_obj_name.items():
|
||||||
|
new_obj = bpy.data.objects.get(new_obj_name)
|
||||||
|
if new_obj and new_obj.parent and new_obj.parent.name in old_obj_name_to_new_obj_name:
|
||||||
|
world_matrix = new_obj.matrix_world.copy()
|
||||||
|
new_parent_name = old_obj_name_to_new_obj_name[new_obj.parent.name]
|
||||||
|
new_parent = bpy.data.objects.get(new_parent_name)
|
||||||
|
if new_parent:
|
||||||
|
new_obj.parent = new_parent
|
||||||
|
new_obj.matrix_world = world_matrix
|
||||||
|
|
||||||
|
for old in old_to_new.keys():
|
||||||
|
if old.is_a("IfcElementAssembly"):
|
||||||
|
tool.Root.recreate_aggregate(old_to_new)
|
||||||
|
|
||||||
|
cls.remove_old_connections(old_to_new)
|
||||||
|
tool.Duplicate.recreate_connections(connection_relationships, old_to_new)
|
||||||
|
tool.Duplicate.recreate_port_connections(port_connection_snapshot, old_to_new)
|
||||||
|
tool.Duplicate.recreate_decompositions(decomposition_relationships, old_to_new)
|
||||||
|
cls.remove_linked_aggregate_data(old_to_new)
|
||||||
|
cls._recalculate_walls_with_new_connections(old_to_new)
|
||||||
|
bonsai.bim.handler.refresh_ui_data()
|
||||||
|
tool.Root.reload_grid_decorator()
|
||||||
|
return old_to_new
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _duplicate_ifc_object_once(
|
||||||
|
cls,
|
||||||
|
obj: bpy.types.Object,
|
||||||
|
active_object: Optional[bpy.types.Object],
|
||||||
|
linked: bool,
|
||||||
|
arrays_to_duplicate: dict[bpy.types.Object, Any],
|
||||||
|
old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]],
|
||||||
|
old_obj_name_to_new_obj_name: dict[str, str],
|
||||||
|
keep_source_selected: bool = False,
|
||||||
|
) -> Optional[bpy.types.Object]:
|
||||||
|
"""Per-source body of the duplicate flow. Mutates old_to_new and
|
||||||
|
old_obj_name_to_new_obj_name in place. Returns new_obj when obj is
|
||||||
|
the active_object, else None.
|
||||||
|
|
||||||
|
keep_source_selected: when True, skip the source deselect so batched
|
||||||
|
callers can run N iterations without N×2 select flips and without
|
||||||
|
needing a post-loop restore on the source."""
|
||||||
|
new_active_obj: Optional[bpy.types.Object] = None
|
||||||
|
element = tool.Ifc.get_entity(obj)
|
||||||
|
if element:
|
||||||
|
if element.is_a("IfcAnnotation") and element.ObjectType == "DRAWING":
|
||||||
|
tool.Blender.deselect_object(obj)
|
||||||
|
return None # For now, don't copy drawings until we stabilise a bit more. It's tricky.
|
||||||
|
elif tool.Geometry.is_locked(element):
|
||||||
|
tool.Blender.deselect_object(obj)
|
||||||
|
return None
|
||||||
|
elif tool.Geometry.is_representation_item(obj):
|
||||||
|
cls.duplicate_ifc_item(obj)
|
||||||
|
return None
|
||||||
|
|
||||||
|
tracked_opening_type = tool.Model.get_tracked_opening_type(obj)
|
||||||
|
is_tracked_opening = bool(tracked_opening_type)
|
||||||
|
keep_data_linked = linked and not element and not is_tracked_opening
|
||||||
|
|
||||||
|
# Prior to duplicating, sync the object placement to make decomposition recreation more stable.
|
||||||
|
cls.commit_placement_if_moved(obj, apply_scale=False)
|
||||||
|
|
||||||
|
new_obj = obj.copy()
|
||||||
|
temp_data = None
|
||||||
|
|
||||||
|
# Currently for optimization we do not apply pending changes (scale or changed .data)
|
||||||
|
# to the original and duplicated objects.
|
||||||
|
# Keep new object edited if original is.
|
||||||
|
if tool.Ifc.is_edited(obj, ignore_scale=True):
|
||||||
|
tool.Ifc.edit(new_obj)
|
||||||
|
|
||||||
|
if obj.data and not keep_data_linked:
|
||||||
|
# assure root.copy_class won't replace the previous mesh globally
|
||||||
|
temp_data = obj.data.copy()
|
||||||
|
new_obj.data = temp_data
|
||||||
|
|
||||||
|
# Unlink from previous boolean element
|
||||||
|
# and keep object tracked for decorations.
|
||||||
|
if is_tracked_opening:
|
||||||
|
mprops = tool.Geometry.get_mesh_props(new_obj.data)
|
||||||
|
mprops.ifc_boolean_id = 0
|
||||||
|
tool.Root.add_tracked_opening(new_obj, tracked_opening_type)
|
||||||
|
|
||||||
|
if obj == active_object:
|
||||||
|
new_active_obj = new_obj
|
||||||
|
for collection in obj.users_collection:
|
||||||
|
collection.objects.link(new_obj)
|
||||||
|
if not keep_source_selected:
|
||||||
|
obj.select_set(False)
|
||||||
|
new_obj.select_set(True)
|
||||||
|
old_obj_name_to_new_obj_name[obj.name] = new_obj.name
|
||||||
|
|
||||||
|
if not element:
|
||||||
|
return new_active_obj
|
||||||
|
|
||||||
|
# clear object's collection so it will be able to have it's own
|
||||||
|
tool.Blender.get_object_bim_props(new_obj).collection = None
|
||||||
|
# copy the actual class
|
||||||
|
new = bonsai.core.root.copy_class(tool.Ifc, tool.Collector, tool.Geometry, tool.Root, obj=new_obj)
|
||||||
|
|
||||||
|
# clean up the orphaned mesh with ifc id of the original object to avoid confusion
|
||||||
|
# IfcGridAxis keeps the same mesh data (it's pointing to ifc id 0, so it's not a problem)
|
||||||
|
if new and temp_data and not new.is_a("IfcGridAxis"):
|
||||||
|
if new.is_a("IfcRelSpaceBoundary"):
|
||||||
|
surface = new.ConnectionGeometry.SurfaceOnRelatingElement
|
||||||
|
temp_data.name = f"0/{surface.id()}"
|
||||||
|
tool.Ifc.link(surface, temp_data)
|
||||||
|
else:
|
||||||
|
tool.Blender.remove_data_block(temp_data)
|
||||||
|
|
||||||
|
if new:
|
||||||
|
# TODO: handle array data for other cases of duplication
|
||||||
|
array_data = arrays_to_duplicate.get(obj, None)
|
||||||
|
tool.Model.handle_array_on_copied_element(new, array_data)
|
||||||
|
if array_data:
|
||||||
|
for child in tool.Array.get_all_children_objects(new):
|
||||||
|
child.select_set(True)
|
||||||
|
|
||||||
|
# TODO: add new array children to recreate their decomposition too
|
||||||
|
old_to_new.setdefault(element, []).append(new)
|
||||||
|
if new.is_a("IfcRelSpaceBoundary"):
|
||||||
|
tool.Boundary.decorate_boundary(new_obj)
|
||||||
|
# Slab-trim booleans (from extend_walls_to_underside) belong to
|
||||||
|
# the source wall's connection, not the copy. Strip them so the
|
||||||
|
# duplicate reverts to its pre-clip extrusion — mirrors the way
|
||||||
|
# filling rels are dropped while manual booleans persist on copy.
|
||||||
|
# Reload the body when something was stripped so the viewport
|
||||||
|
# immediately shows the unclipped geometry; otherwise the user
|
||||||
|
# sees a stale mesh until they Shift+G, which is easy to miss.
|
||||||
|
if new.is_a("IfcWall"):
|
||||||
|
if tool.Model.strip_underside_booleans(new):
|
||||||
|
tool.Model.reload_body_representation(new_obj)
|
||||||
|
# HasOpenings rels don't follow object duplication, so
|
||||||
|
# the duplicate's body must rebuild to match its current
|
||||||
|
# opening set.
|
||||||
|
else:
|
||||||
|
tool.Model.regenerate_wall(new_obj)
|
||||||
|
|
||||||
|
return new_active_obj
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _recalculate_walls_with_new_connections(
|
||||||
|
cls, old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]]
|
||||||
|
) -> None:
|
||||||
|
"""Recalculate new IfcWall duplicates that just received an
|
||||||
|
``IfcRelConnectsPathElements``. The in-loop ``regenerate_wall`` runs
|
||||||
|
before ``recreate_connections``, so wall body geometry doesn't reflect
|
||||||
|
the junction until this second pass."""
|
||||||
|
walls_to_recalc: list[bpy.types.Object] = []
|
||||||
|
for new_list in old_to_new.values():
|
||||||
|
for new_entity in new_list:
|
||||||
|
if not new_entity.is_a("IfcWall"):
|
||||||
|
continue
|
||||||
|
if not (getattr(new_entity, "ConnectedTo", None) or getattr(new_entity, "ConnectedFrom", None)):
|
||||||
|
continue
|
||||||
|
new_obj = tool.Ifc.get_object(new_entity)
|
||||||
|
if new_obj is not None:
|
||||||
|
walls_to_recalc.append(new_obj)
|
||||||
|
if walls_to_recalc:
|
||||||
|
tool.Model.recalculate_walls(walls_to_recalc)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def duplicate_ifc_item(cls, obj: bpy.types.Object) -> None:
|
def duplicate_ifc_item(cls, obj: bpy.types.Object) -> None:
|
||||||
props = tool.Geometry.get_geometry_props()
|
props = tool.Geometry.get_geometry_props()
|
||||||
|
|||||||
@@ -1247,6 +1247,35 @@ class Model(bonsai.core.tool.Model):
|
|||||||
with tool.Geometry.batch_host_recut():
|
with tool.Geometry.batch_host_recut():
|
||||||
cls._regenerate_array_body(parent_obj, data, array_layers_to_apply)
|
cls._regenerate_array_body(parent_obj, data, array_layers_to_apply)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _prune_orphan_array_children(cls, array: dict[str, Any]) -> None:
|
||||||
|
"""Drop GUIDs from ``array['children']`` whose IFC entity or Blender
|
||||||
|
object is no longer alive, and cascade-remove the orphan IFC entity
|
||||||
|
if it still exists. Outliner / keyboard delete of a Bonsai-managed
|
||||||
|
object bypasses ``bim.delete``'s cascade, leaving dangling opening
|
||||||
|
and filling references that later confuse regen and crash the
|
||||||
|
``batch_host_recut`` drain."""
|
||||||
|
live_guids: list[str] = []
|
||||||
|
ifc_file = tool.Ifc.get()
|
||||||
|
for guid in array["children"]:
|
||||||
|
try:
|
||||||
|
element = ifc_file.by_guid(guid)
|
||||||
|
except RuntimeError:
|
||||||
|
continue
|
||||||
|
obj = tool.Ifc.get_object(element)
|
||||||
|
try:
|
||||||
|
is_live = obj is not None and obj.data is not None
|
||||||
|
except ReferenceError:
|
||||||
|
is_live = False
|
||||||
|
if is_live:
|
||||||
|
live_guids.append(guid)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
ifcopenshell.api.root.remove_product(ifc_file, product=element)
|
||||||
|
except (RuntimeError, ifcopenshell.Error):
|
||||||
|
pass
|
||||||
|
array["children"] = live_guids
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _regenerate_array_body(
|
def _regenerate_array_body(
|
||||||
cls, parent_obj: bpy.types.Object, data: list[dict[str, Any]], array_layers_to_apply: Iterable[int]
|
cls, parent_obj: bpy.types.Object, data: list[dict[str, Any]], array_layers_to_apply: Iterable[int]
|
||||||
@@ -1262,6 +1291,7 @@ class Model(bonsai.core.tool.Model):
|
|||||||
obj_stack = [parent_obj]
|
obj_stack = [parent_obj]
|
||||||
|
|
||||||
for array_i, array in enumerate(data):
|
for array_i, array in enumerate(data):
|
||||||
|
cls._prune_orphan_array_children(array)
|
||||||
child_i = 0
|
child_i = 0
|
||||||
existing_children = set(array["children"])
|
existing_children = set(array["children"])
|
||||||
total_existing_children = len(array["children"])
|
total_existing_children = len(array["children"])
|
||||||
@@ -1275,6 +1305,14 @@ class Model(bonsai.core.tool.Model):
|
|||||||
else:
|
else:
|
||||||
base_offset = Vector([array["x"], array["y"], array["z"]]) * unit_scale
|
base_offset = Vector([array["x"], array["y"], array["z"]]) * unit_scale
|
||||||
|
|
||||||
|
target_new_in_this_layer = (array["count"] - 1) * len(obj_stack)
|
||||||
|
missing_count = max(0, target_new_in_this_layer - total_existing_children)
|
||||||
|
new_entities_pool: list[ifcopenshell.entity_instance] = []
|
||||||
|
if missing_count > 0:
|
||||||
|
batch_old_to_new = tool.Geometry.duplicate_ifc_object_n_times(parent_obj, missing_count)
|
||||||
|
new_entities_pool = batch_old_to_new.get(parent_element, [])
|
||||||
|
new_entities_iter = iter(new_entities_pool)
|
||||||
|
|
||||||
for i in range(array["count"]):
|
for i in range(array["count"]):
|
||||||
if i == 0:
|
if i == 0:
|
||||||
continue
|
continue
|
||||||
@@ -1292,8 +1330,13 @@ class Model(bonsai.core.tool.Model):
|
|||||||
child_obj = tool.Ifc.get_object(child_element)
|
child_obj = tool.Ifc.get_object(child_element)
|
||||||
assert child_obj
|
assert child_obj
|
||||||
except (IndexError, RuntimeError, AssertionError):
|
except (IndexError, RuntimeError, AssertionError):
|
||||||
old_to_new, _ = tool.Geometry.duplicate_ifc_objects([parent_obj])
|
try:
|
||||||
child_element = next(iter(old_to_new.values()))[0]
|
child_element = next(new_entities_iter)
|
||||||
|
except StopIteration:
|
||||||
|
# Stale-GUID mid-list left the pool exhausted; fall back
|
||||||
|
# to a one-off duplicate so the layer can still complete.
|
||||||
|
old_to_new, _ = tool.Geometry.duplicate_ifc_objects([parent_obj])
|
||||||
|
child_element = next(iter(old_to_new.values()))[0]
|
||||||
child_obj = tool.Ifc.get_object(child_element)
|
child_obj = tool.Ifc.get_object(child_element)
|
||||||
|
|
||||||
# add child pset
|
# add child pset
|
||||||
@@ -1361,14 +1404,7 @@ 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
|
tool.Blender.set_object_selection(parent_obj, True)
|
||||||
# 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
|
@classmethod
|
||||||
def mirror_parent_void_fillings_to_children(
|
def mirror_parent_void_fillings_to_children(
|
||||||
|
|||||||
@@ -373,35 +373,37 @@ class Root(bonsai.core.tool.Root):
|
|||||||
try:
|
try:
|
||||||
new_aggregate = old_to_new[old_aggregate]
|
new_aggregate = old_to_new[old_aggregate]
|
||||||
except:
|
except:
|
||||||
bonsai.core.aggregate.unassign_object(
|
for new_entity in new:
|
||||||
tool.Ifc,
|
bonsai.core.aggregate.unassign_object(
|
||||||
tool.Aggregate,
|
|
||||||
tool.Collector,
|
|
||||||
relating_obj=tool.Ifc.get_object(old_aggregate),
|
|
||||||
related_obj=tool.Ifc.get_object(new[0]),
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
bonsai.core.aggregate.assign_object(
|
|
||||||
tool.Ifc,
|
|
||||||
tool.Aggregate,
|
|
||||||
tool.Collector,
|
|
||||||
relating_obj=tool.Ifc.get_object(new_aggregate[0]),
|
|
||||||
related_obj=tool.Ifc.get_object(new[0]),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Make sure that the array children also get reassigned to the correct aggregate
|
|
||||||
pset = ifcopenshell.util.element.get_pset(new[0], "BBIM_Array")
|
|
||||||
if pset:
|
|
||||||
array_children = tool.Array.get_all_children_objects(new[0])
|
|
||||||
for obj in array_children:
|
|
||||||
bonsai.core.aggregate.assign_object(
|
|
||||||
tool.Ifc,
|
tool.Ifc,
|
||||||
tool.Aggregate,
|
tool.Aggregate,
|
||||||
tool.Collector,
|
tool.Collector,
|
||||||
relating_obj=tool.Ifc.get_object(new_aggregate[0]),
|
relating_obj=tool.Ifc.get_object(old_aggregate),
|
||||||
related_obj=tool.Ifc.get_object(tool.Ifc.get_entity(obj)),
|
related_obj=tool.Ifc.get_object(new_entity),
|
||||||
)
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
for new_entity in new:
|
||||||
|
bonsai.core.aggregate.assign_object(
|
||||||
|
tool.Ifc,
|
||||||
|
tool.Aggregate,
|
||||||
|
tool.Collector,
|
||||||
|
relating_obj=tool.Ifc.get_object(new_aggregate[0]),
|
||||||
|
related_obj=tool.Ifc.get_object(new_entity),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Make sure that the array children also get reassigned to the correct aggregate
|
||||||
|
pset = ifcopenshell.util.element.get_pset(new_entity, "BBIM_Array")
|
||||||
|
if pset:
|
||||||
|
array_children = tool.Array.get_all_children_objects(new_entity)
|
||||||
|
for obj in array_children:
|
||||||
|
bonsai.core.aggregate.assign_object(
|
||||||
|
tool.Ifc,
|
||||||
|
tool.Aggregate,
|
||||||
|
tool.Collector,
|
||||||
|
relating_obj=tool.Ifc.get_object(new_aggregate[0]),
|
||||||
|
related_obj=tool.Ifc.get_object(tool.Ifc.get_entity(obj)),
|
||||||
|
)
|
||||||
|
|
||||||
if new_aggregate is None:
|
if new_aggregate is None:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -357,6 +357,13 @@ class System(bonsai.core.tool.System):
|
|||||||
if not cls.is_mep_element(element):
|
if not cls.is_mep_element(element):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Array children inherit port topology from their parent's IFC
|
||||||
|
# entity, but their positions are derived — drawing ports on every
|
||||||
|
# copy of an arrayed segment doubles up markers and misleads the
|
||||||
|
# user into thinking each copy has its own port network.
|
||||||
|
if tool.Array.is_array_child(element):
|
||||||
|
continue
|
||||||
|
|
||||||
selected_element = element in connected_elements
|
selected_element = element in connected_elements
|
||||||
verts_pos = []
|
verts_pos = []
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,714 @@
|
|||||||
|
# Bonsai - OpenBIM Blender Add-on
|
||||||
|
# Copyright (C) 2026
|
||||||
|
#
|
||||||
|
# This file is part of Bonsai.
|
||||||
|
#
|
||||||
|
# Bonsai is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# Bonsai is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
# This file was generated with the assistance of an AI coding tool.
|
||||||
|
|
||||||
|
"""Tests for the batched array-duplicate path.
|
||||||
|
|
||||||
|
`tool.Geometry.duplicate_ifc_object_n_times` lifts the per-call overhead of
|
||||||
|
`duplicate_ifc_objects` (snapshot, UI refresh, decorator reload, select
|
||||||
|
flips) out of the per-child loop in `_regenerate_array_body`. These tests
|
||||||
|
pin three contracts:
|
||||||
|
|
||||||
|
1. N-way batched duplicate produces N distinct entities mapped from the
|
||||||
|
source under `old_to_new[source_element]`, and the source object stays
|
||||||
|
selected throughout (no per-iteration deselect).
|
||||||
|
2. Per-layer batching collapses the N independent UI refreshes into one.
|
||||||
|
3. End-to-end array regen still yields the same number and shape of
|
||||||
|
children as the per-call baseline."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
import ifcopenshell
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import bonsai.tool as tool
|
||||||
|
from test.bim.bootstrap import NewFile
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.model
|
||||||
|
|
||||||
|
|
||||||
|
def _build_actuator(name: str = "Actuator") -> tuple[bpy.types.Object, ifcopenshell.entity_instance]:
|
||||||
|
"""Minimal IfcActuator + cube — matches the test_array_batch_recut.py shape."""
|
||||||
|
bpy.ops.bim.create_project()
|
||||||
|
bpy.ops.mesh.primitive_cube_add()
|
||||||
|
obj = bpy.context.active_object
|
||||||
|
obj.name = name
|
||||||
|
rprops = tool.Root.get_root_props()
|
||||||
|
rprops.ifc_product = "IfcElement"
|
||||||
|
bpy.ops.bim.assign_class(ifc_class="IfcActuator", predefined_type="ELECTRICACTUATOR", userdefined_type="")
|
||||||
|
element = tool.Ifc.get_entity(obj)
|
||||||
|
return obj, element
|
||||||
|
|
||||||
|
|
||||||
|
def _build_actuator_with_array_pset(
|
||||||
|
count: int, x: float = 1.0
|
||||||
|
) -> tuple[bpy.types.Object, ifcopenshell.entity_instance, list[dict]]:
|
||||||
|
obj, element = _build_actuator()
|
||||||
|
parent_data = [
|
||||||
|
{
|
||||||
|
"children": [],
|
||||||
|
"count": count,
|
||||||
|
"method": "OFFSET",
|
||||||
|
"x": x,
|
||||||
|
"y": 0.0,
|
||||||
|
"z": 0.0,
|
||||||
|
"use_local_space": False,
|
||||||
|
"sync_children": False,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
pset = ifcopenshell.api.pset.add_pset(tool.Ifc.get(), product=element, name="BBIM_Array")
|
||||||
|
ifcopenshell.api.pset.edit_pset(
|
||||||
|
tool.Ifc.get(),
|
||||||
|
pset=pset,
|
||||||
|
properties={"Data": json.dumps(parent_data), "Parent": element.GlobalId},
|
||||||
|
)
|
||||||
|
return obj, element, parent_data
|
||||||
|
|
||||||
|
|
||||||
|
class TestDuplicateIfcObjectNTimes(NewFile):
|
||||||
|
def test_returns_empty_dict_for_zero_count(self):
|
||||||
|
obj, _ = _build_actuator()
|
||||||
|
result = tool.Geometry.duplicate_ifc_object_n_times(obj, 0)
|
||||||
|
assert result == {}
|
||||||
|
|
||||||
|
def test_returns_empty_dict_for_negative_count(self):
|
||||||
|
obj, _ = _build_actuator()
|
||||||
|
result = tool.Geometry.duplicate_ifc_object_n_times(obj, -3)
|
||||||
|
assert result == {}
|
||||||
|
|
||||||
|
def test_produces_n_distinct_entities(self):
|
||||||
|
obj, element = _build_actuator()
|
||||||
|
result = tool.Geometry.duplicate_ifc_object_n_times(obj, 5)
|
||||||
|
new_entities = result.get(element)
|
||||||
|
assert new_entities is not None
|
||||||
|
assert len(new_entities) == 5
|
||||||
|
assert len({e.id() for e in new_entities}) == 5
|
||||||
|
for new_entity in new_entities:
|
||||||
|
assert new_entity.is_a("IfcActuator")
|
||||||
|
assert new_entity.GlobalId != element.GlobalId
|
||||||
|
|
||||||
|
def test_source_stays_selected_after_batch(self):
|
||||||
|
obj, _ = _build_actuator()
|
||||||
|
obj.select_set(True)
|
||||||
|
tool.Geometry.duplicate_ifc_object_n_times(obj, 4)
|
||||||
|
assert obj in bpy.context.selected_objects, "source object must remain selected across batched duplicates"
|
||||||
|
|
||||||
|
def test_each_new_entity_has_blender_object(self):
|
||||||
|
obj, element = _build_actuator()
|
||||||
|
result = tool.Geometry.duplicate_ifc_object_n_times(obj, 3)
|
||||||
|
for new_entity in result[element]:
|
||||||
|
new_obj = tool.Ifc.get_object(new_entity)
|
||||||
|
assert new_obj is not None
|
||||||
|
assert new_obj is not obj
|
||||||
|
|
||||||
|
|
||||||
|
class TestBatchedRefreshUIDataCallCount(NewFile):
|
||||||
|
def test_n_times_calls_refresh_ui_data_once(self):
|
||||||
|
obj, _ = _build_actuator()
|
||||||
|
with patch("bonsai.bim.handler.refresh_ui_data") as refresh_mock:
|
||||||
|
tool.Geometry.duplicate_ifc_object_n_times(obj, 8)
|
||||||
|
assert (
|
||||||
|
refresh_mock.call_count == 1
|
||||||
|
), f"batched 8-way duplicate must call refresh_ui_data once, got {refresh_mock.call_count}"
|
||||||
|
|
||||||
|
def test_n_times_calls_reload_grid_decorator_once(self):
|
||||||
|
obj, _ = _build_actuator()
|
||||||
|
with patch.object(tool.Root, "reload_grid_decorator") as reload_mock:
|
||||||
|
tool.Geometry.duplicate_ifc_object_n_times(obj, 8)
|
||||||
|
assert reload_mock.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestRegenerateArrayEndToEnd(NewFile):
|
||||||
|
def test_regenerate_array_creates_expected_children(self):
|
||||||
|
obj, element, parent_data = _build_actuator_with_array_pset(count=8)
|
||||||
|
bpy.context.view_layer.objects.active = obj
|
||||||
|
tool.Model.regenerate_array(obj, parent_data)
|
||||||
|
|
||||||
|
layer = parent_data[0]
|
||||||
|
assert len(layer["children"]) == 7, "8-element array means 7 new children (parent + 7)"
|
||||||
|
for child_guid in layer["children"]:
|
||||||
|
child_element = tool.Ifc.get().by_guid(child_guid)
|
||||||
|
assert child_element is not None
|
||||||
|
assert child_element.is_a("IfcActuator")
|
||||||
|
child_pset = ifcopenshell.util.element.get_pset(child_element, "BBIM_Array")
|
||||||
|
assert child_pset is not None
|
||||||
|
assert child_pset["Parent"] == element.GlobalId
|
||||||
|
|
||||||
|
def test_regenerate_array_parent_stays_selected(self):
|
||||||
|
obj, element, parent_data = _build_actuator_with_array_pset(count=4)
|
||||||
|
bpy.context.view_layer.objects.active = obj
|
||||||
|
obj.select_set(True)
|
||||||
|
tool.Model.regenerate_array(obj, parent_data)
|
||||||
|
assert (
|
||||||
|
obj in bpy.context.selected_objects
|
||||||
|
), "regenerate_array must leave parent_obj selected on return (post-condition)"
|
||||||
|
|
||||||
|
def test_regen_operator_leaves_only_parent_selected_and_active(self):
|
||||||
|
"""Post-condition parity between grow and shrink for the user-facing
|
||||||
|
``bim.regenerate_array`` operator: only the parent is selected + active;
|
||||||
|
every child is deselected. Pre-fix the grow path left new children
|
||||||
|
selected, creating inconsistency with the shrink path.
|
||||||
|
|
||||||
|
Scoped to the operator, not the tool method — ``remove_array`` and
|
||||||
|
``apply_array`` also invoke ``tool.Model.regenerate_array`` internally
|
||||||
|
but expect a different post-selection state (children stay selected
|
||||||
|
for user follow-up work)."""
|
||||||
|
obj, element, parent_data = _build_actuator_with_array_pset(count=6)
|
||||||
|
bpy.context.view_layer.objects.active = obj
|
||||||
|
obj.select_set(True)
|
||||||
|
bpy.ops.bim.regenerate_array()
|
||||||
|
|
||||||
|
assert obj in bpy.context.selected_objects
|
||||||
|
assert bpy.context.view_layer.objects.active is obj
|
||||||
|
parent_pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
|
||||||
|
parent_data_after = json.loads(parent_pset["Data"])
|
||||||
|
for child_guid in parent_data_after[0]["children"]:
|
||||||
|
child_element = tool.Ifc.get().by_guid(child_guid)
|
||||||
|
child_obj = tool.Ifc.get_object(child_element)
|
||||||
|
assert (
|
||||||
|
child_obj not in bpy.context.selected_objects
|
||||||
|
), f"child {child_obj.name} must be deselected on regenerate_array return"
|
||||||
|
|
||||||
|
def test_regen_operator_after_shrink_still_leaves_only_parent_selected(self):
|
||||||
|
obj, element, parent_data = _build_actuator_with_array_pset(count=6)
|
||||||
|
bpy.context.view_layer.objects.active = obj
|
||||||
|
bpy.ops.bim.regenerate_array()
|
||||||
|
|
||||||
|
parent_pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
|
||||||
|
arrays = json.loads(parent_pset["Data"])
|
||||||
|
arrays[0]["count"] = 3
|
||||||
|
pset_entity = tool.Ifc.get().by_id(parent_pset["id"])
|
||||||
|
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset_entity, properties={"Data": json.dumps(arrays)})
|
||||||
|
bpy.ops.bim.regenerate_array()
|
||||||
|
|
||||||
|
assert obj in bpy.context.selected_objects
|
||||||
|
assert bpy.context.view_layer.objects.active is obj
|
||||||
|
parent_pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
|
||||||
|
arrays_after = json.loads(parent_pset["Data"])
|
||||||
|
for child_guid in arrays_after[0]["children"]:
|
||||||
|
child_element = tool.Ifc.get().by_guid(child_guid)
|
||||||
|
child_obj = tool.Ifc.get_object(child_element)
|
||||||
|
assert child_obj not in bpy.context.selected_objects
|
||||||
|
|
||||||
|
def test_regenerate_array_child_positions_match_offset(self):
|
||||||
|
obj, element, parent_data = _build_actuator_with_array_pset(count=4, x=2.5)
|
||||||
|
bpy.context.view_layer.objects.active = obj
|
||||||
|
parent_x = obj.matrix_world.translation.x
|
||||||
|
tool.Model.regenerate_array(obj, parent_data)
|
||||||
|
|
||||||
|
layer = parent_data[0]
|
||||||
|
for i, child_guid in enumerate(layer["children"], start=1):
|
||||||
|
child_element = tool.Ifc.get().by_guid(child_guid)
|
||||||
|
child_obj = tool.Ifc.get_object(child_element)
|
||||||
|
expected_x = parent_x + 2.5 * i
|
||||||
|
assert child_obj.matrix_world.translation.x == pytest.approx(
|
||||||
|
expected_x
|
||||||
|
), f"child {i}: expected x≈{expected_x}, got {child_obj.matrix_world.translation.x}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRegenerateArrayUIRefreshCoalesces(NewFile):
|
||||||
|
def test_n_children_grow_calls_refresh_ui_data_once_per_layer(self):
|
||||||
|
obj, element, parent_data = _build_actuator_with_array_pset(count=8)
|
||||||
|
bpy.context.view_layer.objects.active = obj
|
||||||
|
with patch("bonsai.bim.handler.refresh_ui_data") as refresh_mock:
|
||||||
|
tool.Model.regenerate_array(obj, parent_data)
|
||||||
|
assert refresh_mock.call_count == 1, (
|
||||||
|
"growing an array layer from 0 to 7 children must call refresh_ui_data once, "
|
||||||
|
f"got {refresh_mock.call_count}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_n_children_grow_calls_reload_grid_decorator_once_per_layer(self):
|
||||||
|
obj, element, parent_data = _build_actuator_with_array_pset(count=8)
|
||||||
|
bpy.context.view_layer.objects.active = obj
|
||||||
|
with patch.object(tool.Root, "reload_grid_decorator") as reload_mock:
|
||||||
|
tool.Model.regenerate_array(obj, parent_data)
|
||||||
|
assert reload_mock.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecreateAggregateIteratesAllNew(NewFile):
|
||||||
|
"""Pins the [0]-indexing sweep in tool/root.py recreate_aggregate. When the
|
||||||
|
new-list has N>1 entries (the batched-duplicate shape), every entry must be
|
||||||
|
aggregate-assigned, not just new[0]."""
|
||||||
|
|
||||||
|
def test_iterates_assign_object_per_new_entity_when_old_has_aggregate(self):
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
old_assembly = Mock()
|
||||||
|
old_assembly.is_a = lambda c: c == "IfcElementAssembly"
|
||||||
|
old_parent_aggregate = Mock()
|
||||||
|
old_parent_aggregate.is_a = lambda c: False
|
||||||
|
|
||||||
|
new_assemblies = [Mock(), Mock(), Mock()]
|
||||||
|
new_parent_aggregate = [Mock()]
|
||||||
|
|
||||||
|
old_to_new = {old_assembly: new_assemblies, old_parent_aggregate: new_parent_aggregate}
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"ifcopenshell.util.element.get_aggregate",
|
||||||
|
side_effect=lambda e: old_parent_aggregate if e is old_assembly else None,
|
||||||
|
), patch("bonsai.core.aggregate.assign_object") as assign_mock, patch(
|
||||||
|
"ifcopenshell.util.element.get_pset", return_value=None
|
||||||
|
), patch.object(
|
||||||
|
tool.Ifc, "get_object", side_effect=lambda e: Mock(spec=bpy.types.Object)
|
||||||
|
), patch.object(
|
||||||
|
tool.Blender, "select_and_activate_single_object"
|
||||||
|
):
|
||||||
|
tool.Root.recreate_aggregate(old_to_new)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
assign_mock.call_count == 3
|
||||||
|
), f"recreate_aggregate must assign each of N new entities (not just new[0]); got {assign_mock.call_count}"
|
||||||
|
|
||||||
|
def test_iterates_unassign_object_per_new_entity_when_aggregate_missing(self):
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
old_assembly = Mock()
|
||||||
|
old_assembly.is_a = lambda c: c == "IfcElementAssembly"
|
||||||
|
old_parent_aggregate = Mock()
|
||||||
|
|
||||||
|
new_assemblies = [Mock(), Mock(), Mock()]
|
||||||
|
old_to_new = {old_assembly: new_assemblies} # parent aggregate NOT in old_to_new
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"ifcopenshell.util.element.get_aggregate",
|
||||||
|
side_effect=lambda e: old_parent_aggregate if e is old_assembly else None,
|
||||||
|
), patch("bonsai.core.aggregate.unassign_object") as unassign_mock, patch.object(
|
||||||
|
tool.Ifc, "get_object", side_effect=lambda e: Mock(spec=bpy.types.Object)
|
||||||
|
):
|
||||||
|
tool.Root.recreate_aggregate(old_to_new)
|
||||||
|
|
||||||
|
assert unassign_mock.call_count == 3, (
|
||||||
|
f"recreate_aggregate must unassign each of N new entities when parent aggregate is missing; "
|
||||||
|
f"got {unassign_mock.call_count}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecreateConnectionsZipsPairs(NewFile):
|
||||||
|
"""Pins the [0]-indexing sweep in tool/duplicate.py recreate_connections. When
|
||||||
|
both sides of a connection are duplicated N times, zip-pair the N new
|
||||||
|
relating with N new related; when only one side is duplicated, skip."""
|
||||||
|
|
||||||
|
def _make_connection_data(self):
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
from bonsai.tool.duplicate import ConnectionRecord
|
||||||
|
|
||||||
|
return ConnectionRecord(
|
||||||
|
type="path",
|
||||||
|
relating_element=Mock(),
|
||||||
|
related_element=Mock(),
|
||||||
|
relating_connection_type="ATSTART",
|
||||||
|
related_connection_type="ATEND",
|
||||||
|
relating_priorities=[],
|
||||||
|
related_priorities=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_zips_n_pairs_when_both_sides_duplicated(self):
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
data = self._make_connection_data()
|
||||||
|
old_to_new = {
|
||||||
|
data.relating_element: [Mock(), Mock(), Mock()],
|
||||||
|
data.related_element: [Mock(), Mock(), Mock()],
|
||||||
|
}
|
||||||
|
relationship = {Mock(): data}
|
||||||
|
|
||||||
|
with patch.object(tool.Ifc, "run", return_value=None) as run_mock:
|
||||||
|
tool.Duplicate.recreate_connections(relationship, old_to_new)
|
||||||
|
|
||||||
|
connect_calls = [c for c in run_mock.call_args_list if c.args and c.args[0] == "geometry.connect_path"]
|
||||||
|
assert (
|
||||||
|
len(connect_calls) == 3
|
||||||
|
), f"zip-pair must create 3 connect_path calls for 3-vs-3 batched duplicate; got {len(connect_calls)}"
|
||||||
|
|
||||||
|
def test_skips_when_other_side_not_duplicated(self):
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
data = self._make_connection_data()
|
||||||
|
# Only relating side is in old_to_new; related side was NOT duplicated.
|
||||||
|
old_to_new = {data.relating_element: [Mock(), Mock(), Mock()]}
|
||||||
|
relationship = {Mock(): data}
|
||||||
|
|
||||||
|
with patch.object(tool.Ifc, "run", return_value=None) as run_mock:
|
||||||
|
tool.Duplicate.recreate_connections(relationship, old_to_new)
|
||||||
|
|
||||||
|
connect_calls = [c for c in run_mock.call_args_list if c.args and c.args[0] == "geometry.connect_path"]
|
||||||
|
assert (
|
||||||
|
connect_calls == []
|
||||||
|
), "when only one side of a connection is in old_to_new, no connections should be recreated"
|
||||||
|
|
||||||
|
def test_single_pair_case_unchanged(self):
|
||||||
|
"""Pre-sweep behavior (1 source -> 1 new) must still work — zip with two 1-element lists."""
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
data = self._make_connection_data()
|
||||||
|
old_to_new = {
|
||||||
|
data.relating_element: [Mock()],
|
||||||
|
data.related_element: [Mock()],
|
||||||
|
}
|
||||||
|
relationship = {Mock(): data}
|
||||||
|
|
||||||
|
with patch.object(tool.Ifc, "run", return_value=None) as run_mock:
|
||||||
|
tool.Duplicate.recreate_connections(relationship, old_to_new)
|
||||||
|
|
||||||
|
connect_calls = [c for c in run_mock.call_args_list if c.args and c.args[0] == "geometry.connect_path"]
|
||||||
|
assert len(connect_calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecalculateWallsWithNewConnections(NewFile):
|
||||||
|
"""Pins the post-connection wall recalc: after ``recreate_connections``
|
||||||
|
wires new IfcRelConnectsPathElements onto duplicated walls, the wall
|
||||||
|
bodies must be re-recalculated because the in-loop ``regenerate_wall``
|
||||||
|
fired before the connections existed. Otherwise the junction geometry
|
||||||
|
stays stale and the user has to manually regen."""
|
||||||
|
|
||||||
|
def test_walls_with_new_connections_are_recalculated(self):
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
wall_new = Mock()
|
||||||
|
wall_new.is_a = lambda c: c == "IfcWall"
|
||||||
|
wall_new.ConnectedTo = [Mock()]
|
||||||
|
wall_new.ConnectedFrom = []
|
||||||
|
|
||||||
|
wall_obj = Mock(spec=bpy.types.Object)
|
||||||
|
old_to_new = {Mock(): [wall_new]}
|
||||||
|
|
||||||
|
with patch.object(tool.Ifc, "get_object", return_value=wall_obj), patch.object(
|
||||||
|
tool.Model, "recalculate_walls"
|
||||||
|
) as recalc_mock:
|
||||||
|
tool.Geometry._recalculate_walls_with_new_connections(old_to_new)
|
||||||
|
|
||||||
|
assert recalc_mock.call_count == 1
|
||||||
|
assert recalc_mock.call_args.args[0] == [wall_obj]
|
||||||
|
|
||||||
|
def test_walls_without_connections_are_skipped(self):
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
wall_new = Mock()
|
||||||
|
wall_new.is_a = lambda c: c == "IfcWall"
|
||||||
|
wall_new.ConnectedTo = []
|
||||||
|
wall_new.ConnectedFrom = []
|
||||||
|
|
||||||
|
old_to_new = {Mock(): [wall_new]}
|
||||||
|
|
||||||
|
with patch.object(tool.Ifc, "get_object", return_value=Mock(spec=bpy.types.Object)), patch.object(
|
||||||
|
tool.Model, "recalculate_walls"
|
||||||
|
) as recalc_mock:
|
||||||
|
tool.Geometry._recalculate_walls_with_new_connections(old_to_new)
|
||||||
|
|
||||||
|
assert recalc_mock.call_count == 0, "walls with no new connections must not trigger a recalc pass"
|
||||||
|
|
||||||
|
def test_non_wall_entities_are_skipped(self):
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
actuator_new = Mock()
|
||||||
|
actuator_new.is_a = lambda c: c == "IfcActuator"
|
||||||
|
actuator_new.ConnectedTo = [Mock()]
|
||||||
|
|
||||||
|
old_to_new = {Mock(): [actuator_new]}
|
||||||
|
|
||||||
|
with patch.object(tool.Ifc, "get_object", return_value=Mock(spec=bpy.types.Object)), patch.object(
|
||||||
|
tool.Model, "recalculate_walls"
|
||||||
|
) as recalc_mock:
|
||||||
|
tool.Geometry._recalculate_walls_with_new_connections(old_to_new)
|
||||||
|
|
||||||
|
assert recalc_mock.call_count == 0
|
||||||
|
|
||||||
|
def test_multiple_new_walls_collected_into_one_call(self):
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
wall_a_new = Mock()
|
||||||
|
wall_a_new.is_a = lambda c: c == "IfcWall"
|
||||||
|
wall_a_new.ConnectedTo = [Mock()]
|
||||||
|
wall_a_new.ConnectedFrom = []
|
||||||
|
wall_b_new = Mock()
|
||||||
|
wall_b_new.is_a = lambda c: c == "IfcWall"
|
||||||
|
wall_b_new.ConnectedTo = []
|
||||||
|
wall_b_new.ConnectedFrom = [Mock()]
|
||||||
|
|
||||||
|
objs = {wall_a_new: Mock(spec=bpy.types.Object), wall_b_new: Mock(spec=bpy.types.Object)}
|
||||||
|
old_to_new = {Mock(): [wall_a_new], Mock(): [wall_b_new]}
|
||||||
|
|
||||||
|
with patch.object(tool.Ifc, "get_object", side_effect=lambda e: objs.get(e)), patch.object(
|
||||||
|
tool.Model, "recalculate_walls"
|
||||||
|
) as recalc_mock:
|
||||||
|
tool.Geometry._recalculate_walls_with_new_connections(old_to_new)
|
||||||
|
|
||||||
|
assert recalc_mock.call_count == 1
|
||||||
|
assert set(recalc_mock.call_args.args[0]) == {objs[wall_a_new], objs[wall_b_new]}
|
||||||
|
|
||||||
|
|
||||||
|
class TestMEPActionGuardsAgainstArrayChildren(NewFile):
|
||||||
|
"""Pins the array-child guards on the three MEP-action visibility helpers.
|
||||||
|
Writable MEP actions (add fitting, remove terminal, join, re-edit bend)
|
||||||
|
applied to an array child get wiped by the next regen — gating the icons
|
||||||
|
at the visibility layer prevents that footgun."""
|
||||||
|
|
||||||
|
def test_active_is_flow_segment_returns_false_for_array_child(self):
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
from bonsai.bim.module.model.mep import _active_is_flow_segment
|
||||||
|
|
||||||
|
obj = Mock(spec=bpy.types.Object)
|
||||||
|
element = Mock()
|
||||||
|
element.is_a = lambda c: c == "IfcFlowSegment"
|
||||||
|
|
||||||
|
with patch.object(tool.Ifc, "get_entity", return_value=element), patch.object(
|
||||||
|
tool.Array, "is_array_child", return_value=True
|
||||||
|
), patch.object(tool.System, "has_parametric_body", return_value=True):
|
||||||
|
assert _active_is_flow_segment(obj) is False
|
||||||
|
|
||||||
|
def test_active_is_flow_segment_true_for_non_array_parent(self):
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
from bonsai.bim.module.model.mep import _active_is_flow_segment
|
||||||
|
|
||||||
|
obj = Mock(spec=bpy.types.Object)
|
||||||
|
element = Mock()
|
||||||
|
element.is_a = lambda c: c == "IfcFlowSegment"
|
||||||
|
|
||||||
|
with patch.object(tool.Ifc, "get_entity", return_value=element), patch.object(
|
||||||
|
tool.Array, "is_array_child", return_value=False
|
||||||
|
), patch.object(tool.System, "has_parametric_body", return_value=True):
|
||||||
|
assert _active_is_flow_segment(obj) is True
|
||||||
|
|
||||||
|
def test_active_is_bend_fitting_returns_false_for_array_child(self):
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
from bonsai.bim.module.model.mep import _active_is_bend_fitting
|
||||||
|
|
||||||
|
obj = Mock(spec=bpy.types.Object)
|
||||||
|
element = Mock()
|
||||||
|
|
||||||
|
with patch.object(tool.Ifc, "get_entity", return_value=element), patch(
|
||||||
|
"bonsai.bim.module.model.mep._is_bend_fitting", return_value=True
|
||||||
|
), patch.object(tool.Array, "is_array_child", return_value=True):
|
||||||
|
assert _active_is_bend_fitting(obj) is False
|
||||||
|
|
||||||
|
def test_n_mep_selected_returns_false_when_any_selected_is_array_child(self):
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
from bonsai.bim.module.model.mep import _n_mep_selected
|
||||||
|
|
||||||
|
obj_a = Mock(spec=bpy.types.Object)
|
||||||
|
obj_b = Mock(spec=bpy.types.Object)
|
||||||
|
element_a = Mock()
|
||||||
|
element_b = Mock()
|
||||||
|
|
||||||
|
def is_array_child(el):
|
||||||
|
return el is element_b
|
||||||
|
|
||||||
|
with patch.object(tool.Blender, "get_selected_objects", return_value=[obj_a, obj_b]), patch.object(
|
||||||
|
tool.Ifc, "get_entity", side_effect=lambda o: element_a if o is obj_a else element_b
|
||||||
|
), patch.object(tool.System, "is_mep_element", return_value=True), patch.object(
|
||||||
|
tool.Array, "is_array_child", side_effect=is_array_child
|
||||||
|
):
|
||||||
|
assert _n_mep_selected(2) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestSelectOnlyParent(NewFile):
|
||||||
|
"""Pins ``tool.Array.select_only_parent`` — the shared helper wired into
|
||||||
|
both ``bim.regenerate_array`` and ``bim.finish_editing_array`` so the
|
||||||
|
grow / shrink / edit-commit paths converge on the same post-condition:
|
||||||
|
only the parent is selected + active."""
|
||||||
|
|
||||||
|
def test_deselects_children_selects_and_activates_parent(self):
|
||||||
|
obj, element, parent_data = _build_actuator_with_array_pset(count=4)
|
||||||
|
bpy.context.view_layer.objects.active = obj
|
||||||
|
obj.select_set(True)
|
||||||
|
tool.Model.regenerate_array(obj, parent_data)
|
||||||
|
for child_guid in parent_data[0]["children"]:
|
||||||
|
child_element = tool.Ifc.get().by_guid(child_guid)
|
||||||
|
child_obj = tool.Ifc.get_object(child_element)
|
||||||
|
child_obj.select_set(True)
|
||||||
|
|
||||||
|
tool.Array.select_only_parent(obj, bpy.context)
|
||||||
|
|
||||||
|
assert obj in bpy.context.selected_objects
|
||||||
|
assert bpy.context.view_layer.objects.active is obj
|
||||||
|
for child_guid in parent_data[0]["children"]:
|
||||||
|
child_element = tool.Ifc.get().by_guid(child_guid)
|
||||||
|
child_obj = tool.Ifc.get_object(child_element)
|
||||||
|
assert child_obj not in bpy.context.selected_objects
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsArrayChild(NewFile):
|
||||||
|
"""Pins ``tool.Array.is_array_child`` — the light helper used by the port
|
||||||
|
decorator (and any future per-element guard) to skip array children."""
|
||||||
|
|
||||||
|
def test_returns_false_when_no_bbim_array_pset(self):
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
element = Mock()
|
||||||
|
with patch("ifcopenshell.util.element.get_pset", return_value=None):
|
||||||
|
assert tool.Array.is_array_child(element) is False
|
||||||
|
|
||||||
|
def test_returns_false_on_the_array_parent_itself(self):
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
element = Mock()
|
||||||
|
element.GlobalId = "PARENT_GUID"
|
||||||
|
with patch("ifcopenshell.util.element.get_pset", return_value={"Parent": "PARENT_GUID"}):
|
||||||
|
assert tool.Array.is_array_child(element) is False
|
||||||
|
|
||||||
|
def test_returns_true_when_parent_guid_points_elsewhere(self):
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
element = Mock()
|
||||||
|
element.GlobalId = "CHILD_GUID"
|
||||||
|
with patch("ifcopenshell.util.element.get_pset", return_value={"Parent": "PARENT_GUID"}):
|
||||||
|
assert tool.Array.is_array_child(element) is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestOrphanArrayChildPrune(NewFile):
|
||||||
|
"""Outliner / keyboard delete of a Bonsai-managed array child bypasses
|
||||||
|
``bim.delete``'s cascade, leaving the IFC entity and its opening / filling
|
||||||
|
refs behind. Regen must prune these orphans before the main loop or the
|
||||||
|
stale registry entry corrupts the ``batch_host_recut`` drain."""
|
||||||
|
|
||||||
|
def test_orphan_ifc_entity_pruned_from_children_list(self):
|
||||||
|
obj, element, parent_data = _build_actuator_with_array_pset(count=4)
|
||||||
|
bpy.context.view_layer.objects.active = obj
|
||||||
|
tool.Model.regenerate_array(obj, parent_data)
|
||||||
|
assert len(parent_data[0]["children"]) == 3
|
||||||
|
|
||||||
|
orphan_guid = parent_data[0]["children"][1]
|
||||||
|
orphan_element = tool.Ifc.get().by_guid(orphan_guid)
|
||||||
|
orphan_obj = tool.Ifc.get_object(orphan_element)
|
||||||
|
assert orphan_obj is not None
|
||||||
|
bpy.data.objects.remove(orphan_obj, do_unlink=True)
|
||||||
|
|
||||||
|
tool.Model.regenerate_array(obj, parent_data)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
orphan_guid not in parent_data[0]["children"]
|
||||||
|
), "orphan GUID must be pruned from array['children'] once its Blender object is dead"
|
||||||
|
try:
|
||||||
|
still_there = tool.Ifc.get().by_guid(orphan_guid)
|
||||||
|
except RuntimeError:
|
||||||
|
still_there = None
|
||||||
|
assert still_there is None, "orphan IFC entity must be cascade-removed, not left as a leak"
|
||||||
|
|
||||||
|
def test_regen_completes_when_child_deleted_outside_bim_cascade(self):
|
||||||
|
obj, element, parent_data = _build_actuator_with_array_pset(count=6)
|
||||||
|
bpy.context.view_layer.objects.active = obj
|
||||||
|
tool.Model.regenerate_array(obj, parent_data)
|
||||||
|
|
||||||
|
victim_guid = parent_data[0]["children"][2]
|
||||||
|
victim_element = tool.Ifc.get().by_guid(victim_guid)
|
||||||
|
victim_obj = tool.Ifc.get_object(victim_element)
|
||||||
|
bpy.data.objects.remove(victim_obj, do_unlink=True)
|
||||||
|
|
||||||
|
tool.Model.regenerate_array(obj, parent_data)
|
||||||
|
|
||||||
|
assert len(parent_data[0]["children"]) == 5, "regen must rebuild to the target count after pruning the orphan"
|
||||||
|
for guid in parent_data[0]["children"]:
|
||||||
|
child = tool.Ifc.get().by_guid(guid)
|
||||||
|
child_obj = tool.Ifc.get_object(child)
|
||||||
|
assert child_obj is not None, "every surviving child must have a live Blender object"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecreatePortConnectionsZipsPairs(NewFile):
|
||||||
|
"""Pins the [0]-indexing sweep in tool/duplicate.py recreate_port_connections.
|
||||||
|
When both sides of a port-to-port connection are duplicated N times, the
|
||||||
|
connection must be recreated on every pair of new siblings — not just the
|
||||||
|
first. Matters for arrayed MEP segments (pipes / ducts / cables) where each
|
||||||
|
child in the array should stay connected to its neighbour after regen."""
|
||||||
|
|
||||||
|
def _make_snapshot(self, relating_element, records, port_counts):
|
||||||
|
from bonsai.tool.duplicate import PortConnectionSnapshot
|
||||||
|
|
||||||
|
return PortConnectionSnapshot(
|
||||||
|
by_element={relating_element: records},
|
||||||
|
port_counts=port_counts,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _make_record(self, related_element, relating_port_index=0, related_port_index=0, direction="SOURCE"):
|
||||||
|
from bonsai.tool.duplicate import PortConnectionRecord
|
||||||
|
|
||||||
|
return PortConnectionRecord(
|
||||||
|
relating_port_index=relating_port_index,
|
||||||
|
related_element=related_element,
|
||||||
|
related_port_index=related_port_index,
|
||||||
|
direction=direction,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_zips_n_pairs_when_both_sides_duplicated(self):
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
relating_old = Mock()
|
||||||
|
related_old = Mock()
|
||||||
|
record = self._make_record(related_old)
|
||||||
|
snapshot = self._make_snapshot(relating_old, [record], port_counts={})
|
||||||
|
|
||||||
|
old_to_new = {
|
||||||
|
relating_old: [Mock(), Mock(), Mock()],
|
||||||
|
related_old: [Mock(), Mock(), Mock()],
|
||||||
|
}
|
||||||
|
|
||||||
|
fake_ports = [Mock(), Mock()]
|
||||||
|
with patch.object(tool.System, "get_ports", return_value=fake_ports), patch.object(
|
||||||
|
tool.Ifc, "run", return_value=None
|
||||||
|
) as run_mock:
|
||||||
|
tool.Duplicate.recreate_port_connections(snapshot, old_to_new)
|
||||||
|
|
||||||
|
connect_calls = [c for c in run_mock.call_args_list if c.args and c.args[0] == "system.connect_port"]
|
||||||
|
assert (
|
||||||
|
len(connect_calls) == 3
|
||||||
|
), f"zip-pair must create 3 connect_port calls for 3-vs-3 batched MEP duplicate; got {len(connect_calls)}"
|
||||||
|
|
||||||
|
def test_skips_when_other_side_not_duplicated(self):
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
relating_old = Mock()
|
||||||
|
related_old = Mock()
|
||||||
|
record = self._make_record(related_old)
|
||||||
|
snapshot = self._make_snapshot(relating_old, [record], port_counts={})
|
||||||
|
|
||||||
|
# Only relating side is in old_to_new.
|
||||||
|
old_to_new = {relating_old: [Mock(), Mock(), Mock()]}
|
||||||
|
|
||||||
|
with patch.object(tool.System, "get_ports", return_value=[Mock()]), patch.object(
|
||||||
|
tool.Ifc, "run", return_value=None
|
||||||
|
) as run_mock:
|
||||||
|
tool.Duplicate.recreate_port_connections(snapshot, old_to_new)
|
||||||
|
|
||||||
|
connect_calls = [c for c in run_mock.call_args_list if c.args and c.args[0] == "system.connect_port"]
|
||||||
|
assert connect_calls == [], "when only one side is in old_to_new, no port connections should be recreated"
|
||||||
|
|
||||||
|
def test_single_pair_case_unchanged(self):
|
||||||
|
"""Pre-sweep behavior (1 source -> 1 new) must still work — zip with two 1-element lists."""
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
relating_old = Mock()
|
||||||
|
related_old = Mock()
|
||||||
|
record = self._make_record(related_old)
|
||||||
|
snapshot = self._make_snapshot(relating_old, [record], port_counts={})
|
||||||
|
|
||||||
|
old_to_new = {relating_old: [Mock()], related_old: [Mock()]}
|
||||||
|
|
||||||
|
with patch.object(tool.System, "get_ports", return_value=[Mock()]), patch.object(
|
||||||
|
tool.Ifc, "run", return_value=None
|
||||||
|
) as run_mock:
|
||||||
|
tool.Duplicate.recreate_port_connections(snapshot, old_to_new)
|
||||||
|
|
||||||
|
connect_calls = [c for c in run_mock.call_args_list if c.args and c.args[0] == "system.connect_port"]
|
||||||
|
assert len(connect_calls) == 1
|
||||||
@@ -346,7 +346,9 @@ def test_active_is_flow_segment_classifies_segment_vs_fitting():
|
|||||||
fitting_elem.is_a = lambda c: c == "IfcFlowFitting"
|
fitting_elem.is_a = lambda c: c == "IfcFlowFitting"
|
||||||
|
|
||||||
plain = Mock()
|
plain = Mock()
|
||||||
with patch("bonsai.bim.module.model.mep.tool.System.has_parametric_body", return_value=True):
|
with patch("bonsai.bim.module.model.mep.tool.System.has_parametric_body", return_value=True), patch(
|
||||||
|
"bonsai.bim.module.model.mep.tool.Array.is_array_child", return_value=False
|
||||||
|
):
|
||||||
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=segment_elem):
|
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=segment_elem):
|
||||||
assert _active_is_flow_segment(plain) is True
|
assert _active_is_flow_segment(plain) is True
|
||||||
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=fitting_elem):
|
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=fitting_elem):
|
||||||
|
|||||||
@@ -164,6 +164,62 @@ def test_stale_element_skipped_at_drain():
|
|||||||
assert recut.call_count == 0
|
assert recut.call_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
class _DeadStructRNA:
|
||||||
|
"""Simulates a Blender object whose StructRNA has been removed — every
|
||||||
|
attribute access raises ReferenceError. Enqueue this as voided_obj to
|
||||||
|
reproduce the outliner-mid-batch-delete crash."""
|
||||||
|
|
||||||
|
def __getattr__(self, name):
|
||||||
|
raise ReferenceError("StructRNA of type Object has been removed")
|
||||||
|
|
||||||
|
def __bool__(self):
|
||||||
|
raise ReferenceError("StructRNA of type Object has been removed")
|
||||||
|
|
||||||
|
|
||||||
|
def test_dead_structrna_recut_skipped_at_drain():
|
||||||
|
"""Blender object is deleted while the batch is open (outliner delete +
|
||||||
|
manual DEL bypass the bim.delete cascade). The drain must skip it silently
|
||||||
|
— not raise — so unrelated hosts in the same batch still get their recut."""
|
||||||
|
from bonsai import tool
|
||||||
|
|
||||||
|
dead_obj = _DeadStructRNA()
|
||||||
|
live_obj = _mock_voided_obj("LiveWall")
|
||||||
|
rep = Mock()
|
||||||
|
|
||||||
|
def get_entity(obj):
|
||||||
|
# Called only when the guard clears — for the dead ref, guard short-circuits first.
|
||||||
|
return _mock_element(2)
|
||||||
|
|
||||||
|
with patch("bonsai.core.geometry.switch_representation") as recut, patch.object(
|
||||||
|
tool.Ifc, "get_entity", side_effect=get_entity
|
||||||
|
), patch.object(tool.Geometry, "get_active_representation", return_value=rep):
|
||||||
|
with tool.Geometry.batch_host_recut():
|
||||||
|
tool.Geometry._host_recut_queue[999] = (dead_obj, rep)
|
||||||
|
tool.Geometry.recut_host(live_obj, rep)
|
||||||
|
|
||||||
|
assert recut.call_count == 1, "live host must still get its recut despite a dead sibling in the queue"
|
||||||
|
drained_obj = recut.call_args.kwargs["obj"]
|
||||||
|
assert drained_obj is live_obj
|
||||||
|
|
||||||
|
|
||||||
|
def test_dead_structrna_update_skipped_at_drain():
|
||||||
|
"""Same guarantee for update_representation drain path."""
|
||||||
|
from bonsai import tool
|
||||||
|
|
||||||
|
dead_obj = _DeadStructRNA()
|
||||||
|
live_obj = _mock_voided_obj("LiveWall")
|
||||||
|
bpy_ops_mock = Mock()
|
||||||
|
|
||||||
|
with patch("bonsai.tool.geometry.bpy.ops", new=bpy_ops_mock), patch.object(
|
||||||
|
tool.Ifc, "get_entity", return_value=_mock_element(42)
|
||||||
|
), patch.object(tool.Geometry, "get_active_representation", return_value=Mock()):
|
||||||
|
with tool.Geometry.batch_host_recut():
|
||||||
|
tool.Geometry._host_update_queue[999] = dead_obj
|
||||||
|
tool.Geometry.update_host_representation(live_obj)
|
||||||
|
|
||||||
|
assert bpy_ops_mock.bim.update_representation.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
def test_exception_inside_batch_still_resets_state():
|
def test_exception_inside_batch_still_resets_state():
|
||||||
from bonsai import tool
|
from bonsai import tool
|
||||||
|
|
||||||
|
|||||||
@@ -630,15 +630,15 @@ class TestUsingArrays(NewFile):
|
|||||||
def test_remove_array_first_to_last(self):
|
def test_remove_array_first_to_last(self):
|
||||||
self.setup_array(add_second_layer=True)
|
self.setup_array(add_second_layer=True)
|
||||||
bpy.ops.bim.remove_array(item=0)
|
bpy.ops.bim.remove_array(item=0)
|
||||||
assert len(bpy.context.selected_objects) == 3
|
assert len(self._array_objects()) == 3
|
||||||
bpy.ops.bim.remove_array(item=0)
|
bpy.ops.bim.remove_array(item=0)
|
||||||
assert len(bpy.context.selected_objects) == 1
|
assert len(self._array_objects()) == 1
|
||||||
|
|
||||||
def test_apply_array_1_layer(self):
|
def test_apply_array_1_layer(self):
|
||||||
self.setup_array()
|
self.setup_array()
|
||||||
bpy.ops.bim.apply_array()
|
bpy.ops.bim.apply_array()
|
||||||
|
|
||||||
objs = bpy.context.selected_objects
|
objs = self._array_objects()
|
||||||
assert len(objs) == 4
|
assert len(objs) == 4
|
||||||
# check BBIM_Array psets are removed
|
# check BBIM_Array psets are removed
|
||||||
for obj in objs:
|
for obj in objs:
|
||||||
@@ -664,7 +664,7 @@ class TestUsingArrays(NewFile):
|
|||||||
self.setup_array(sync_children=True)
|
self.setup_array(sync_children=True)
|
||||||
bpy.ops.bim.apply_array()
|
bpy.ops.bim.apply_array()
|
||||||
|
|
||||||
objs = bpy.context.selected_objects
|
objs = self._array_objects()
|
||||||
assert len(objs) == 4
|
assert len(objs) == 4
|
||||||
# check BBIM_Array psets are removed
|
# check BBIM_Array psets are removed
|
||||||
for obj in objs:
|
for obj in objs:
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ Future versions of this API may support:
|
|||||||
|
|
||||||
from ._get_segment_start_point_label import register_referent_name_callback
|
from ._get_segment_start_point_label import register_referent_name_callback
|
||||||
from .add_stationing_referent import add_stationing_referent
|
from .add_stationing_referent import add_stationing_referent
|
||||||
|
from .add_positioning_referent import add_positioning_referent
|
||||||
from .add_vertical_layout import add_vertical_layout
|
from .add_vertical_layout import add_vertical_layout
|
||||||
from .add_zero_length_segment import add_zero_length_segment
|
from .add_zero_length_segment import add_zero_length_segment
|
||||||
from .create import create
|
from .create import create
|
||||||
@@ -94,6 +95,7 @@ from .util import *
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"add_stationing_referent",
|
"add_stationing_referent",
|
||||||
|
"add_positioning_referent",
|
||||||
"add_vertical_layout",
|
"add_vertical_layout",
|
||||||
"add_zero_length_segment",
|
"add_zero_length_segment",
|
||||||
"create",
|
"create",
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# IfcOpenShell - IFC toolkit and geometry engine
|
||||||
|
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
|
||||||
|
#
|
||||||
|
# This file is part of IfcOpenShell.
|
||||||
|
#
|
||||||
|
# IfcOpenShell is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Lesser General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# IfcOpenShell is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Lesser General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Lesser General Public License
|
||||||
|
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import ifcopenshell
|
||||||
|
import ifcopenshell.api.alignment
|
||||||
|
from ifcopenshell.api.alignment.update_fallback_position import update_fallback_position
|
||||||
|
import ifcopenshell.api.pset
|
||||||
|
import ifcopenshell.guid
|
||||||
|
from ifcopenshell import entity_instance
|
||||||
|
|
||||||
|
|
||||||
|
def add_positioning_referent(
|
||||||
|
file: ifcopenshell.file,
|
||||||
|
name: str,
|
||||||
|
alignment: entity_instance,
|
||||||
|
distance_along: float,
|
||||||
|
station: float,
|
||||||
|
positioned_product: entity_instance,
|
||||||
|
) -> entity_instance:
|
||||||
|
"""
|
||||||
|
Semantically defines the position of a product along an alignment by adding an IfcReferent to the alignment that defines the stationing system.
|
||||||
|
|
||||||
|
:param alignment: the alignment to receive the referent
|
||||||
|
:param distance_along: distance along the alignment basis curve
|
||||||
|
:param station: station value
|
||||||
|
:param name: name to assign to IfcReferent.Name, typically a stringized version of the station value
|
||||||
|
:param positioned_product: the product whose position is informed by the referent
|
||||||
|
:return: referent
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
.. code:: python
|
||||||
|
|
||||||
|
alignment = model.by_type("IfcAlignment")[0]
|
||||||
|
pier = model.by_type("IfcBridgePart")[0]
|
||||||
|
ifcopenshell.api.alignment.add_positioning_referent(model,name="Pier 1 Sta 1+00",alignment=alignment,distance_along=0.0,station=100.0,positioned_product=pier)
|
||||||
|
"""
|
||||||
|
|
||||||
|
basis_curve = ifcopenshell.api.alignment.get_basis_curve(alignment)
|
||||||
|
|
||||||
|
object_placement = None
|
||||||
|
representation = None
|
||||||
|
if basis_curve and basis_curve.is_a("IfcCompositeCurve") and 0 < len(basis_curve.Segments):
|
||||||
|
object_placement = file.createIfcLinearPlacement(
|
||||||
|
RelativePlacement=file.createIfcAxis2PlacementLinear(
|
||||||
|
Location=file.createIfcPointByDistanceExpression(
|
||||||
|
DistanceAlong=file.createIfcLengthMeasure(distance_along),
|
||||||
|
OffsetLateral=None,
|
||||||
|
OffsetVertical=None,
|
||||||
|
OffsetLongitudinal=None,
|
||||||
|
BasisCurve=basis_curve,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
update_fallback_position(file, object_placement)
|
||||||
|
else:
|
||||||
|
object_placement = file.createIfcLocalPlacement(
|
||||||
|
PlacementRelTo=None,
|
||||||
|
RelativePlacement=file.createIfcAxis2Placement2D(
|
||||||
|
Location=file.createIfcCartesianPoint(alignment.ObjectPlacement.RelativePlacement.Location.Coordinates)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# this commented out code is what you would do to add a geometric representation of the referent
|
||||||
|
# the example is a circle. a better way would be to pass a representation into the function
|
||||||
|
# representation = file.create_entity(
|
||||||
|
# name="IfcCircle",
|
||||||
|
# position=file.createIfcAxis2Placement2D(Location=file.createIfcCartesianPoint(Coordinates=(0.0, 0.0)),
|
||||||
|
# radius=1.0)
|
||||||
|
# )
|
||||||
|
|
||||||
|
# create referent for the station
|
||||||
|
referent = file.createIfcReferent(
|
||||||
|
GlobalId=ifcopenshell.guid.new(),
|
||||||
|
OwnerHistory=None,
|
||||||
|
Name=name,
|
||||||
|
Description=None,
|
||||||
|
ObjectType=None,
|
||||||
|
ObjectPlacement=object_placement,
|
||||||
|
Representation=representation,
|
||||||
|
PredefinedType="POSITION",
|
||||||
|
)
|
||||||
|
pset_stationing = ifcopenshell.api.pset.add_pset(file, product=referent, name="Pset_Stationing")
|
||||||
|
ifcopenshell.api.pset.edit_pset(file, pset=pset_stationing, properties={"Station": station})
|
||||||
|
|
||||||
|
if len(referent.Positions) == 0:
|
||||||
|
rel_positions = file.createIfcRelPositions(
|
||||||
|
GlobalId=ifcopenshell.guid.new(),
|
||||||
|
RelatingPositioningElement=referent,
|
||||||
|
RelatedProducts=[
|
||||||
|
positioned_product,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
referent.Positions[0].RelatedProducts += (positioned_product,)
|
||||||
|
|
||||||
|
return referent
|
||||||
@@ -16,35 +16,33 @@
|
|||||||
# You should have received a copy of the GNU Lesser General Public License
|
# You should have received a copy of the GNU Lesser General Public License
|
||||||
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import numpy as np
|
from typing import Optional
|
||||||
|
|
||||||
import ifcopenshell
|
import ifcopenshell
|
||||||
import ifcopenshell.api.alignment
|
import ifcopenshell.api.alignment
|
||||||
from ifcopenshell.api.alignment.update_fallback_position import update_fallback_position
|
from ifcopenshell.api.alignment.update_fallback_position import update_fallback_position
|
||||||
import ifcopenshell.api.pset
|
import ifcopenshell.api.pset
|
||||||
import ifcopenshell.geom
|
|
||||||
import ifcopenshell.guid
|
import ifcopenshell.guid
|
||||||
import ifcopenshell.util.element
|
import ifcopenshell.util.element
|
||||||
import ifcopenshell.util.unit
|
from ifcopenshell import entity_instance
|
||||||
from ifcopenshell import entity_instance, ifcopenshell_wrapper
|
|
||||||
|
|
||||||
|
|
||||||
def add_stationing_referent(
|
def add_stationing_referent(
|
||||||
file: ifcopenshell.file,
|
file: ifcopenshell.file,
|
||||||
|
name: str,
|
||||||
alignment: entity_instance,
|
alignment: entity_instance,
|
||||||
distance_along: float,
|
distance_along: float,
|
||||||
station: float,
|
station: float,
|
||||||
name: str,
|
incoming_station: Optional[float] = None,
|
||||||
positioned_product: entity_instance,
|
|
||||||
) -> entity_instance:
|
) -> entity_instance:
|
||||||
"""
|
"""
|
||||||
Adds an IfcReferent to the alignment with the Pset_Stationing property set.
|
Adds an IfcReferent to the alignment that defines the stationing system.
|
||||||
|
|
||||||
|
:param name: name to assign to IfcReferent.Name, typically a stringized version of the station value
|
||||||
:param alignment: the alignment to receive the referent
|
:param alignment: the alignment to receive the referent
|
||||||
:param distance_along: distance along the alignment basis curve
|
:param distance_along: distance along the alignment basis curve
|
||||||
:param station: station value
|
:param station: station value
|
||||||
:param name: name to assign to IfcReferent.Name, typically a stringized version of the station value
|
:param incoming_station: station value of the incoming segment, only set to specify a station equation
|
||||||
:param positioned_product: the product whose position is informed by the referent
|
|
||||||
:return: referent
|
:return: referent
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
@@ -52,7 +50,7 @@ def add_stationing_referent(
|
|||||||
.. code:: python
|
.. code:: python
|
||||||
|
|
||||||
alignment = model.by_type("IfcAlignment")[0]
|
alignment = model.by_type("IfcAlignment")[0]
|
||||||
ifcopenshell.api.alignment.add_stationing_referent(model,alignment=alignment,distance_along=0.0,station=100.0)
|
ifcopenshell.api.alignment.add_stationing_referent(model,name="1+00.0",alignment=alignment,distance_along=0.0,station=100.0)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
basis_curve = ifcopenshell.api.alignment.get_basis_curve(alignment)
|
basis_curve = ifcopenshell.api.alignment.get_basis_curve(alignment)
|
||||||
@@ -100,8 +98,12 @@ def add_stationing_referent(
|
|||||||
Representation=representation,
|
Representation=representation,
|
||||||
PredefinedType="STATION",
|
PredefinedType="STATION",
|
||||||
)
|
)
|
||||||
|
properties = {"Station": station}
|
||||||
|
if incoming_station is not None:
|
||||||
|
properties["IncomingStation"] = incoming_station
|
||||||
|
|
||||||
pset_stationing = ifcopenshell.api.pset.add_pset(file, product=referent, name="Pset_Stationing")
|
pset_stationing = ifcopenshell.api.pset.add_pset(file, product=referent, name="Pset_Stationing")
|
||||||
ifcopenshell.api.pset.edit_pset(file, pset=pset_stationing, properties={"Station": station})
|
ifcopenshell.api.pset.edit_pset(file, pset=pset_stationing, properties=properties)
|
||||||
|
|
||||||
nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
|
nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
|
||||||
if nest is None:
|
if nest is None:
|
||||||
@@ -115,15 +117,4 @@ def add_stationing_referent(
|
|||||||
nest.RelatedObjects, key=lambda x: ifcopenshell.util.element.get_pset(x, name="Pset_Stationing", prop="Station")
|
nest.RelatedObjects, key=lambda x: ifcopenshell.util.element.get_pset(x, name="Pset_Stationing", prop="Station")
|
||||||
)
|
)
|
||||||
|
|
||||||
if len(referent.Positions) == 0:
|
|
||||||
rel_positions = file.createIfcRelPositions(
|
|
||||||
GlobalId=ifcopenshell.guid.new(),
|
|
||||||
RelatingPositioningElement=referent,
|
|
||||||
RelatedProducts=[
|
|
||||||
positioned_product,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
referent.Positions[0].RelatedProducts += (positioned_product,)
|
|
||||||
|
|
||||||
return referent
|
return referent
|
||||||
|
|||||||
@@ -51,18 +51,6 @@ def _move_vertical_layout_to_child_alignment(
|
|||||||
# aggregate the child alignment to the parent alignment
|
# aggregate the child alignment to the parent alignment
|
||||||
ifcopenshell.api.aggregate.assign_object(file, products=[child_alignment], relating_object=parent_alignment)
|
ifcopenshell.api.aggregate.assign_object(file, products=[child_alignment], relating_object=parent_alignment)
|
||||||
|
|
||||||
# move all referents positioning segments of the vertical layout to the referent nest of the child alignment
|
|
||||||
child_referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, child_alignment)
|
|
||||||
parent_referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, parent_alignment)
|
|
||||||
for referent in parent_referent_nest.RelatedObjects:
|
|
||||||
for product in referent.Positions[0].RelatedProducts:
|
|
||||||
if product.is_a("IfcAlignmentSegment") and product.Nests[0].RelatingObject == vertical_layout:
|
|
||||||
# ifcopenshell.api.nest.change_nest(file,referent,child_alignment) - this doesn't work because referent is assigned to child_alignment.IsNestedBy[0].RelatedObjects
|
|
||||||
# and it needs to be assigned to child_alignment.IsNestedBy[1].RelatedObjects
|
|
||||||
# move the referent manually - unassign it and add it to the child alignment's referent nest
|
|
||||||
ifcopenshell.api.nest.unassign_object(file, [referent])
|
|
||||||
child_referent_nest.RelatedObjects += (referent,)
|
|
||||||
|
|
||||||
# if the parent alignment has a representation, move the Axis/Curve3D represention to the child alignment
|
# if the parent alignment has a representation, move the Axis/Curve3D represention to the child alignment
|
||||||
base_curve = ifcopenshell.api.alignment.get_basis_curve(parent_alignment)
|
base_curve = ifcopenshell.api.alignment.get_basis_curve(parent_alignment)
|
||||||
if base_curve:
|
if base_curve:
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ def create(
|
|||||||
|
|
||||||
referent_name = ifcopenshell.util.alignment.station_as_string(file, start_station)
|
referent_name = ifcopenshell.util.alignment.station_as_string(file, start_station)
|
||||||
referent = ifcopenshell.api.alignment.add_stationing_referent(
|
referent = ifcopenshell.api.alignment.add_stationing_referent(
|
||||||
file, alignment, 0.0, start_station, referent_name, alignment
|
file, referent_name, alignment, 0.0, start_station
|
||||||
)
|
)
|
||||||
|
|
||||||
for layout in alignment_layouts:
|
for layout in alignment_layouts:
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ def create_as_polyline(
|
|||||||
|
|
||||||
# define stationing
|
# define stationing
|
||||||
name = ifcopenshell.util.alignment.station_as_string(file, start_station)
|
name = ifcopenshell.util.alignment.station_as_string(file, start_station)
|
||||||
referent = ifcopenshell.api.alignment.add_stationing_referent(file, alignment, 0.0, start_station, name, alignment)
|
referent = ifcopenshell.api.alignment.add_stationing_referent(file, name, alignment, 0.0, start_station)
|
||||||
|
|
||||||
# IFC 4.1.4.1.1 Alignment Aggregation To Project
|
# IFC 4.1.4.1.1 Alignment Aggregation To Project
|
||||||
project = file.by_type("IfcProject")[0]
|
project = file.by_type("IfcProject")[0]
|
||||||
|
|||||||
@@ -16,23 +16,47 @@
|
|||||||
# You should have received a copy of the GNU Lesser General Public License
|
# You should have received a copy of the GNU Lesser General Public License
|
||||||
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
import ifcopenshell
|
import ifcopenshell
|
||||||
import ifcopenshell.api.alignment
|
import ifcopenshell.api.alignment
|
||||||
|
import ifcopenshell.util.element
|
||||||
from ifcopenshell import entity_instance
|
from ifcopenshell import entity_instance
|
||||||
|
|
||||||
|
|
||||||
def distance_along_from_station(file: ifcopenshell.file, alignment: entity_instance, station: float) -> float:
|
def _distance_along_of_referent(referent: entity_instance) -> float:
|
||||||
|
placement = referent.ObjectPlacement
|
||||||
|
if placement.is_a("IfcLinearPlacement"):
|
||||||
|
return placement.RelativePlacement.Location.DistanceAlong.wrappedValue
|
||||||
|
# IfcLocalPlacement fallback (e.g. semantic-only alignment, or the placement could not yet
|
||||||
|
# be expressed relative to a basis curve) carries no DistanceAlong; it is only ever used for
|
||||||
|
# the starting referent, at distance 0.0.
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def distance_along_from_station(file: ifcopenshell.file, alignment: entity_instance, station: float) -> Optional[float]:
|
||||||
"""
|
"""
|
||||||
Given a station, returns the distance along the horizontal alignment.
|
Given a station, returns the distance along the horizontal alignment.
|
||||||
|
|
||||||
If the alignment does not have stationing defined with an IfcReferent, the start of the alignment is assumed
|
If the alignment does not have stationing defined with an IfcReferent, the start of the alignment is assumed
|
||||||
to be at station 0.0. That is, the station is the distance along.
|
to be at station 0.0. That is, the station is the distance along.
|
||||||
|
|
||||||
.. note:: The current implementation does not account for station equations and assumes stationing is increasing along the alignment.
|
Station equations (where Pset_Stationing.IncomingStation is set on a referent) are taken into account.
|
||||||
|
For each STATION referent nested to the alignment, DistanceAlong (D) and the outgoing station (S, i.e.
|
||||||
|
Pset_Stationing.Station) are read off, sorted by DistanceAlong. The requested station is located within
|
||||||
|
the segment defined by the last referent whose outgoing station is less than or equal to it, and the
|
||||||
|
distance along is computed as D + (station - S) for that referent.
|
||||||
|
|
||||||
|
If the station falls within a gap introduced by a forward (gap) station equation - that is, it was skipped
|
||||||
|
over by the equation - there is no distance along that corresponds to it, and None is returned.
|
||||||
|
|
||||||
|
Note that an overlap (backward) station equation causes a range of stations to correspond to two distinct
|
||||||
|
distances along the alignment, one on either side of the equation. This implementation returns the distance
|
||||||
|
along in the segment following the equation (i.e. the outgoing side).
|
||||||
|
|
||||||
:param alignment: the alignment
|
:param alignment: the alignment
|
||||||
:param station: station value
|
:param station: station value
|
||||||
:return: distance along the horizontal alignment
|
:return: distance along the horizontal alignment, or None if the station falls inside a station equation gap
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
@@ -43,6 +67,33 @@ def distance_along_from_station(file: ifcopenshell.file, alignment: entity_insta
|
|||||||
print(dist_along) # 100.00
|
print(dist_along) # 100.00
|
||||||
"""
|
"""
|
||||||
|
|
||||||
start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment)
|
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
|
||||||
dist_along = station - start_station
|
if referent_nest is None:
|
||||||
return dist_along
|
start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment)
|
||||||
|
return station - start_station
|
||||||
|
|
||||||
|
stations = [
|
||||||
|
(_distance_along_of_referent(referent), ifcopenshell.util.element.get_pset(referent, name="Pset_Stationing", prop="Station"))
|
||||||
|
for referent in referent_nest.RelatedObjects
|
||||||
|
]
|
||||||
|
stations.sort(key=lambda entry: entry[0])
|
||||||
|
|
||||||
|
index = None
|
||||||
|
for i, (distance_along, outgoing_station) in enumerate(stations):
|
||||||
|
if outgoing_station <= station:
|
||||||
|
index = i
|
||||||
|
|
||||||
|
if index is None:
|
||||||
|
# station precedes the alignment's starting station; extrapolate from the first referent
|
||||||
|
distance_along, outgoing_station = stations[0]
|
||||||
|
return distance_along + (station - outgoing_station)
|
||||||
|
|
||||||
|
distance_along, outgoing_station = stations[index]
|
||||||
|
|
||||||
|
if index + 1 < len(stations):
|
||||||
|
next_distance_along, _ = stations[index + 1]
|
||||||
|
if station - outgoing_station > next_distance_along - distance_along:
|
||||||
|
# the station was skipped over by a forward (gap) station equation
|
||||||
|
return None
|
||||||
|
|
||||||
|
return distance_along + (station - outgoing_station)
|
||||||
|
|||||||
@@ -36,9 +36,12 @@ def update_fallback_position(file: ifcopenshell.file, lp: entity_instance):
|
|||||||
|
|
||||||
p = ifcopenshell.util.placement.get_local_placement(lp)
|
p = ifcopenshell.util.placement.get_local_placement(lp)
|
||||||
|
|
||||||
x = float(p[0, 3])
|
|
||||||
y = float(p[1, 3])
|
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file)
|
||||||
z = float(p[2, 3])
|
|
||||||
|
x = float(p[0, 3])*unit_scale
|
||||||
|
y = float(p[1, 3])*unit_scale
|
||||||
|
z = float(p[2, 3])*unit_scale
|
||||||
|
|
||||||
rx = float(p[0, 0])
|
rx = float(p[0, 0])
|
||||||
ry = float(p[1, 0])
|
ry = float(p[1, 0])
|
||||||
|
|||||||
@@ -1125,7 +1125,8 @@ def get_decomposition(element: ifcopenshell.entity_instance, is_recursive=True)
|
|||||||
"""
|
"""
|
||||||
Retrieves all subelements of an element based on the spatial decomposition
|
Retrieves all subelements of an element based on the spatial decomposition
|
||||||
hierarchy. This includes all subspaces and elements contained in subspaces,
|
hierarchy. This includes all subspaces and elements contained in subspaces,
|
||||||
parts of an aggregate, all openings, and all fills of any openings.
|
parts of an aggregate, all openings, all fills of any openings, and any
|
||||||
|
surface features adhering to an element (IFC4.3 and above).
|
||||||
|
|
||||||
:param element: The IFC element
|
:param element: The IFC element
|
||||||
:return: The decomposition of the element
|
:return: The decomposition of the element
|
||||||
@@ -1161,6 +1162,10 @@ def get_decomposition(element: ifcopenshell.entity_instance, is_recursive=True)
|
|||||||
related = rel.RelatedObjects
|
related = rel.RelatedObjects
|
||||||
queue.extend(related)
|
queue.extend(related)
|
||||||
results.update(related)
|
results.update(related)
|
||||||
|
for rel in getattr(element, "HasSurfaceFeatures", []):
|
||||||
|
related = rel.RelatedSurfaceFeatures
|
||||||
|
queue.extend(related)
|
||||||
|
results.update(related)
|
||||||
if not is_recursive:
|
if not is_recursive:
|
||||||
break
|
break
|
||||||
return results
|
return results
|
||||||
@@ -1251,6 +1256,8 @@ def get_parent(
|
|||||||
- Nesting: components are attached to a host parent
|
- Nesting: components are attached to a host parent
|
||||||
- Filling: the physical element fills an opening, such as a window filling a hole
|
- Filling: the physical element fills an opening, such as a window filling a hole
|
||||||
- Voiding: the opening voids another physical element, such as a hole in a wall
|
- Voiding: the opening voids another physical element, such as a hole in a wall
|
||||||
|
- Adherence: a surface feature adheres to a host element, such as a road
|
||||||
|
marking adhering to a road course (IFC4.3 and above)
|
||||||
|
|
||||||
:param element: Any physical or spatial element in the tree
|
:param element: Any physical or spatial element in the tree
|
||||||
:param ifc_class: Optionally filter the type of parent you're after. For
|
:param ifc_class: Optionally filter the type of parent you're after. For
|
||||||
@@ -1270,6 +1277,7 @@ def get_parent(
|
|||||||
or get_nest(element)
|
or get_nest(element)
|
||||||
or get_filled_void(element)
|
or get_filled_void(element)
|
||||||
or get_voided_element(element)
|
or get_voided_element(element)
|
||||||
|
or get_adhered_element(element)
|
||||||
)
|
)
|
||||||
|
|
||||||
if not ifc_class:
|
if not ifc_class:
|
||||||
@@ -1321,6 +1329,28 @@ def get_voided_element(element: ifcopenshell.entity_instance) -> Union[ifcopensh
|
|||||||
return rel[0].RelatingBuildingElement
|
return rel[0].RelatingBuildingElement
|
||||||
|
|
||||||
|
|
||||||
|
def get_adhered_element(element: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]:
|
||||||
|
"""If the element is a surface feature, get the element it adheres to
|
||||||
|
|
||||||
|
In IFC4.3 an IfcSurfaceFeature (such as a road marking) adheres to a host
|
||||||
|
element through the IfcRelAdheresToElement relationship. This is a [1:1]
|
||||||
|
cardinality hierarchical relationship, in the same family as aggregation,
|
||||||
|
containment and nesting.
|
||||||
|
|
||||||
|
:param element: The IfcSurfaceFeature
|
||||||
|
:return: The host element that the surface feature adheres to
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
.. code:: python
|
||||||
|
|
||||||
|
marking = file.by_type("IfcSurfaceFeature")[0]
|
||||||
|
host = ifcopenshell.util.element.get_adhered_element(marking)
|
||||||
|
"""
|
||||||
|
if rel := getattr(element, "AdheresToElement", None):
|
||||||
|
return rel[0].RelatingElement
|
||||||
|
|
||||||
|
|
||||||
def get_aggregate(element: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]:
|
def get_aggregate(element: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]:
|
||||||
"""
|
"""
|
||||||
Retrieves the aggregate parent of an element.
|
Retrieves the aggregate parent of an element.
|
||||||
@@ -1415,6 +1445,29 @@ def get_contained(element: ifcopenshell.entity_instance) -> list[ifcopenshell.en
|
|||||||
return objects
|
return objects
|
||||||
|
|
||||||
|
|
||||||
|
def get_surface_features(element: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]:
|
||||||
|
"""Retrieves the surface features that adhere to an element.
|
||||||
|
|
||||||
|
In IFC4.3 an IfcSurfaceFeature (such as a road marking) adheres to a host
|
||||||
|
element through the IfcRelAdheresToElement relationship.
|
||||||
|
|
||||||
|
:param element: The IFC element
|
||||||
|
:return: The surface features adhering to the element
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
.. code:: python
|
||||||
|
|
||||||
|
element = file.by_type("IfcCourse")[0]
|
||||||
|
markings = ifcopenshell.util.element.get_surface_features(element)
|
||||||
|
"""
|
||||||
|
objects: list[ifcopenshell.entity_instance] = []
|
||||||
|
if has_surface_features := getattr(element, "HasSurfaceFeatures", ()):
|
||||||
|
for rel in has_surface_features:
|
||||||
|
objects.extend(rel.RelatedSurfaceFeatures)
|
||||||
|
return objects
|
||||||
|
|
||||||
|
|
||||||
def get_components(
|
def get_components(
|
||||||
element: ifcopenshell.entity_instance, include_ports: bool = False
|
element: ifcopenshell.entity_instance, include_ports: bool = False
|
||||||
) -> list[ifcopenshell.entity_instance]:
|
) -> list[ifcopenshell.entity_instance]:
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# IfcOpenShell - IFC toolkit and geometry engine
|
||||||
|
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
|
||||||
|
#
|
||||||
|
# This file is part of IfcOpenShell.
|
||||||
|
#
|
||||||
|
# IfcOpenShell is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Lesser General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# IfcOpenShell is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Lesser General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Lesser General Public License
|
||||||
|
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
|
import ifcopenshell.api.alignment
|
||||||
|
import ifcopenshell.api.context
|
||||||
|
import ifcopenshell.api.unit
|
||||||
|
import ifcopenshell.util.element
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_positioning_referent():
|
||||||
|
file = ifcopenshell.file(schema="IFC4X3")
|
||||||
|
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
|
||||||
|
length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT")
|
||||||
|
ifcopenshell.api.unit.assign_unit(file, units=[length])
|
||||||
|
geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model")
|
||||||
|
axis_model_representation_subcontext = ifcopenshell.api.context.add_context(
|
||||||
|
file,
|
||||||
|
context_type="Model",
|
||||||
|
context_identifier="Axis",
|
||||||
|
target_view="MODEL_VIEW",
|
||||||
|
parent=geometric_representation_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
alignment = ifcopenshell.api.alignment.create(file, "TestAlignment", start_station=2000.0)
|
||||||
|
|
||||||
|
horizontal_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||||
|
segment = ifcopenshell.api.alignment.get_layout_segments(horizontal_layout)[0]
|
||||||
|
|
||||||
|
referent = ifcopenshell.api.alignment.add_positioning_referent(
|
||||||
|
file, "P.C.", alignment, distance_along=0.0, station=2000.0, positioned_product=segment
|
||||||
|
)
|
||||||
|
|
||||||
|
assert referent.is_a("IfcReferent")
|
||||||
|
assert referent.PredefinedType == "POSITION"
|
||||||
|
assert referent.Name == "P.C."
|
||||||
|
assert ifcopenshell.util.element.get_pset(element=referent, name="Pset_Stationing")
|
||||||
|
assert ifcopenshell.util.element.get_pset(element=referent, name="Pset_Stationing", prop="Station") == 2000.0
|
||||||
|
assert referent.ObjectPlacement != None
|
||||||
|
|
||||||
|
assert len(referent.Positions) == 1
|
||||||
|
rel_positions = referent.Positions[0]
|
||||||
|
assert rel_positions.is_a("IfcRelPositions")
|
||||||
|
assert rel_positions.RelatingPositioningElement == referent
|
||||||
|
assert rel_positions.RelatedProducts == (segment,)
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_positioning_referent_creates_separate_referent_per_call():
|
||||||
|
file = ifcopenshell.file(schema="IFC4X3")
|
||||||
|
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
|
||||||
|
length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT")
|
||||||
|
ifcopenshell.api.unit.assign_unit(file, units=[length])
|
||||||
|
geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model")
|
||||||
|
axis_model_representation_subcontext = ifcopenshell.api.context.add_context(
|
||||||
|
file,
|
||||||
|
context_type="Model",
|
||||||
|
context_identifier="Axis",
|
||||||
|
target_view="MODEL_VIEW",
|
||||||
|
parent=geometric_representation_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
alignment = ifcopenshell.api.alignment.create(file, "TestAlignment", start_station=2000.0)
|
||||||
|
|
||||||
|
horizontal_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||||
|
segment = ifcopenshell.api.alignment.get_layout_segments(horizontal_layout)[0]
|
||||||
|
|
||||||
|
first_referent = ifcopenshell.api.alignment.add_positioning_referent(
|
||||||
|
file, "P.C.", alignment, distance_along=0.0, station=2000.0, positioned_product=segment
|
||||||
|
)
|
||||||
|
|
||||||
|
other_product = file.createIfcBuildingElementProxy(GlobalId=ifcopenshell.guid.new(), Name="Sign")
|
||||||
|
second_referent = ifcopenshell.api.alignment.add_positioning_referent(
|
||||||
|
file, "P.C.", alignment, distance_along=0.0, station=2000.0, positioned_product=other_product
|
||||||
|
)
|
||||||
|
|
||||||
|
# each call creates its own IfcReferent, each with its own IfcRelPositions to the product passed in
|
||||||
|
assert first_referent != second_referent
|
||||||
|
assert len(first_referent.Positions) == 1
|
||||||
|
assert first_referent.Positions[0].RelatedProducts == (segment,)
|
||||||
|
assert len(second_referent.Positions) == 1
|
||||||
|
assert second_referent.Positions[0].RelatedProducts == (other_product,)
|
||||||
|
|
||||||
|
|
||||||
|
test_add_positioning_referent()
|
||||||
|
test_add_positioning_referent_creates_separate_referent_per_call()
|
||||||
@@ -57,3 +57,27 @@ def test_add_stationing_to_alignment():
|
|||||||
assert ifcopenshell.util.element.get_pset(element=referent, name="Pset_Stationing")
|
assert ifcopenshell.util.element.get_pset(element=referent, name="Pset_Stationing")
|
||||||
assert ifcopenshell.util.element.get_pset(element=referent, name="Pset_Stationing", prop="Station") == 2000.0
|
assert ifcopenshell.util.element.get_pset(element=referent, name="Pset_Stationing", prop="Station") == 2000.0
|
||||||
assert referent.ObjectPlacement != None
|
assert referent.ObjectPlacement != None
|
||||||
|
|
||||||
|
# add a station equation at 1000 distance along. this is station 3+000 in coming and 4+000 outgoing.
|
||||||
|
# this is a gap equation.
|
||||||
|
second_referent = ifcopenshell.api.alignment.add_stationing_referent(
|
||||||
|
file, "4+000.000", alignment, distance_along=1000.0, station=4000.0, incoming_station=3000.0
|
||||||
|
)
|
||||||
|
|
||||||
|
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
|
||||||
|
assert len(referent_nest.RelatedObjects) == 2
|
||||||
|
|
||||||
|
assert second_referent == referent_nest.RelatedObjects[1]
|
||||||
|
|
||||||
|
assert second_referent.PredefinedType == "STATION"
|
||||||
|
assert second_referent.Name == "4+000.000"
|
||||||
|
assert ifcopenshell.util.element.get_pset(element=second_referent, name="Pset_Stationing")
|
||||||
|
assert ifcopenshell.util.element.get_pset(element=second_referent, name="Pset_Stationing", prop="Station") == 4000.0
|
||||||
|
assert (
|
||||||
|
ifcopenshell.util.element.get_pset(element=second_referent, name="Pset_Stationing", prop="IncomingStation")
|
||||||
|
== 3000.0
|
||||||
|
)
|
||||||
|
assert second_referent.ObjectPlacement != None
|
||||||
|
|
||||||
|
|
||||||
|
test_add_stationing_to_alignment()
|
||||||
|
|||||||
@@ -59,3 +59,58 @@ def test_distance_along_from_station():
|
|||||||
|
|
||||||
# Station 175+25.36
|
# Station 175+25.36
|
||||||
assert ifcopenshell.api.alignment.distance_along_from_station(file, alignment, 17525.36) == pytest.approx(7525.36)
|
assert ifcopenshell.api.alignment.distance_along_from_station(file, alignment, 17525.36) == pytest.approx(7525.36)
|
||||||
|
|
||||||
|
|
||||||
|
def test_distance_along_from_station_with_station_equations():
|
||||||
|
# Reproduces the worked example from the IFC Alignment Geometry Implementation Guide, chapter 9.2.6:
|
||||||
|
# a gap equation (P3: incoming 14+00.00, outgoing 17+00.00) and an overlap equation
|
||||||
|
# (P4: incoming 19+00.00, outgoing 18+50.00).
|
||||||
|
file = ifcopenshell.file(schema="IFC4X3")
|
||||||
|
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
|
||||||
|
length = ifcopenshell.api.unit.add_conversion_based_unit(file, name="foot")
|
||||||
|
ifcopenshell.api.unit.assign_unit(file, units=[length])
|
||||||
|
geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model")
|
||||||
|
axis_model_representation_subcontext = ifcopenshell.api.context.add_context(
|
||||||
|
file,
|
||||||
|
context_type="Model",
|
||||||
|
context_identifier="Axis",
|
||||||
|
target_view="MODEL_VIEW",
|
||||||
|
parent=geometric_representation_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
coordinates = [(500.0, 2500.0), (3340.0, 660.0), (4340.0, 5000.0), (7600.0, 4560.0), (8480.0, 2010.0)]
|
||||||
|
radii = [(1000.0), (1250.0), (950.0)]
|
||||||
|
vpoints = [(0.0, 100.0), (2000.0, 135.0), (5000.0, 105.0), (7400.0, 153.0), (9800.0, 105.0), (12800.0, 90.0)]
|
||||||
|
lengths = [(1600.0), (1200.0), (2000.0), (800.0)]
|
||||||
|
|
||||||
|
alignment = ifcopenshell.api.alignment.create_by_pi_method(
|
||||||
|
file, "TestAlignment", coordinates, radii, vpoints, lengths, start_station=1000.0
|
||||||
|
)
|
||||||
|
|
||||||
|
ifcopenshell.api.alignment.add_stationing_referent(
|
||||||
|
file, "P3", alignment, distance_along=400.0, station=1700.0, incoming_station=1400.0
|
||||||
|
)
|
||||||
|
ifcopenshell.api.alignment.add_stationing_referent(
|
||||||
|
file, "P4", alignment, distance_along=600.0, station=1850.0, incoming_station=1900.0
|
||||||
|
)
|
||||||
|
|
||||||
|
distance_along_from_station = ifcopenshell.api.alignment.distance_along_from_station
|
||||||
|
|
||||||
|
# between P2 and P3: Sta. 13+00.00
|
||||||
|
assert distance_along_from_station(file, alignment, 1300.0) == pytest.approx(300.0)
|
||||||
|
|
||||||
|
# between P3 and P4: Sta. 18+00.00
|
||||||
|
assert distance_along_from_station(file, alignment, 1800.0) == pytest.approx(500.0)
|
||||||
|
|
||||||
|
# between P4 and P5: Sta. 19+25.00
|
||||||
|
assert distance_along_from_station(file, alignment, 1925.0) == pytest.approx(675.0)
|
||||||
|
|
||||||
|
# Sta. 15+00.00 falls inside the gap opened by the equation at P3 and has no corresponding distance along
|
||||||
|
assert distance_along_from_station(file, alignment, 1500.0) is None
|
||||||
|
|
||||||
|
# Sta. 18+75.00 falls inside the overlap zone at P4; the post-equation (outgoing) match is returned
|
||||||
|
assert distance_along_from_station(file, alignment, 1875.0) == pytest.approx(625.0)
|
||||||
|
|
||||||
|
|
||||||
|
test_distance_along_from_station()
|
||||||
|
test_distance_along_from_station_with_station_equations()
|
||||||
|
|||||||
Reference in New Issue
Block a user