Polish tool.Model + tool.Pset + add tool.Slab service

tool.Model gains:

* get_pipe_segment_props / get_duct_segment_props — typed prop accessors
  for the MEP-segment edit lifecycle.
* resolve_active_props_for_edit — picks the right BIM*Properties to
  drive a parametric edit triad based on the active object's IFC class.
* mirror_parent_void_fillings_to_children — when an array parent has
  hosted fillings (door/window in a wall), replicate the same fill
  rels onto each array child. Uses tool.Array.get_parametric_propagation_
  targets so the propagation stays within the array family (the old
  get_all_element_occurrences over-propagated to standalone occurrences
  of the same type, which silently mutated unrelated arrays).
* unshare_opening_representation — fork a shared IfcShapeRepresentation
  so editing one opening doesn't mutate its array sibling.
* duplicate_ifc_objects gains a post-condition select-restore on the
  array parent so callers don't get a deselected parent for N>=2 arrays.

sync_object_ifc_position is kept as a thin delegate to
tool.Geometry.commit_placement_if_moved (the new home, added in C8) so
the 6 v0.8.0 callers in mep / product / system don't AttributeError;
PR4 migrates each caller and removes the delegate.

tool.Pset gains:

* upsert_pset — get-or-add-or-edit in one call.
* write_bbim_data — JSON-encode + write BBIM_* metadata in one call.

tool.Slab is new — slab-specific reads (active extrusion, axis
direction) used by the slab gizmos, pure-IFC, no PropertyGroup mutation.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-05-27 00:14:51 +02:00
parent 3483683cb4
commit db9d903650
4 changed files with 325 additions and 24 deletions
+1
View File
@@ -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
+220 -24
View File
@@ -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.<attr> != value``.
"""
obj = context.active_object
if not obj:
return None
props = props_getter(obj)
if not getattr(props, "is_editing", False):
return None
if subtype is not None:
attr, value = subtype
if getattr(props, attr, None) != value:
return None
return obj, props
@classmethod
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:
+30
View File
@@ -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_<Type> pset and write ``data`` as the IfcText-serialised
JSON ``Data`` property. Canonical writer for parametric-modifier pset state."""
data_text = tool.Ifc.get().createIfcText(json.dumps(data, default=list))
return cls.upsert_pset(element, pset_name, {"Data": data_text})
@classmethod
def get_pset_props(cls, obj: str, obj_type: tool.Ifc.OBJECT_TYPE) -> PsetProperties:
if obj_type == "Object":
+74
View File
@@ -0,0 +1,74 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Side-effect-free slab helpers — IFC reads for LAYER3 extrusions.
Exposes ``read_geometry``: a single live read of the parametric attributes
(extrusion depth and slope) that drive icon placement and dimension display
on a LAYER3 slab. Lives in ``tool/`` so bim-layer callers can stay
declarative they get a dict, not an IFC walk."""
from __future__ import annotations
from typing import TYPE_CHECKING, TypedDict
import ifcopenshell.util.unit
import bonsai.core.tool
import bonsai.tool as tool
if TYPE_CHECKING:
import bpy
class SlabGeometry(TypedDict):
depth: float
x_angle: float
class Slab(bonsai.core.tool.Slab):
@classmethod
def read_geometry(cls, obj: bpy.types.Object) -> SlabGeometry | None:
"""Live-read slab parametric geometry as a dict, or ``None`` if the
object is not a LAYER3 extruded slab.
Returned keys (all SI units): ``depth`` (extrusion thickness along the
slab's local Z), ``x_angle`` (slope in radians; zero for level slabs).
The slope is encoded in ``obj.matrix_world`` as a post-rotation, so
callers projecting world points into slab-local space via
``mw.inverted()`` will see a level frame whose Z runs along the slab
thickness ``x_angle`` is reported for callers that need the slope
as a scalar but is already applied by the placement."""
element = tool.Ifc.get_entity(obj)
if not element or not tool.Blender.Modifier.is_slab(element):
return None
representation = tool.Geometry.get_body_representation(element)
if not representation:
return None
extrusion = tool.Model.get_extrusion(representation)
if not extrusion:
return None
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
x_angle = tool.Model.get_existing_x_angle(extrusion)
return {
"depth": extrusion.Depth * unit_scale,
"x_angle": x_angle,
}