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