First Commit

This commit is contained in:
2026-05-31 10:17:09 +07:00
commit 17a9c69379
4547 changed files with 1170384 additions and 0 deletions
@@ -0,0 +1,96 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2022 Dion Moult <dion@thinkmoult.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/>.
"""Create geometric representations and assign them to elements
These functions support both the creation of arbitrary geometry as well as
geometry that follows parametric rules (e.g. layered geometry or profiled
geometry extrusions).
"""
from .. import wrap_usecases
from .add_axis_representation import add_axis_representation
from .add_topology_representation import add_topology_representation
from .add_boolean import add_boolean
from .clip_solid import clip_solid
from .clip_solid_bounded import clip_solid_bounded
from .add_door_representation import add_door_representation
from .add_footprint_representation import add_footprint_representation
from .add_mesh_representation import add_mesh_representation
from .add_profile_representation import add_profile_representation
from .add_railing_representation import add_railing_representation
try:
from .add_representation import add_representation
except (ModuleNotFoundError, ImportError):
# ImportError - in case if user has fake-bpy modules.
pass # Silently fail. This is Blender / Bonsai specific and on its way out.
from .add_shape_aspect import add_shape_aspect
from .add_slab_representation import add_slab_representation
from .add_wall_representation import add_wall_representation
from .add_window_representation import add_window_representation
from .assign_representation import assign_representation
from .connect_element import connect_element
from .connect_path import connect_path
from .connect_wall import connect_wall
from .create_2pt_wall import create_2pt_wall
from .disconnect_element import disconnect_element
from .disconnect_path import disconnect_path
from .edit_object_placement import edit_object_placement
from .map_representation import map_representation
from .copy_representation import copy_representation
from .regenerate_wall_representation import regenerate_wall_representation
from .remove_boolean import remove_boolean
from .remove_representation import remove_representation
from .unassign_representation import unassign_representation
from .validate_type import validate_type
wrap_usecases(__path__, __name__)
__all__ = [
"add_axis_representation",
"add_topology_representation",
"add_boolean",
"clip_solid",
"clip_solid_bounded",
"copy_representation",
"add_door_representation",
"add_footprint_representation",
"add_mesh_representation",
"add_profile_representation",
"add_railing_representation",
"add_representation",
"add_shape_aspect",
"add_slab_representation",
"add_wall_representation",
"add_window_representation",
"assign_representation",
"connect_element",
"connect_path",
"connect_wall",
"create_2pt_wall",
"disconnect_element",
"disconnect_path",
"edit_object_placement",
"map_representation",
"regenerate_wall_representation",
"remove_boolean",
"remove_representation",
"unassign_representation",
"validate_type",
]
@@ -0,0 +1,111 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2022 Dion Moult <dion@thinkmoult.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/>.
from typing import Any, Union
import ifcopenshell.util.unit
COORD = Union[tuple[float, float], tuple[float, float, float]]
def add_axis_representation(
file: ifcopenshell.file, context: ifcopenshell.entity_instance, axis: tuple[COORD, COORD]
) -> ifcopenshell.entity_instance:
"""Adds a new axis representation
Certain objects are typically "axis-based", such as walls, beams,
and columns. This means you can represent them abstractly by simply
drawing a single line either in 2D (such as for walls) or 3D (for beams
and columns). Humans can understand this axis-based representation as
being a simplification of a layered extrusion or a profile that is being
extruded along that axis and joined to other elements.
Using an axis-based representation makes it easy for users and computers
to analyse connectivity and spatial relationships, as well as makes it
easy to parametrically edit these objects by simply stretching the start
or end of the axis.
For now, only simple straight line axes are supported, represented by a
start and end coordinate. The order is important. For walls, the start
must be at the minimum local X ordinate, and the end at the maximum
local X ordinate. For beams and columns, the start is at the minimum
local Z ordinate, and the end of the maximum local Z ordinate. The first
coordinate is the "start" and the second coordinate is the "end". This
stat and end is then used to determine any parametric junctions with
other elements.
Using an axis-representation is optional, but highly recommended for
"standard" representations of walls, beams, columns, and other
structural members. A rule of thumb is that if you can draw it as a line
on paper, you can probably represent it using an axis.
:param context: The IfcGeometricRepresentationContext that the
representation is part of. This must be either a
Model/Axis/GRAPH_VIEW (3D) or Plan/Axis/GRAPH_VIEW (2D).
:param axis: The axis, as a list of two coordinates, the coordinates
being either a list of 2 or 3 float coordinates depending on whether
the axis is 2D or 3D.
:return: The newly created IfcShapeRepresentation entity
Example:
.. code:: python
context = ifcopenshell.util.representation.get_context(model, "Plan", "Axis", "GRAPH_VIEW")
axis = ifcopenshell.api.geometry.add_axis_representation(model,
context=context, axis=[(0.0, 0.0), (1.0, 0.0)])
"""
usecase = Usecase()
usecase.file = file
usecase.settings = {
"context": context,
"axis": axis or [],
}
return usecase.execute()
class Usecase:
file: ifcopenshell.file
settings: dict[str, Any]
def execute(self):
self.settings["unit_scale"] = ifcopenshell.util.unit.calculate_unit_scale(self.file)
is_2d = len(self.settings["axis"][0]) == 2
points = [self.convert_si_to_unit(p) for p in self.settings["axis"]]
if self.file.schema == "IFC2X3":
curve = self.file.createIfcPolyline([self.file.createIfcCartesianPoint(p) for p in points])
else:
if is_2d:
curve = self.file.createIfcIndexedPolyCurve(
self.file.createIfcCartesianPointList2D(points), None, False
)
else:
curve = self.file.createIfcIndexedPolyCurve(
self.file.createIfcCartesianPointList3D(points), None, False
)
return self.file.createIfcShapeRepresentation(
self.settings["context"],
self.settings["context"].ContextIdentifier,
"Curve2D" if is_2d else "Curve3D",
[curve],
)
def convert_si_to_unit(self, co):
if isinstance(co, (tuple, list)):
return [self.convert_si_to_unit(o) for o in co]
return co / self.settings["unit_scale"]
@@ -0,0 +1,114 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2022 Dion Moult <dion@thinkmoult.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/>.
from __future__ import annotations
from typing import Literal
import ifcopenshell.util.element
def add_boolean(
file: ifcopenshell.file,
first_item: ifcopenshell.entity_instance,
second_items: list[ifcopenshell.entity_instance],
operator: Literal["DIFFERENCE", "INTERSECTION", "UNION"] = "DIFFERENCE",
) -> list[ifcopenshell.entity_instance]:
"""Adds a boolean operation to two or more representation items
This function protects against recursive booleans.
After a boolean operation is made, since the items of
IfcShapeRepresentation may be modified, it is not guaranteed that the
RepresentationType is still valid. After performing all your booleans, it
is recommended to run :func:`ifcopenshell.api.geometry.validate_csg` to
ensure correctness.
:param first_item: The IfcBooleanOperand that the operation is performed upon
:param second_items: The IfcBooleanOperands that the operation will be
performed with, in the order given of the list.
:param operator: The type of boolean operation to perform
:return: A list of newly created IfcBooleanResult in the order of boolean
operations (based on the order of second items). If nothing was
created, the list will be empty.
"""
def is_operand(item):
return (
item.is_a("IfcBooleanResult")
or item.is_a("IfcCsgPrimitive3D")
or item.is_a("IfcHalfSpaceSolid")
or item.is_a("IfcSolidModel")
or item.is_a("IfcTessellatedFaceSet")
)
if not is_operand(first_item):
return []
original_first_item = first_item
second_items = [i for i in second_items if i != first_item and is_operand(i)]
while True:
is_part_of_boolean = False
for inverse in file.get_inverse(first_item):
if inverse.is_a("IfcBooleanResult"):
is_part_of_boolean = True
first_item = inverse
if inverse.FirstOperand == original_first_item and inverse.SecondOperand in second_items:
second_items.remove(inverse.SecondOperand)
elif inverse.SecondOperand == original_first_item and inverse.FirstOperand in second_items:
second_items.remove(inverse.FirstOperand)
break
if not is_part_of_boolean:
break
if not second_items:
return []
# Don't replace style or aspect relationships.
to_replace = set(
[i for i in file.get_inverse(first_item) if i.is_a("IfcShapeRepresentation") or i.is_a("IfcBooleanResult")]
)
first = first_item
booleans = []
for second_item in second_items:
if first.is_a("IfcTesselatedFaceSet"):
first.Closed = True # For now, trust the user to do the right thing.
if second_item.is_a("IfcTesselatedFaceSet"):
second_item.Closed = True # For now, trust the user to do the right thing.
if (
operator == "DIFFERENCE"
and second_item.is_a("IfcHalfSpaceSolid")
and (
first.is_a("IfcSweptAreaSolid")
or first.is_a("IfcSweptDiskSolid")
or first.is_a("IfcBooleanClippingResult")
)
):
first = file.create_entity("IfcBooleanClippingResult", operator, first, second_item)
else:
first = file.create_entity("IfcBooleanResult", operator, first, second_item)
booleans.append(first)
for inverse in to_replace:
ifcopenshell.util.element.replace_attribute(inverse, first_item, first)
return booleans
@@ -0,0 +1,679 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2023 @Andrej730
#
# 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/>.
from __future__ import annotations
import dataclasses
from math import cos, radians
from typing import Any, Literal, Optional, Union, get_args, overload
import numpy as np
import ifcopenshell.api.geometry
import ifcopenshell.util.unit
from ifcopenshell.api.geometry.add_window_representation import create_ifc_window
from ifcopenshell.util.shape_builder import ShapeBuilder, V
DOOR_TYPE = Literal[
"SINGLE_SWING_LEFT",
"SINGLE_SWING_RIGHT",
"DOUBLE_SWING_RIGHT",
"DOUBLE_SWING_LEFT",
"DOUBLE_DOOR_SINGLE_SWING",
"DOUBLE_DOOR_DOUBLE_SWING",
"SLIDING_TO_LEFT",
"SLIDING_TO_RIGHT",
"DOUBLE_DOOR_SLIDING",
]
SUPPORTED_DOOR_TYPES = get_args(DOOR_TYPE)
def mm(x: float) -> float:
"""mm to meters shortcut for readability"""
return x / 1000
def create_ifc_door_lining(
builder: ShapeBuilder, size: np.ndarray, thickness: Union[list[float], float], position: Optional[np.ndarray] = None
) -> ifcopenshell.entity_instance:
"""`thickness` of the profile is defined as list in the following order: `(SIDE, TOP)`
`thickness` can be also defined just as 1 float value.
"""
np_X, np_Y, np_Z = 0, 1, 2
np_XZ = [0, 2]
if not isinstance(thickness, list):
thickness = [thickness, thickness]
th_side, th_up = thickness
points = V(
[
(0.0, 0.0, 0.0),
(0.0, 0.0, size[np_Z]),
(size[np_X], 0.0, size[np_Z]),
(size[np_X], 0.0, 0.0),
(size[np_X] - th_side, 0.0, 0.0),
(size[np_X] - th_side, 0.0, size[np_Z] - th_up),
(th_side, 0.0, size[np_Z] - th_up),
(th_side, 0.0, 0.0),
]
)
points = points[:, np_XZ]
door_lining = builder.polyline(points, closed=True)
door_lining = builder.extrude(door_lining, size[np_Y], **builder.extrude_kwargs("Y"))
if position is None:
position = np.zeros(3)
builder.translate(door_lining, position)
return door_lining
def create_ifc_box(
builder: ShapeBuilder, size: np.ndarray, position: Optional[np.ndarray] = None
) -> ifcopenshell.entity_instance:
np_Z, np_XY = 2, slice(2)
rect = builder.rectangle(size[np_XY])
if position is None:
position = np.zeros(3)
box = builder.extrude(rect, size[np_Z], position=position, extrusion_vector=(0, 0, 1))
return box
# we use dataclass as we need default values for arguments
# it's okay to use slots since we don't need dynamic attributes
@dataclasses.dataclass(slots=True)
class DoorLiningProperties:
LiningDepth: Optional[float] = None
"""Optional, defaults to 50mm."""
LiningThickness: Optional[float] = None
"""Optional, defaults to 50mm."""
LiningOffset: Optional[float] = None
"""Offset from the outer side of the wall (by Y-axis). Optional, defaults to 0.0."""
LiningToPanelOffsetX: Optional[float] = None
"""Offset from the wall. Optional, defaults to 25mm."""
LiningToPanelOffsetY: Optional[float] = None
"""Offset from the X-axis (unlike windows). Optional, defaults to 25mm."""
TransomThickness: Optional[float] = None
"""Vertical distance between door and window panels. Optional, defaults to 0.0."""
TransomOffset: Optional[float] = None
"""Distance from the bottom door opening
to the beginning of the transom
unlike windows TransomOffset which goes to the center of the transom.
Optional, defaults 1.525m."""
ShapeAspectStyle: None = None
"""Optional. Deprecated argument."""
CasingDepth: Optional[float] = None
"""Casing cover wall faces around the opening
on the left, right and upper sides
Casing should be either on both sides of the wall or no casing
If `LiningOffset` is present then therefore casing is not possible on outer wall
therefore there will be no casing on inner wall either. Optional, defaults to 5mm."""
CasingThickness: Optional[float] = None
"""Casing thickness by Z-axis. Optional, defaults to 75mm."""
ThresholdDepth: Optional[float] = None
"""Threshold covers the bottom side of the opening. Optional, defaults to 100mm."""
ThresholdThickness: Optional[float] = None
"""Theshold thickness by Z-axis. Optional, defaults to 25mm."""
ThresholdOffset: Optional[float] = None
"""Threshold offset by Y-axis. Optional, defaults to 0.0."""
def initialize_properties(self, unit_scale: float) -> None:
# in meters
# fmt: off
default_values: dict[str, float] = dict(
LiningDepth = mm(50),
LiningThickness = mm(50),
LiningOffset = 0.0,
LiningToPanelOffsetX = mm(25),
LiningToPanelOffsetY = mm(25),
TransomThickness = 0.0,
TransomOffset = mm(1525),
CasingDepth = mm(5),
CasingThickness = mm(75),
ThresholdDepth = mm(100),
ThresholdThickness = mm(25),
ThresholdOffset = 0.0,
)
# fmt: on
si_conversion = 1 / unit_scale
for attr, default_value in default_values.items():
if getattr(self, attr) is not None:
continue
setattr(self, attr, default_value * si_conversion)
@dataclasses.dataclass(slots=True)
class DoorPanelProperties:
PanelDepth: Optional[float] = None
"""Frame thickness by Y axis. Optional, defaults to 35 mm."""
PanelWidth: float = 1.0
"""Ratio to the clear door opening. Optional, defaults to 1.0."""
FrameDepth: Optional[float] = None
"""Frame thickness by Y axis. Optional, defaults to 35 mm."""
FrameThickness: Optional[float] = None
"""Frame thickness by X axis. Optional, defaults to 35 mm."""
PanelPosition: None = None
"""Optional, value is never used"""
PanelOperation: None = None
"""Optional, value is never used.
Defines the basic ways to describe how door panels operate."""
ShapeAspectStyle: None = None
"""Optional. Deprecated argument."""
def initialize_properties(self, unit_scale: float) -> None:
# in meters
# fmt: off
default_values: dict[str, float] = dict(
PanelDepth = mm(35),
FrameDepth = mm(35),
FrameThickness = mm(35),
)
# fmt: on
si_conversion = 1 / unit_scale
for attr, default_value in default_values.items():
if getattr(self, attr) is not None:
continue
setattr(self, attr, default_value * si_conversion)
def add_door_representation(
file: ifcopenshell.file,
*, # keywords only as this API implementation is probably not final
context: ifcopenshell.entity_instance,
overall_height: Optional[float] = None,
overall_width: Optional[float] = None,
# door type
# http://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcDoorTypeOperationEnum.htm
operation_type: DOOR_TYPE = "SINGLE_SWING_LEFT",
lining_properties: Optional[Union[DoorLiningProperties, dict[str, Any]]] = None,
panel_properties: Optional[Union[DoorPanelProperties, dict[str, Any]]] = None,
part_of_product: Optional[ifcopenshell.entity_instance] = None,
unit_scale: Optional[float] = None,
) -> Union[ifcopenshell.entity_instance, None]:
"""Add a geometric representation for a door.
units in usecase_settings expected to be in ifc project units
:param context: IfcGeometricRepresentationContext for the representation.
:param overall_height: Overall door height. Defaults to 2m.
:param overall_width: Overall door width. Defaults to 0.9m.
:param operation_type: Type of the door. Defaults to SINGLE_SWING_LEFT.
:param lining_properties: DoorLiningProperties or a dictionary to create one.
See DoorLiningProperties description for details.
:param panel_properties: DoorPanelProperties or a dictionary to create one.
See DoorPanelProperties description for details.
:param unit_scale: The unit scale as calculated by
ifcopenshell.util.unit.calculate_unit_scale. If not provided, it
will be automatically calculated for you.
:return: IfcShapeRepresentation for a door.
"""
usecase = Usecase()
usecase.file = file
# http://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcDoor.htm
# http://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcDoorLiningProperties.htm
# http://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcDoorPanelProperties.htm
# define unit_scale first as it's going to be used setting default arguments
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file) if unit_scale is None else unit_scale
settings: dict[str, Any] = {"unit_scale": unit_scale}
if lining_properties is None:
lining_properties = DoorLiningProperties()
elif not isinstance(lining_properties, DoorLiningProperties):
lining_properties = DoorLiningProperties(**lining_properties)
lining_properties.initialize_properties(unit_scale)
lining_properties = dataclasses.asdict(lining_properties)
if panel_properties is None:
panel_properties = DoorPanelProperties()
elif not isinstance(panel_properties, DoorPanelProperties):
panel_properties = DoorPanelProperties(**panel_properties)
panel_properties.initialize_properties(unit_scale)
panel_properties = dataclasses.asdict(panel_properties)
settings.update(
{
"context": context,
"overall_height": overall_height if overall_height is not None else usecase.convert_si_to_unit(2.0),
"overall_width": overall_width if overall_width is not None else usecase.convert_si_to_unit(0.9),
"operation_type": operation_type,
"lining_properties": lining_properties,
"panel_properties": panel_properties,
"part_of_product": part_of_product,
}
)
usecase.settings = settings
return usecase.execute()
class Usecase:
file: ifcopenshell.file
settings: dict[str, Any]
def execute(self) -> Union[ifcopenshell.entity_instance, None]:
builder = ShapeBuilder(self.file)
np_X, np_Y, np_Z = 0, 1, 2
np_XY = slice(2)
np_YX = [1, 0]
overall_height: float = self.settings["overall_height"]
overall_width: float = self.settings["overall_width"]
door_type: DOOR_TYPE = self.settings["operation_type"]
double_swing_door = "DOUBLE_SWING" in door_type
double_door = "DOUBLE_DOOR" in door_type
sliding_door = "SLIDING" in door_type
if self.settings["context"].TargetView == "ELEVATION_VIEW":
rect = builder.rectangle((overall_width, 0, overall_height))
representation_evelevation = builder.get_representation(self.settings["context"], rect)
return representation_evelevation
panel_props = self.settings["panel_properties"]
lining_props = self.settings["lining_properties"]
# lining params
lining_depth: float = lining_props["LiningDepth"]
lining_thickness_default: float = lining_props["LiningThickness"]
lining_offset: float = lining_props["LiningOffset"]
lining_to_panel_offset_x: float = (
lining_props["LiningToPanelOffsetX"] if not sliding_door else lining_thickness_default
)
panel_depth: float = panel_props["PanelDepth"]
lining_to_panel_offset_y_full: float = (
lining_props["LiningToPanelOffsetY"] if not sliding_door else -panel_depth
)
transom_thickness: float = lining_props["TransomThickness"] / 2
transfom_offset: float = lining_props["TransomOffset"]
if transom_thickness == 0:
transfom_offset = 0
window_lining_height = overall_height - transfom_offset - transom_thickness
side_lining_thickness = lining_thickness_default
panel_lining_overlap_x = max(lining_thickness_default - lining_to_panel_offset_x, 0) if not sliding_door else 0
top_lining_thickness = transom_thickness or lining_thickness_default
panel_top_lining_overlap_x = max(top_lining_thickness - lining_to_panel_offset_x, 0) if not sliding_door else 0
door_opening_width = overall_width - lining_to_panel_offset_x * 2
if double_swing_door:
side_lining_thickness = side_lining_thickness - panel_lining_overlap_x
top_lining_thickness = top_lining_thickness - panel_top_lining_overlap_x
threshold_thickness: float = lining_props["ThresholdThickness"]
threshold_depth: float = lining_props["ThresholdDepth"]
threshold_offset: float = lining_props["ThresholdOffset"]
threshold_width = overall_width - side_lining_thickness * 2
casing_thickness: float = lining_props["CasingThickness"]
casing_depth: float = lining_props["CasingDepth"]
# panel params
panel_width: float = door_opening_width * panel_props["PanelWidth"]
frame_depth: float = panel_props["FrameDepth"]
frame_thickness: float = panel_props["FrameThickness"]
frame_height = window_lining_height - lining_to_panel_offset_x * 2
glass_thickness = self.convert_si_to_unit(0.01)
# handle dimensions (hardcoded)
handle_size = self.convert_si_to_unit(V(120, 40, 20) * 0.001)
handle_offset = self.convert_si_to_unit(V(60, 0, 1000) * 0.001) # to the handle center
handle_center_offset = V(handle_size[np_Y] / 2, 0, handle_size[np_Z]) / 2
slider_arrow_symbol_size = self.convert_si_to_unit(30 * 0.001)
if transfom_offset:
panel_height = transfom_offset + transom_thickness - lining_to_panel_offset_x - threshold_thickness
lining_height = transfom_offset + transom_thickness
else:
panel_height = overall_height - lining_to_panel_offset_x - threshold_thickness
lining_height = overall_height
# add lining
lining_size = V(overall_width, lining_depth, lining_height)
lining_thickness = [side_lining_thickness, top_lining_thickness]
def l_shape_check(lining_thickness: list[float]) -> bool:
return lining_to_panel_offset_y_full < lining_depth and any(
lining_to_panel_offset_x < th for th in lining_thickness
)
# create 2d representation
if self.settings["context"].TargetView == "PLAN_VIEW":
items_2d: list[ifcopenshell.entity_instance] = []
panel_size = V(panel_width, panel_depth)
if not sliding_door:
panel_position = V(lining_to_panel_offset_x, lining_depth)
else:
panel_position = V(lining_to_panel_offset_x, -panel_size[np_Y])
if self.settings["context"].ContextIdentifier == "Annotation":
# only sliding door has annotation representation
if not sliding_door:
return None
# arrow symbol
arrow_symbol: list[ifcopenshell.entity_instance] = []
arrow_offset = slider_arrow_symbol_size / cos(radians(15))
arrow_symbol.append(
builder.polyline(
points=((0.35 * panel_size[np_X], 0), (0.65 * panel_size[np_X], 0)),
)
)
arrow_symbol.append(
builder.polyline(
points=(
(slider_arrow_symbol_size, arrow_offset),
(0, 0),
(slider_arrow_symbol_size, -arrow_offset),
),
position_offset=(0.35 * panel_size[np_X], 0),
)
)
builder.translate(arrow_symbol, panel_position + (0, -arrow_offset * 1.5))
items_2d.extend(arrow_symbol)
representation_2d = builder.get_representation(self.settings["context"], items_2d, "Curve2D")
return representation_2d
door_items: list[ifcopenshell.entity_instance] = []
# create lining
if l_shape_check([side_lining_thickness]):
lining_points = [
(0, 0),
(0, lining_depth),
(lining_to_panel_offset_x, lining_depth),
(lining_to_panel_offset_x, lining_to_panel_offset_y_full),
(lining_thickness_default, lining_to_panel_offset_y_full),
(lining_thickness_default, 0),
]
lining = builder.polyline(lining_points, closed=True)
else:
lining = builder.rectangle((side_lining_thickness, lining_depth))
items_2d.append(lining)
items_2d.append(
builder.mirror(lining, mirror_axes=V(1, 0), mirror_point=V(overall_width / 2, 0), create_copy=True)
)
# TODO: make second swing lines dashed
def create_ifc_door_panel_2d(
panel_size: np.ndarray,
panel_position: np.ndarray,
door_swing_type: Literal["LEFT", "RIGHT"],
sliding: bool = False,
) -> list[ifcopenshell.entity_instance]:
if sliding:
return create_ifc_door_sliding_panel_2d(panel_size, panel_position, door_swing_type)
door_items: list[ifcopenshell.entity_instance] = []
panel_size = panel_size[np_YX]
# create semi-semi-circle
if double_swing_door:
trim_points_mask = (3, 1)
second_swing_line = builder.polyline(
points=(
(0, 0),
(0, -panel_size[np_Y]),
(panel_size[np_X], -panel_size[np_Y]),
)
)
door_items.append(second_swing_line)
else:
trim_points_mask = (0, 1)
semicircle = builder.create_ellipse_curve(
panel_size[np_Y] - panel_size[np_X],
panel_size[np_Y],
trim_points_mask=trim_points_mask,
position=(panel_size[np_X], 0),
)
door_items.append(semicircle)
# create door
door = builder.rectangle(panel_size)
door_items.append(door)
builder.translate(door_items, panel_position)
if door_swing_type == "RIGHT":
mirror_point = panel_position + (panel_size[np_Y] / 2, 0)
builder.mirror(door_items, mirror_axes=(1, 0), mirror_point=mirror_point)
return door_items
def create_ifc_door_sliding_panel_2d(
panel_size: np.ndarray, panel_position: np.ndarray, door_swing_type: Literal["LEFT", "RIGHT"]
) -> list[ifcopenshell.entity_instance]:
door = builder.rectangle(panel_size, position=panel_position - (panel_size[np_X] * 0.5, 0))
if door_swing_type == "RIGHT":
mirror_point = panel_position + (panel_size[np_X] / 2, 0)
builder.mirror(door, mirror_axes=(1, 0), mirror_point=mirror_point)
return [door]
door_items: list[ifcopenshell.entity_instance] = []
if double_door:
panel_size[np_X] = panel_size[np_X] / 2
door_items.extend(create_ifc_door_panel_2d(panel_size, panel_position, "LEFT", sliding_door))
mirror_point = panel_position + V(door_opening_width / 2, 0)
door_items.extend(
builder.mirror(door_items, mirror_axes=(1, 0), mirror_point=mirror_point, create_copy=True)
)
else:
door_swing_type = "LEFT" if door_type.endswith("LEFT") else "RIGHT"
door_items.extend(create_ifc_door_panel_2d(panel_size, panel_position, door_swing_type, sliding_door))
items_2d.extend(door_items)
builder.translate(items_2d, (0, lining_offset))
representation_2d = builder.get_representation(self.settings["context"], items_2d)
return representation_2d
lining_items: list[ifcopenshell.entity_instance] = []
main_lining_size = lining_size
# need to check offsets to decide whether lining should be rectangle
# or L shaped
if l_shape_check(lining_thickness):
main_lining_size = lining_size.copy()
main_lining_size[np_Y] = lining_to_panel_offset_y_full
second_lining_size = lining_size.copy()
second_lining_size[np_Y] = lining_size[np_Y] - lining_to_panel_offset_y_full
second_lining_position = V(0, lining_to_panel_offset_y_full, 0)
second_lining_thickness = [min(th, lining_to_panel_offset_x) for th in lining_thickness]
second_lining = create_ifc_door_lining(
builder, second_lining_size, second_lining_thickness, second_lining_position
)
lining_items.append(second_lining)
main_lining = create_ifc_door_lining(builder, main_lining_size, lining_thickness)
lining_items.append(main_lining)
# add threshold
threshold_items: list[ifcopenshell.entity_instance]
if not threshold_thickness:
threshold_items = []
else:
threshold_size = V(threshold_width, threshold_depth, threshold_thickness)
threshold_position = V(side_lining_thickness, threshold_offset, 0)
threshold_items = [create_ifc_box(builder, threshold_size, threshold_position)]
# add casings
casing_items: list[ifcopenshell.entity_instance] = []
if not lining_offset and casing_thickness:
casing_wall_overlap = max(casing_thickness - lining_thickness_default, 0)
inner_casing_thickness = [
casing_thickness - panel_lining_overlap_x,
casing_thickness - panel_top_lining_overlap_x,
]
outer_casing_thickness = inner_casing_thickness.copy() if double_swing_door else casing_thickness
casing_size = V(overall_width + casing_wall_overlap * 2, casing_depth, overall_height + casing_wall_overlap)
casing_position = V(-casing_wall_overlap, -casing_depth, 0)
outer_casing = create_ifc_door_lining(builder, casing_size, outer_casing_thickness, casing_position)
casing_items.append(outer_casing)
inner_casing_position = V(-casing_wall_overlap, lining_depth, 0)
inner_casing = create_ifc_door_lining(builder, casing_size, inner_casing_thickness, inner_casing_position)
casing_items.append(inner_casing)
def create_ifc_door_panel(
panel_size: np.ndarray, panel_position: np.ndarray, door_swing_type: Literal["LEFT", "RIGHT"]
) -> list[ifcopenshell.entity_instance]:
door_items: list[ifcopenshell.entity_instance] = []
# add door panel
door_items.append(create_ifc_box(builder, panel_size, panel_position))
# add door handle
handle_points = [
(0, 0),
(0, -handle_size[np_Y]),
(handle_size[np_X], -handle_size[np_Y]),
(handle_size[np_X], -handle_size[np_Y] / 2),
(handle_size[np_Y] / 2, -handle_size[np_Y] / 2),
(handle_size[np_Y] / 2, 0),
]
handle_polyline = builder.polyline(handle_points, closed=True)
handle_position = panel_position + handle_offset - handle_center_offset
door_handle = builder.extrude(handle_polyline, handle_size[np_Z], position=handle_position)
door_items.append(door_handle)
if door_swing_type == "LEFT":
builder.mirror(
door_handle, mirror_axes=(1, 0), mirror_point=panel_position[np_XY] + (panel_size[np_X] / 2, 0)
)
door_handle_mirrored = builder.mirror(
door_handle,
mirror_axes=(0, 1),
mirror_point=handle_position[np_XY] + (0, panel_size[np_Y] / 2),
create_copy=True,
)
door_items.append(door_handle_mirrored)
return door_items
door_items: list[ifcopenshell.entity_instance] = []
panel_size = V(panel_width, panel_depth, panel_height)
panel_position = V(lining_to_panel_offset_x, lining_to_panel_offset_y_full, threshold_thickness)
if double_door:
# keeping a little space between doors for readibility
double_door_offset = self.convert_si_to_unit(0.001)
panel_size[np_X] = panel_size[np_X] / 2 - double_door_offset
door_items.extend(create_ifc_door_panel(panel_size, panel_position, "LEFT"))
mirror_point = panel_position + V(door_opening_width / 2, 0, 0)
door_items.extend(
builder.mirror(door_items, mirror_axes=(1, 0), mirror_point=mirror_point[np_XY], create_copy=True)
)
else:
door_swing_type = "LEFT" if door_type.endswith("LEFT") else "RIGHT"
door_items.extend(create_ifc_door_panel(panel_size, panel_position, door_swing_type))
# add on top window
if not transom_thickness:
window_lining_items = []
frame_items = []
glass_items = []
else:
window_lining_thickness = [
side_lining_thickness,
lining_thickness_default,
side_lining_thickness,
transom_thickness,
]
window_lining_size = V(overall_width, lining_depth, window_lining_height)
window_position = V(0, 0, overall_height - window_lining_height)
frame_size = V(door_opening_width, frame_depth, frame_height)
current_window_items = create_ifc_window(
builder,
window_lining_size,
window_lining_thickness,
lining_to_panel_offset_x,
lining_to_panel_offset_y_full,
frame_size,
frame_thickness,
glass_thickness,
window_position,
)
window_lining_items = current_window_items["Lining"]
frame_items = current_window_items["Framing"]
glass_items = current_window_items["Glazing"]
lining_offset_items = lining_items + door_items + window_lining_items + frame_items + glass_items
builder.translate(lining_offset_items, (0, lining_offset, 0))
output_items = lining_offset_items + threshold_items + casing_items
representation = builder.get_representation(self.settings["context"], output_items)
if self.settings["part_of_product"]:
ifcopenshell.api.geometry.add_shape_aspect(
self.file,
"Lining",
items=lining_items + window_lining_items + threshold_items + casing_items,
representation=representation,
part_of_product=self.settings["part_of_product"],
)
ifcopenshell.api.geometry.add_shape_aspect(
self.file,
"Framing",
items=door_items + frame_items,
representation=representation,
part_of_product=self.settings["part_of_product"],
)
if glass_items:
ifcopenshell.api.geometry.add_shape_aspect(
self.file,
"Glazing",
items=glass_items,
representation=representation,
part_of_product=self.settings["part_of_product"],
)
return representation
@overload
def convert_si_to_unit(self, value: float) -> float: ...
@overload
def convert_si_to_unit(self, value: np.ndarray) -> np.ndarray: ...
def convert_si_to_unit(self, value: Union[float, np.ndarray]) -> Union[float, np.ndarray]:
si_conversion = 1 / self.settings["unit_scale"]
return value * si_conversion
@@ -0,0 +1,34 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2022 Dion Moult <dion@thinkmoult.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.util.unit
def add_footprint_representation(
file,
# IfcGeometricRepresentationContext
context: ifcopenshell.entity_instance,
# A list of IFC curves to include in the curve set
curves: list[ifcopenshell.entity_instance],
) -> ifcopenshell.entity_instance:
return file.createIfcShapeRepresentation(
context,
context.ContextIdentifier,
"GeometricCurveSet",
[file.createIfcGeometricCurveSet(curves)],
)
@@ -0,0 +1,134 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2022 Dion Moult <dion@thinkmoult.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/>.
from typing import Optional, TypeVar
import numpy as np
import numpy.typing as npt
import ifcopenshell.util.unit
from ifcopenshell.util.shape_builder import SequenceOfVectors, ShapeBuilder, VectorType
T = TypeVar("T")
COORD_3D = tuple[float, float, float]
def add_mesh_representation(
file: ifcopenshell.file,
context: ifcopenshell.entity_instance,
vertices: list[SequenceOfVectors],
edges: Optional[list[list[tuple[int, int]]]] = None,
# Optional faces is not supported currently.
faces: list[list[list[int]]] = None,
coordinate_offset: Optional[VectorType] = None,
unit_scale: Optional[float] = None,
force_faceted_brep: bool = False,
) -> ifcopenshell.entity_instance:
"""
Add a mesh representation.
Vertices, edges, and faces are given in the form of: ``[item1, item2, item3, ...]``.
Each ``itemN`` is a sublist representing data for a separate IfcRepresentationItem to add.
You can provide either ``edges`` or ``faces``, no need to provide both.
But currently ``edges`` argument is not supported.
:param context: The IfcGeometricRepresentationContext for the representation.
:param vertices: A list of coordinates.
where ``itemN = [(0., 0., 0.), (1., 1., 1.), (x, y, z), ...]``
:param edges: A list of edges, represented by vertex index pairs
where ``itemN = [(0, 1), (1, 2), (v1, v2), ...]``
:param faces: A list of polygons, represented by vertex indices.
where ``itemN = [(0, 1, 2), (5, 4, 2, 3), (v1, v2, v3, ... vN), ...]``
:param coordinate_offset: Optionally apply a vector offset to all coordinates.
In project units.
:param unit_scale: Scale factor for ``vertices`` units.
If omitted, it is assumed that ``vertices`` are in SI units.
If other value is provided ``vertices`` coords will be divided by ``unit_scale``.
:param force_faceted_brep: Force using IfcFacetedBreps instead of IfcPolygonalFaceSets.
:return: IfcShapeRepresentation.
"""
# TODO: Support edges without faces.
assert faces is not None, f"Currently 'faces' argument is not optional."
assert len(faces) != 0
assert len(vertices) != 0
assert len(faces) == len(vertices)
usecase = Usecase()
usecase.file = file
# Process arguments.
if unit_scale is None:
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file)
np_vertices = np.array(vertices, dtype=np.float64) * (1 / unit_scale)
if coordinate_offset is not None:
np_vertices += coordinate_offset
return usecase.execute(context, np_vertices, faces, force_faceted_brep)
class Usecase:
file: ifcopenshell.file
vertices: npt.NDArray[np.float64]
"""In project units."""
def execute(
self,
context: ifcopenshell.entity_instance,
vertices: npt.NDArray[np.float64],
faces: list[list[list[int]]],
force_faceted_brep: bool,
) -> ifcopenshell.entity_instance:
self.builder = ShapeBuilder(self.file)
self.vertices = vertices
self.faces = faces
self.context = context
self.force_faceted_brep = force_faceted_brep
return self.create_mesh_representation()
def create_mesh_representation(self) -> ifcopenshell.entity_instance:
if self.force_faceted_brep or self.file.schema == "IFC2X3":
return self.create_faceted_brep()
return self.create_polygonal_face_set()
def create_faceted_brep(self) -> ifcopenshell.entity_instance:
items: list[ifcopenshell.entity_instance] = []
for i in range(0, len(self.vertices)):
items.append(self.builder.faceted_brep(self.vertices[i], self.faces[i]))
return self.file.create_entity(
"IfcShapeRepresentation",
self.context,
self.context.ContextIdentifier,
"Brep",
items,
)
def create_polygonal_face_set(self) -> ifcopenshell.entity_instance:
items: list[ifcopenshell.entity_instance] = []
for i in range(0, len(self.vertices)):
items.append(self.builder.polygonal_face_set(self.vertices[i], self.faces[i]))
return self.file.create_entity(
"IfcShapeRepresentation",
self.context,
self.context.ContextIdentifier,
"Tessellation",
items,
)
@@ -0,0 +1,224 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2022 Dion Moult <dion@thinkmoult.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/>.
from typing import Any, Literal, Optional, Union, get_args
import ifcopenshell.geom
import ifcopenshell.util.element
import ifcopenshell.util.shape
import ifcopenshell.util.unit
from ifcopenshell.util.data import Clipping
VECTOR_3D = tuple[float, float, float]
CardinalPointNumeric = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]
CardinalPointString = Literal[
"bottom left",
"bottom centre",
"bottom right",
"mid-depth left",
"mid-depth centre",
"mid-depth right",
"top left",
"top centre",
"top right",
"geometric centroid",
"bottom in line with the geometric centroid",
"left in line with the geometric centroid",
"right in line with the geometric centroid",
"top in line with the geometric centroid",
"shear centre",
"bottom in line with the shear centre",
"left in line with the shear centre",
"right in line with the shear centre",
"top in line with the shear centre",
]
CARDINAL_POINT_VALUES: tuple[CardinalPointString, ...] = get_args(CardinalPointString)
CardinalPoint = Union[CardinalPointNumeric, CardinalPointString]
def add_profile_representation(
file: ifcopenshell.file,
context: ifcopenshell.entity_instance,
profile: ifcopenshell.entity_instance,
depth: float = 1.0,
# TODO: None makes more sense as default value?
cardinal_point: Union[CardinalPoint, None] = 5,
clippings: Optional[list[Union[Clipping, dict[str, Any]]]] = None,
placement_zx_axes: tuple[Union[VECTOR_3D, None], Union[VECTOR_3D, None]] = (None, None),
) -> ifcopenshell.entity_instance:
"""Add profile representation.
:param context: The IfcGeometricRepresentationContext for the representation,
only Model/Body/MODEL_VIEW type of representations are currently supported.
:param profile: The IfcProfileDef to extrude.
:param depth: The depth of the extrusion in meters.
:param cardinal_point: The cardinal point of the profile.
:param clippings: A list of planes that define clipping half space solids.
Planes are defined either by Clipping objects
or by dictionaries of arguments for `Clipping.parse`.
:param placement_zx_axes: A tuple of two vectors that define the placement of the profile.
The first vector is the Z axis, the second vector is the X axis.
:return: IfcShapeRepresentation.
"""
usecase = Usecase()
usecase.file = file
clippings = clippings if clippings is not None else []
return usecase.execute(context, profile, depth, cardinal_point, clippings, placement_zx_axes)
class Usecase:
file: ifcopenshell.file
clippings: list[Clipping]
def execute(
self,
context: ifcopenshell.entity_instance,
profile: ifcopenshell.entity_instance,
depth: float,
cardinal_point: Union[CardinalPoint, None],
clippings: list[Union[Clipping, dict[str, Any]]],
placement_zx_axes: tuple[Union[VECTOR_3D, None], Union[VECTOR_3D, None]],
) -> ifcopenshell.entity_instance:
if isinstance(cardinal_point, int):
cardinal_point = CARDINAL_POINT_VALUES[cardinal_point - 1]
self.cardinal_point = cardinal_point
self.profile = profile
self.clippings = [Clipping.parse(c) for c in clippings]
self.depth = depth
self.placement_zx_axes = placement_zx_axes
self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(self.file)
return self.file.create_entity(
"IfcShapeRepresentation",
context,
context.ContextIdentifier,
"Clipping" if self.clippings else "SweptSolid",
[self.create_item()],
)
def create_item(self) -> ifcopenshell.entity_instance:
point = self.get_point()
placement = self.file.createIfcAxis2Placement3D(
point,
self.file.create_entity("IfcDirection", self.placement_zx_axes[0] or (0.0, 0.0, 1.0)),
self.file.create_entity("IfcDirection", self.placement_zx_axes[1] or (1.0, 0.0, 0.0)),
)
extrusion = self.file.create_entity(
"IfcExtrudedAreaSolid",
self.profile,
placement,
self.file.createIfcDirection((0.0, 0.0, 1.0)),
self.convert_si_to_unit(self.depth),
)
if self.clippings:
return self.apply_clippings(extrusion)
return extrusion
def apply_clippings(self, first_operand: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance:
while self.clippings:
clipping = self.clippings.pop()
if isinstance(clipping, ifcopenshell.entity_instance):
new = ifcopenshell.util.element.copy(self.file, clipping)
new.FirstOperand = first_operand
first_operand = new
else: # Clipping
first_operand = clipping.apply(self.file, first_operand, self.unit_scale)
return first_operand
def convert_si_to_unit(self, co: float) -> float:
return co / self.unit_scale
def get_point(self) -> ifcopenshell.entity_instance:
if not self.cardinal_point:
return self.file.createIfcCartesianPoint((0.0, 0.0, 0.0))
elif self.cardinal_point == "bottom left":
return self.file.createIfcCartesianPoint((-self.get_x() / 2, self.get_y() / 2, 0.0))
elif self.cardinal_point == "bottom centre":
return self.file.createIfcCartesianPoint((0.0, self.get_y() / 2, 0.0))
elif self.cardinal_point == "bottom right":
return self.file.createIfcCartesianPoint((self.get_x() / 2, self.get_y() / 2, 0.0))
elif self.cardinal_point == "mid-depth left":
return self.file.createIfcCartesianPoint((-self.get_x() / 2, 0.0, 0.0))
elif self.cardinal_point == "mid-depth centre":
return self.file.createIfcCartesianPoint((0.0, 0.0, 0.0))
elif self.cardinal_point == "mid-depth right":
return self.file.createIfcCartesianPoint((self.get_x() / 2, 0.0, 0.0))
elif self.cardinal_point == "top left":
return self.file.createIfcCartesianPoint((-self.get_x() / 2, -self.get_y() / 2, 0.0))
elif self.cardinal_point == "top centre":
return self.file.createIfcCartesianPoint((0.0, -self.get_y() / 2, 0.0))
elif self.cardinal_point == "top right":
return self.file.createIfcCartesianPoint((self.get_x() / 2, -self.get_y() / 2, 0.0))
# TODO other cardinal points
return self.file.createIfcCartesianPoint((0.0, 0.0, 0.0))
def get_x(self) -> float:
if self.profile.is_a("IfcAsymmetricIShapeProfileDef"):
return self.profile.OverallWidth
elif self.profile.is_a("IfcCShapeProfileDef"):
return self.profile.Width
elif self.profile.is_a("IfcCircleProfileDef"):
return self.profile.Radius * 2
elif self.profile.is_a("IfcEllipseProfileDef"):
return self.profile.SemiAxis1 * 2
elif self.profile.is_a("IfcIShapeProfileDef"):
return self.profile.OverallWidth
elif self.profile.is_a("IfcLShapeProfileDef"):
return self.profile.Width
elif self.profile.is_a("IfcRectangleProfileDef"):
return self.profile.XDim
elif self.profile.is_a("IfcTShapeProfileDef"):
return self.profile.FlangeWidth
elif self.profile.is_a("IfcUShapeProfileDef"):
return self.profile.FlangeWidth
elif self.profile.is_a("IfcZShapeProfileDef"):
return (self.profile.FlangeWidth * 2) - self.profile.WebThickness
else:
settings = ifcopenshell.geom.settings()
settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS)
shape = ifcopenshell.geom.create_shape(settings, self.profile)
return self.convert_si_to_unit(ifcopenshell.util.shape.get_x(shape))
return 0.0
def get_y(self) -> float:
if self.profile.is_a("IfcAsymmetricIShapeProfileDef"):
return self.profile.OverallDepth
elif self.profile.is_a("IfcCShapeProfileDef"):
return self.profile.Depth
elif self.profile.is_a("IfcCircleProfileDef"):
return self.profile.Radius * 2
elif self.profile.is_a("IfcEllipseProfileDef"):
return self.profile.SemiAxis2 * 2
elif self.profile.is_a("IfcIShapeProfileDef"):
return self.profile.OverallDepth
elif self.profile.is_a("IfcLShapeProfileDef"):
return self.profile.Depth
elif self.profile.is_a("IfcRectangleProfileDef"):
return self.profile.YDim
elif self.profile.is_a("IfcTShapeProfileDef"):
return self.profile.Depth
elif self.profile.is_a("IfcUShapeProfileDef"):
return self.profile.Depth
elif self.profile.is_a("IfcZShapeProfileDef"):
return self.profile.Depth
else:
settings = ifcopenshell.geom.settings()
settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS)
shape = ifcopenshell.geom.create_shape(settings, self.profile)
return self.convert_si_to_unit(ifcopenshell.util.shape.get_y(shape))
return 0.0
@@ -0,0 +1,405 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2023 @Andrej730
#
# 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/>.
from math import cos, pi, radians, sin, tan
from typing import Any, Literal, Optional
import numpy as np
from typing_extensions import assert_never
import ifcopenshell.util.unit
from ifcopenshell.util.shape_builder import (
SequenceOfVectors,
ShapeBuilder,
V,
is_x,
np_angle,
np_angle_signed,
np_intersect_line_line,
np_lerp,
np_normal,
np_normalized,
np_to_3d,
)
def mm(x: float) -> float:
"""mm to meters shortcut for readability"""
return x / 1000
TERMINAL_TYPE = Literal[
"180",
"TO_END_POST",
"TO_WALL",
"TO_FLOOR",
"TO_END_POST_AND_FLOOR",
]
def add_railing_representation(
file: ifcopenshell.file,
*, # keywords only as this API implementation is probably not final
# IfcGeometricRepresentationContext
context: ifcopenshell.entity_instance,
railing_type: Literal["WALL_MOUNTED_HANDRAIL"] = "WALL_MOUNTED_HANDRAIL",
railing_path: SequenceOfVectors,
use_manual_supports: bool = False,
support_spacing: Optional[float] = None,
railing_diameter: Optional[float] = None,
clear_width: Optional[float] = None,
terminal_type: TERMINAL_TYPE = "180",
height: Optional[float] = None,
looped_path: bool = False,
unit_scale: Optional[float] = None,
) -> ifcopenshell.entity_instance:
"""
Units are expected to be in IFC project units.
:param context: IfcGeometricRepresentationContext for the representation.
:param railing_type: Type of the railing. Defaults to "WALL_MOUNTED_HANDRAIL".
:param railing_path: A list of points coordinates for the railing path,
coordinates are expected to be at the top of the railing, not at the center.
If not provided, default path [(0, 0, 1), (1, 0, 1), (2, 0, 1)] (in meters) will be used
:param use_manual_supports: If enabled, supports are added on every vertex on the edges of the railing path.
If disabled, supports are added automatically based on the support spacing. Default to False.
:param support_spacing: Distance between supports if automatic supports are used. Defaults to 1m.
:param railing_diameter: Railing diameter. Defaults to 50mm.
:param clear_width: Clear width between the railing and the wall. Defaults to 40mm.
:param terminal_type: type of the cap. Defaults to "180".
:param height: defaults to 1m
:param looped_path: Whether to end the railing on the first point of `railing_path`. Defaults to False.
:param unit_scale: The unit scale as calculated by
ifcopenshell.util.unit.calculate_unit_scale. If not provided, it
will be automatically calculated for you.
:return: IfcShapeRepresentation for a railing.
"""
usecase = Usecase()
usecase.file = file
# define unit_scale first as it's going to be used setting default arguments
settings: dict[str, Any] = {
"unit_scale": ifcopenshell.util.unit.calculate_unit_scale(file) if unit_scale is None else unit_scale,
}
settings.update(
{
"context": context,
"railing_type": railing_path,
"railing_path": (
railing_path
if railing_path is not None
else usecase.path_si_to_units(V([(0, 0, 1), (1, 0, 1), (2, 0, 1)]))
),
"use_manual_supports": use_manual_supports,
"support_spacing": support_spacing if support_spacing is not None else usecase.convert_si_to_unit(mm(1000)),
"railing_diameter": (
railing_diameter if railing_diameter is not None else usecase.convert_si_to_unit(mm(50))
),
"clear_width": clear_width if clear_width is not None else usecase.convert_si_to_unit(mm(40)),
"terminal_type": terminal_type,
"height": height if height is not None else usecase.convert_si_to_unit(mm(1000)),
"looped_path": looped_path,
}
)
usecase.settings = settings
if railing_type != "WALL_MOUNTED_HANDRAIL":
raise Exception('Only "WALL_MOUNTED_HANDRAIL" railing_type is supported at the moment.')
return usecase.execute()
class Usecase:
file: ifcopenshell.file
settings: dict[str, Any]
def execute(self):
arc_points: list[np.ndarray] = []
items_3d: list[ifcopenshell.entity_instance] = []
builder = ShapeBuilder(self.file)
z_down = V(0, 0, -1)
# measurements
# from settings
use_manual_supports: bool = self.settings["use_manual_supports"]
railing_radius: float = self.settings["railing_diameter"] / 2
support_spacing: float = self.settings["support_spacing"]
clear_width: float = self.settings["clear_width"]
# for calculations purposes we use height without railing radius
height: float = self.settings["height"] - railing_radius
cap_type: TERMINAL_TYPE = self.settings["terminal_type"]
ifc_context: ifcopenshell.entity_instance = self.settings["context"]
railing_coords: SequenceOfVectors = self.settings["railing_path"]
looped_path: bool = self.settings["looped_path"]
railing_coords: np.ndarray
railing_coords = np.subtract(railing_coords, z_down * railing_radius)
# constant
terminal_radius = self.convert_si_to_unit(mm(150))
railing_fillet_radius = self.convert_si_to_unit(mm(100))
support_length = clear_width + railing_radius
support_radius = self.convert_si_to_unit(mm(10))
support_disk_radius = railing_radius
support_disk_depth = self.convert_si_to_unit(mm(20))
# util functions
def collinear(d0: np.ndarray, d1: np.ndarray) -> bool:
return is_x(np_angle(d0, d1), 0)
np_Z = 2
np_XY = slice(2)
np_YX = [1, 0]
def add_support_on_point(
point: np.ndarray, railing_direction: np.ndarray
) -> tuple[ifcopenshell.entity_instance, ...]:
"""create a support arc and a disk based on the position and direction of the railing"""
ortho_dir = railing_direction[np_YX] * (1, -1)
ortho_dir = np_normalized(np_to_3d(ortho_dir))
arc_center = point + ortho_dir * support_length
support_points: list[np.ndarray] = [
point,
arc_center - ortho_dir * support_length * cos(pi / 4) + z_down * support_length * sin(pi / 4),
arc_center + z_down * support_length,
]
polyline = builder.polyline(support_points, closed=False, arc_points=(1,))
solid = builder.create_swept_disk_solid(polyline, support_radius)
support_disk_circle = builder.circle(radius=support_disk_radius)
angle = np_angle_signed((0, 1), ortho_dir[np_XY])
y_extrusion_kwargs = builder.rotate_extrusion_kwargs_by_z(builder.extrude_kwargs("Y"), angle)
support_disk = builder.extrude(
support_disk_circle, support_disk_depth, position=support_points[-1], **y_extrusion_kwargs
)
return (solid, support_disk)
def get_fillet_points(v0: np.ndarray, v1: np.ndarray, v2: np.ndarray, radius: float) -> list[np.ndarray]:
"""get fillet points between edges v0v1 and v1v2"""
dir1 = np_normalized(v0 - v1)
dir2 = np_normalized(v2 - v1)
edge_angle = np_angle(dir1, dir2)
slide_distance = radius / tan(edge_angle / 2)
fillet_v1co = v1 + (dir1 * slide_distance)
fillet_v2co = v1 + (dir2 * slide_distance)
normal = np_normal([v0, v1, v2])
center = np_intersect_line_line(
fillet_v1co,
fillet_v1co + np.cross(normal, dir1),
fillet_v2co,
fillet_v2co + np.cross(normal, dir2),
)[0]
dir_ = np_normalized(np_lerp(fillet_v1co, fillet_v2co, 0.5) - center)
midpointco = center + dir_ * radius
return [fillet_v1co, midpointco, fillet_v2co]
def add_arcs_on_turnings_points(base_points: np.ndarray) -> np.ndarray:
"""add 3 point fillet arcs on turning points of the railing path"""
if len(base_points) < 3:
return base_points
# looking for turning points by checking non-collinear edges
output_points: list[np.ndarray] = list(base_points[:1])
prev_dir = np_normalized(base_points[1] - base_points[0])
i = 1
while i < len(base_points) - 1:
cur_dir = np_normalized(base_points[i + 1] - base_points[i])
if collinear(cur_dir, prev_dir):
output_points.append(base_points[i])
else:
fillet_points = get_fillet_points(
base_points[i - 1], base_points[i], base_points[i + 1], railing_fillet_radius
)
output_points.extend(fillet_points)
arc_points.append(fillet_points[1])
prev_dir = cur_dir
i = i + 1
if looped_path:
output_points[0] = output_points[-1]
else:
output_points.append(base_points[-1])
return V(output_points)
def create_supports_items(
railing_coords: np.ndarray, manual_supports: bool = False
) -> list[ifcopenshell.entity_instance]:
"""create supports items based on the railing coordinates"""
supports_items: list[ifcopenshell.entity_instance] = []
# simplified_coords is a list of points that form non-collinear edges
simplified_coords: list[np.ndarray] = [railing_coords[0]]
prev_dir = np_normalized(railing_coords[1] - railing_coords[0])
# iterating over each edge of the railing path
for i in range(1, len(railing_coords) - 1):
cur_dir = np_normalized(railing_coords[i + 1] - railing_coords[i])
if not collinear(cur_dir, prev_dir):
simplified_coords.append(railing_coords[i])
prev_dir = cur_dir
# for manual supports each vertex on the railing path edge
# will be a point for a support
elif manual_supports:
supports_items.extend(add_support_on_point(point=railing_coords[i], railing_direction=cur_dir))
simplified_coords.append(railing_coords[-1])
if manual_supports:
return supports_items
# create automatic supports based on the support spacing
for i in range(0, len(simplified_coords) - 1):
v0, v1 = simplified_coords[i : i + 2]
edge = v1 - v0
length: float = np.linalg.norm(edge)
edge_dir = np_normalized(edge)
n_supports, support_offset = divmod(length, support_spacing)
n_supports = int(n_supports) + 1
support_offset /= 2
start_position = v0 + support_offset * edge_dir
for support_i in range(n_supports):
support_position = start_position + support_i * support_spacing * edge_dir
supports_items.extend(add_support_on_point(point=support_position, railing_direction=edge))
return supports_items
def add_cap(railing_coords: np.ndarray, arc_points: list[np.ndarray], start: bool = False):
"""add handrail terminal cap"""
railing_coords_for_cap = railing_coords[::-1] if start else railing_coords
arc_points = arc_points[::-1] if start else arc_points
start_point: np.ndarray = railing_coords_for_cap[-1]
cap_dir = railing_coords_for_cap[-1] - railing_coords_for_cap[-2]
cap_dir = np_normalized(cap_dir)
ortho_dir = np_to_3d(cap_dir[np_YX] * (1, -1))
ortho_dir = np_normalized(ortho_dir)
local_z_down = np.cross(cap_dir, ortho_dir)
if start:
ortho_dir = -ortho_dir
arc_middle_point_cos = sin(radians(45))
if cap_type in ("180", "TO_END_POST"):
arc_point = start_point + cap_dir * terminal_radius + terminal_radius * local_z_down
arc_points.append(arc_point)
cap_coords = [arc_point, start_point + terminal_radius * 2 * local_z_down]
if cap_type == "TO_END_POST":
end_point = railing_coords_for_cap[-2].copy()
end_point[np_Z] -= terminal_radius * 2
cap_coords.append(end_point)
elif cap_type == "TO_WALL":
arc_point = (
start_point
+ cap_dir * clear_width * arc_middle_point_cos
+ ortho_dir * clear_width * (1 - arc_middle_point_cos)
)
arc_points.append(arc_point)
cap_coords = [arc_point, start_point + ortho_dir * clear_width + cap_dir * clear_width]
elif cap_type == "TO_FLOOR":
arc_point = (
start_point
+ cap_dir * terminal_radius * arc_middle_point_cos
+ z_down * terminal_radius * (1 - arc_middle_point_cos)
)
arc_points.append(arc_point)
arc_end = start_point + cap_dir * terminal_radius + terminal_radius * z_down
cap_coords = [
arc_point,
arc_end,
arc_end + z_down * (height - terminal_radius),
]
elif cap_type == "TO_END_POST_AND_FLOOR":
first_arc_end = start_point + cap_dir * terminal_radius + terminal_radius * local_z_down
first_arc_coords = get_fillet_points(
start_point, start_point + cap_dir * terminal_radius, first_arc_end, terminal_radius
)
arc_points.append(first_arc_coords[1])
end_point = railing_coords_for_cap[-2].copy()
end_point[np_Z] -= height
second_arc_coords = get_fillet_points(
first_arc_end, first_arc_end + local_z_down * terminal_radius, end_point, terminal_radius
)
arc_points.append(second_arc_coords[1])
cap_coords = [start_point] + first_arc_coords + second_arc_coords + [end_point]
else:
assert_never(cap_type)
railing_coords = np.vstack((railing_coords_for_cap, cap_coords))
if start:
railing_coords = railing_coords[::-1]
arc_points = arc_points[::-1]
return railing_coords, arc_points
# need to add first two points to the path
# to create the turning arcs and supports on the last segment of the loop
if looped_path:
railing_coords = np.vstack((railing_coords, railing_coords[:2]))
items_3d.extend(create_supports_items(railing_coords, manual_supports=use_manual_supports))
railing_coords = add_arcs_on_turnings_points(railing_coords)
if not looped_path and cap_type != "NONE":
railing_coords, arc_points = add_cap(railing_coords, arc_points, start=True)
railing_coords, arc_points = add_cap(railing_coords, arc_points, start=False)
def get_arc_indices(points: np.ndarray, arc_points: list[np.ndarray]) -> list[int]:
points_ = points.copy()
arc_indices = []
i_base = 0
for arc_point in arc_points:
for i, point in enumerate(points_):
if np.allclose(arc_point, point):
current_index = i + i_base
arc_indices.append(current_index)
i_base = current_index + 1
break
else:
raise Exception(
f"Arc point '{arc_point}' is not present in points:\n{points_}\nFull points data:\n{points}"
)
points_ = points_[i + 1 :]
return arc_indices
railing_path = builder.polyline(
railing_coords,
closed=False,
arc_points=get_arc_indices(railing_coords, arc_points),
)
railing_solid = builder.create_swept_disk_solid(railing_path, railing_radius)
items_3d.append(railing_solid)
representation = builder.get_representation(ifc_context, items=items_3d)
return representation
def convert_si_to_unit(self, value: float) -> float:
return value / self.settings["unit_scale"]
def path_si_to_units(self, path: np.ndarray) -> np.ndarray:
"""converts list of vectors from SI to ifc project units"""
return path / self.settings["unit_scale"]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,89 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Dion Moult <dion@thinkmoult.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/>.
from typing import Optional
import ifcopenshell
def add_shape_aspect(
file: ifcopenshell.file,
name: str,
items: list[ifcopenshell.entity_instance],
representation: ifcopenshell.entity_instance,
part_of_product: ifcopenshell.entity_instance,
description: Optional[str] = None,
) -> ifcopenshell.entity_instance:
"""Adds a shape aspect to items that are part of a representation and product
Existing shape aspects will be reused where possible. If the items already
belong to another shape aspect with a different name, this relationship
will be purged.
Warning: it is not possible to add a shape aspect to types (i.e.
IfcRepresentationMap) in IFC2X3.
:param name: The name of the shape aspect. This is case sensitive.
:param items: IfcRepresentationItems that will be assigned to this aspect.
:param representation: The IfcShapeRepresentation that the items are in.
:param part_of_product: The IfcRepresentationMap or
IfcProductDefinitionShape that the representation is in.
:param description: A description to set for the shape aspect. It's usually
not necessary.
:return: The IfcShapeAspect
"""
result = None
items_set = set(items)
for aspect in part_of_product.HasShapeAspects or []:
if aspect.Name == name:
for aspect_rep in aspect.ShapeRepresentations:
if aspect_rep.ContextOfItems == representation.ContextOfItems:
aspect.Description = description
aspect_rep.Items = tuple(set(aspect_rep.Items) | items_set)
result = aspect
if not result:
aspect_rep = file.createIfcShapeRepresentation(
ContextOfItems=representation.ContextOfItems,
RepresentationIdentifier=representation.RepresentationIdentifier,
RepresentationType=representation.RepresentationType,
Items=items,
)
aspect.ShapeRepresentations += (aspect_rep,)
result = aspect
else:
for aspect_rep in aspect.ShapeRepresentations:
if aspect_rep.ContextOfItems != representation.ContextOfItems:
continue
if set(aspect_rep.Items) & items_set:
if new_items := set(aspect_rep.Items) - items_set:
aspect_rep.Items = tuple(new_items)
else:
file.remove(aspect_rep)
if not aspect.ShapeRepresentations:
file.remove(aspect)
if result:
return result
aspect_rep = file.createIfcShapeRepresentation(
ContextOfItems=representation.ContextOfItems,
RepresentationIdentifier=representation.RepresentationIdentifier,
RepresentationType=representation.RepresentationType,
Items=items,
)
return file.createIfcShapeAspect((aspect_rep,), name, description, True, part_of_product)
@@ -0,0 +1,162 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2022 Dion Moult <dion@thinkmoult.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/>.
from math import cos, sin
from typing import Optional, Union
import ifcopenshell.util.element
import ifcopenshell.util.unit
from ifcopenshell.util.data import Clipping
def add_slab_representation(
file: ifcopenshell.file,
context: ifcopenshell.entity_instance,
depth: float = 0.2,
# TODO: document remaining args.
direction_sense: str = "POSITIVE",
offset: float = 0.0,
x_angle: float = 0.0,
clippings: Optional[list[Union[Clipping, ifcopenshell.entity_instance]]] = None,
polyline: Optional[list[tuple[float, float]]] = None,
) -> ifcopenshell.entity_instance:
"""
Add a geometric representation for a slab.
:param context: The IfcGeometricRepresentationContext for the representation,
only Model/Body/MODEL_VIEW type of representations are currently supported.
:param depth: The slab depth, in meters.
:param x_angle: The slope angle along the slab's X-axis, in radians.
:param clippings: List of planes that define clipping half space solids.
Clippings can be `Clipping` objects or dictionaries of arguments for `Clipping.parse`.
:return: IfcShapeRepresentation.
Example:
.. code:: python
context = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW")
clippings = [ifcopenshell.util.data.Clipping(location=(0.0, 0.0, 0.1), normal=(0.0, 0.0, 1.0),)]
representation = ifcopenshell.api.geometry.add_slab_representation(ifc_file, context, depth=0.2, clippings=clippings)
ifcopenshell.api.geometry.assign_representation(ifc_file, product=element, representation=representation)
"""
usecase = Usecase()
usecase.file = file
return usecase.execute(
context,
depth,
direction_sense,
offset,
x_angle,
clippings if clippings is not None else [],
polyline,
)
class Usecase:
file: ifcopenshell.file
def execute(
self,
context: ifcopenshell.entity_instance,
depth: float,
direction_sense: str,
offset: float,
x_angle: float,
clippings: list[Union[Clipping, ifcopenshell.entity_instance]],
polyline: Optional[list[tuple[float, float]]],
) -> ifcopenshell.entity_instance:
self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(self.file)
self.clippings = clippings
self.depth = depth
self.direction_sense = direction_sense
self.offset = offset
self.x_angle = x_angle
self.polyline = polyline
return self.file.create_entity(
"IfcShapeRepresentation",
context,
context.ContextIdentifier,
"Clipping" if self.clippings else "SweptSolid",
[self.create_item()],
)
def create_item(self) -> ifcopenshell.entity_instance:
size = self.convert_si_to_unit(1)
points = ((0.0, 0.0), (size, 0.0), (size, size), (0.0, size), (0.0, 0.0))
if self.polyline:
points = [
(self.convert_si_to_unit(p[0]), self.convert_si_to_unit(p[1] * abs(1 / cos(self.x_angle))))
for p in self.polyline
]
if self.file.schema == "IFC2X3":
curve = self.file.createIfcPolyline([self.file.createIfcCartesianPoint(p) for p in points])
else:
curve = self.file.createIfcIndexedPolyCurve(self.file.createIfcCartesianPointList2D(points))
if self.x_angle:
direction_ratios = (0.0, sin(self.x_angle), cos(self.x_angle))
else:
direction_ratios = (0.0, 0.0, 1.0)
offset_direction = direction_ratios # offset direction doesn't change if direction_sense is negative
extrusion_direction = self.file.createIfcDirection(direction_ratios)
if self.direction_sense == "NEGATIVE":
direction_ratios = tuple(-n for n in direction_ratios)
extrusion_direction = self.file.createIfcDirection(direction_ratios)
perpendicular_offset = self.convert_si_to_unit(self.offset) * abs(1 / cos(self.x_angle))
perpendicular_depth = self.convert_si_to_unit(self.depth) * abs(1 / cos(self.x_angle))
position = None
# default position for IFC2X3 where .Position is not optional
if self.file.schema == "IFC2X3" or self.offset != 0:
position_vector = (
offset_direction[0] * perpendicular_offset,
offset_direction[1] * perpendicular_offset,
offset_direction[2] * perpendicular_offset,
)
position = self.file.createIfcAxis2Placement3D(
self.file.createIfcCartesianPoint(position_vector),
self.file.createIfcDirection((0.0, 0.0, 1.0)),
self.file.createIfcDirection((1.0, 0.0, 0.0)),
)
extrusion = self.file.create_entity(
"IfcExtrudedAreaSolid",
self.file.createIfcArbitraryClosedProfileDef("AREA", None, curve),
position,
extrusion_direction,
perpendicular_depth,
)
if self.clippings:
return self.apply_clippings(extrusion)
return extrusion
def apply_clippings(self, first_operand: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance:
while self.clippings:
clipping = self.clippings.pop()
if isinstance(clipping, ifcopenshell.entity_instance):
new = ifcopenshell.util.element.copy(self.file, clipping)
new.FirstOperand = first_operand
first_operand = new
else: # Clipping
first_operand = clipping.apply(self.file, first_operand, self.unit_scale)
return first_operand
def convert_si_to_unit(self, co: float) -> float:
return co / self.unit_scale
@@ -0,0 +1,97 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.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/>.
# This file was generated with the assistance of an AI coding tool.
from typing import Optional
import ifcopenshell
_ITEM_TYPE_TO_REP_TYPE = {
"IfcVertex": "Vertex",
"IfcVertexPoint": "Vertex",
"IfcEdge": "Edge",
"IfcOrientedEdge": "Edge",
"IfcEdgeCurve": "Edge",
"IfcEdgeLoop": "Edge",
"IfcPath": "Edge",
"IfcFace": "Face",
"IfcFaceSurface": "Face",
"IfcAdvancedFace": "Face",
"IfcClosedShell": "Face",
"IfcOpenShell": "Face",
"IfcConnectedFaceSet": "Face",
}
def add_topology_representation(
file: ifcopenshell.file,
context: ifcopenshell.entity_instance,
item: ifcopenshell.entity_instance,
representation_identifier: Optional[str] = None,
representation_type: Optional[str] = None,
) -> ifcopenshell.entity_instance:
"""Adds an IfcTopologyRepresentation for a structural element
Structural analysis elements (IfcStructuralSurfaceMember,
IfcStructuralCurveMember) use topology representations rather than solid
geometry. This is analogous to :func:`add_axis_representation` and
:func:`add_profile_representation` but produces an
IfcTopologyRepresentation instead of an IfcShapeRepresentation.
The representation type ("Face", "Edge", "Vertex") is inferred from the
item's IFC class if not provided explicitly.
:param context: The IfcGeometricRepresentationContext for the
representation, typically a Reference context.
:param item: The IfcTopologicalRepresentationItem (e.g. IfcFaceSurface,
IfcEdge) to include in the representation.
:param representation_identifier: The RepresentationIdentifier string.
Defaults to the context's ContextIdentifier.
:param representation_type: The RepresentationType string ("Face",
"Edge", "Vertex"). Inferred from item class if not given.
:return: The newly created IfcTopologyRepresentation entity.
Example:
.. code:: python
context = ifcopenshell.util.representation.get_context(
model, "Model", "Reference", "GRAPH_VIEW")
face = model.createIfcFaceSurface(bounds, surface, True)
rep = ifcopenshell.api.geometry.add_topology_representation(
model, context=context, item=face)
ifcopenshell.api.geometry.assign_representation(
model, product=member, representation=rep)
"""
if representation_identifier is None:
representation_identifier = context.ContextIdentifier
if representation_type is None:
for ifc_class, rep_type in _ITEM_TYPE_TO_REP_TYPE.items():
if item.is_a(ifc_class):
representation_type = rep_type
break
else:
representation_type = "Undefined"
return file.createIfcTopologyRepresentation(
context,
representation_identifier,
representation_type,
[item],
)
@@ -0,0 +1,145 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2022 Dion Moult <dion@thinkmoult.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/>.
from math import cos, sin
from typing import Any, Optional, Union
import ifcopenshell.util.element
import ifcopenshell.util.unit
from ifcopenshell.util.data import Clipping
def add_wall_representation(
file: ifcopenshell.file,
context: ifcopenshell.entity_instance,
length: float = 1.0,
height: float = 3.0,
direction_sense: str = "POSITIVE",
offset: float = 0.0,
thickness: float = 0.2,
x_angle: float = 0.0,
clippings: Optional[list[Union[Clipping, dict[str, Any]]]] = None,
booleans: Optional[list[ifcopenshell.entity_instance]] = None,
) -> ifcopenshell.entity_instance:
"""
Add a geometric representation for a wall.
:param context: The IfcGeometricRepresentationContext for the representation,
only Model/Body/MODEL_VIEW type of representations are currently supported.
:param length: The length of the wall in meters.
:param height: The height of the wall in meters.
:param offset: The base offset distance of the wall from the origin.
:param thickness: The thickness of the wall in meters.
:param x_angle: The slope angle along the wall's X-axis, in radians.
:param clippings: List of clipping definitions. Clippings can be `Clipping` objects
or dictionaries of arguments for `Clipping.parse`. Each clipping has a
``normal`` that points toward the removed material (the discarded side),
not toward the kept material; see :func:`clip_solid` for details.
:param booleans: List of any existing IfcBooleanResults.
:return: IfcShapeRepresentation.
"""
usecase = Usecase()
usecase.file = file
usecase.settings = {
"context": context,
"length": length,
"height": height,
"direction_sense": direction_sense,
"offset": offset,
"thickness": thickness,
"x_angle": x_angle,
"clippings": clippings if clippings is not None else [],
"booleans": booleans if booleans is not None else [],
}
return usecase.execute()
class Usecase:
file: ifcopenshell.file
settings: dict[str, Any]
clippings: list[Clipping]
def execute(self) -> ifcopenshell.entity_instance:
self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(self.file)
self.clippings = [Clipping.parse(c) for c in self.settings["clippings"]]
return self.file.createIfcShapeRepresentation(
self.settings["context"],
self.settings["context"].ContextIdentifier,
"Clipping" if self.clippings or self.settings["booleans"] else "SweptSolid",
[self.create_item()],
)
def create_item(self) -> ifcopenshell.entity_instance:
length = self.convert_si_to_unit(self.settings["length"])
thickness = self.convert_si_to_unit(self.settings["thickness"])
thickness *= 1 / cos(self.settings["x_angle"])
if self.settings["direction_sense"] == "NEGATIVE":
thickness *= -1
points = (
(0.0, 0.0),
(0.0, thickness),
(length, thickness),
(length, 0.0),
(0.0, 0.0),
)
if self.file.schema == "IFC2X3":
curve = self.file.createIfcPolyline([self.file.createIfcCartesianPoint(p) for p in points])
else:
curve = self.file.createIfcIndexedPolyCurve(self.file.createIfcCartesianPointList2D(points), None, False)
if self.settings["x_angle"]:
extrusion_direction = self.file.createIfcDirection(
(0.0, sin(self.settings["x_angle"]), cos(self.settings["x_angle"]))
)
else:
extrusion_direction = self.file.createIfcDirection((0.0, 0.0, 1.0))
extrusion = self.file.createIfcExtrudedAreaSolid(
self.file.createIfcArbitraryClosedProfileDef("AREA", None, curve),
self.file.createIfcAxis2Placement3D(
self.file.createIfcCartesianPoint((0.0, self.convert_si_to_unit(self.settings["offset"]), 0.0)),
self.file.createIfcDirection((0.0, 0.0, 1.0)),
self.file.createIfcDirection((1.0, 0.0, 0.0)),
),
extrusion_direction,
self.convert_si_to_unit(self.settings["height"]) * abs(1 / cos(self.settings["x_angle"])),
)
if self.settings["booleans"]:
extrusion = self.apply_booleans(extrusion)
if self.clippings:
extrusion = self.apply_clippings(extrusion)
return extrusion
def apply_booleans(self, first_operand: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance:
while self.settings["booleans"]:
boolean = self.settings["booleans"].pop()
boolean.FirstOperand = first_operand
first_operand = boolean
return first_operand
def apply_clippings(self, first_operand: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance:
while self.clippings:
clipping = self.clippings.pop()
if isinstance(clipping, ifcopenshell.entity_instance):
new = ifcopenshell.util.element.copy(self.file, clipping)
new.FirstOperand = first_operand
first_operand = new
else: # Clipping
first_operand = clipping.apply(self.file, first_operand, self.unit_scale)
return first_operand
def convert_si_to_unit(self, co: Any) -> Any:
return co / self.unit_scale
@@ -0,0 +1,783 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2023 @Andrej730
#
# 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/>.
from __future__ import annotations
import dataclasses
from itertools import chain
from typing import Any, Literal, Optional, Union, overload
import numpy as np
import ifcopenshell.api.geometry
import ifcopenshell.util.unit
from ifcopenshell.util.shape_builder import ShapeBuilder, V
# SCHEMAS describe panels setup
# where:
# - schema rows represent window X axis
# - schema columns represent window Y axis
# - order of rows is from top of the window to bottom
WINDOW_TYPE = Literal[
"SINGLE_PANEL",
"DOUBLE_PANEL_HORIZONTAL",
"DOUBLE_PANEL_VERTICAL",
"TRIPLE_PANEL_BOTTOM",
"TRIPLE_PANEL_HORIZONTAL",
"TRIPLE_PANEL_LEFT",
"TRIPLE_PANEL_RIGHT",
"TRIPLE_PANEL_TOP",
"TRIPLE_PANEL_VERTICAL",
]
DEFAULT_PANEL_SCHEMAS = {
"SINGLE_PANEL": [[0]],
"DOUBLE_PANEL_HORIZONTAL": [[0], [1]],
"DOUBLE_PANEL_VERTICAL": [[0, 1]],
"TRIPLE_PANEL_BOTTOM": [[0, 1], [2, 2]],
"TRIPLE_PANEL_TOP": [[0, 0], [1, 2]],
"TRIPLE_PANEL_LEFT": [[0, 1], [0, 2]],
"TRIPLE_PANEL_RIGHT": [[0, 1], [2, 1]],
"TRIPLE_PANEL_HORIZONTAL": [[0], [1], [2]],
"TRIPLE_PANEL_VERTICAL": [[0, 1, 2]],
}
def mm(x: float) -> float:
"""mm to meters shortcut for readability"""
return x / 1000
def create_ifc_window_frame_simple(
builder: ShapeBuilder, size: np.ndarray, thickness: Union[list[float], float], position: Optional[np.ndarray] = None
) -> list[ifcopenshell.entity_instance]:
"""`thickness` of the profile is defined as list in the following order:
`(LEFT, TOP, RIGHT, BOTTOM)`
`thickness` can be also defined just as 1 float value.
"""
if not isinstance(thickness, list):
thickness = [thickness] * 4
if position is None:
position = np.zeros(3)
np_X, np_Y, np_Z = 0, 1, 2
np_XZ = [0, 2]
th_left, th_up, th_right, th_bottom = thickness
def get_extruded_profile(profile: ifcopenshell.entity_instance):
return builder.extrude(profile, size[np_Y], position=position, **builder.extrude_kwargs("Y"))
# if all lining sides are present then we can just use two rectangles
# as inner and outer curves of the profile
if thickness.count(0) == 0:
panel_rect = builder.rectangle(size=size[np_XZ])
inner_rect_size = size - (th_left + th_right, 0, th_bottom + th_up)
inner_rect = builder.rectangle(size=inner_rect_size[np_XZ], position=(th_left, th_bottom))
panel_profile = builder.profile(panel_rect, inner_curves=inner_rect)
return [get_extruded_profile(panel_profile)]
# if some side has zero thickness it means we cannot use inner curves
# and need to generate L/U shape or just separate rectangles
else:
def get_segments_from_thickness() -> list[tuple[float, ...]]:
nonlocal thickness
segments = []
cur_segment = []
for i, thickness_ in enumerate(thickness):
if thickness_ == 0:
if cur_segment:
segments.append(tuple(cur_segment))
cur_segment = []
else:
cur_segment.append(i)
if cur_segment:
if len(segments) > 0 and segments[0][0] == 0:
segments[0] = tuple(cur_segment) + segments[0]
else:
segments.append(tuple(cur_segment))
return segments
# prepare coords to build a lining
# fmt: off
outer_coords = [
((0, 0), (0, size[np_Z])),
((0, size[np_Z]), (size[np_X], size[np_Z])),
((size[np_X], size[np_Z]), (size[np_X], 0)),
((size[np_X], 0), (0, 0)),
]
inner_coords = [
((th_left, th_bottom), (th_left, size[np_Z] - th_up)),
((th_left, size[np_Z] - th_up), (size[np_X] - th_right, size[np_Z] - th_up)),
((size[np_X] - th_right, size[np_Z] - th_up), (size[np_X] - th_right, th_bottom)),
((size[np_X] - th_right, th_bottom), (th_left, th_bottom)),
]
# fmt: on
def get_points(segment: tuple[float, ...]) -> list[tuple[float, float]]:
points = []
for side in segment:
outer = outer_coords[side]
if side == segment[0]: # first segment
points.append(outer[0])
points.append(outer[1])
for side in reversed(segment):
inner = inner_coords[side]
if side == segment[-1]: # last non zero segment
points.append(inner[1])
points.append(inner[0])
return points
segments = get_segments_from_thickness()
segments_items: list[ifcopenshell.entity_instance] = []
for seg in segments:
polyline = builder.polyline(points=get_points(seg), closed=True)
panel_profile = builder.profile(polyline)
segments_items.append(get_extruded_profile(panel_profile))
return segments_items
def window_l_shape_check(
lining_to_panel_offset_y_full: float,
lining_depth: float,
lining_to_panel_offset_x: list[float],
lining_thickness: list[float],
) -> bool:
"""`lining_thickness` and `lining_to_panel_offset_x` expected to be defined as a list,
similarly to `create_ifc_window_frame_simple` `thickness` argument"""
l_shape_check = lining_to_panel_offset_y_full < lining_depth and any(
x_offset < th for th, x_offset in zip(lining_thickness, lining_to_panel_offset_x, strict=True)
)
return l_shape_check
def create_ifc_window(
builder: ShapeBuilder,
lining_size: np.ndarray,
lining_thickness: list[float],
lining_to_panel_offset_x: float,
lining_to_panel_offset_y_full: float,
frame_size: np.ndarray,
frame_thickness: float,
glass_thickness: float,
position: np.ndarray,
x_offsets: Optional[list[float]] = None,
) -> dict[str, list[ifcopenshell.entity_instance]]:
"""`lining_thickness` and `x_offsets` are expected to be defined as a list,
similarly to `create_ifc_window_frame_simple` `thickness` argument"""
lining_items: list[ifcopenshell.entity_instance] = []
main_lining_size = lining_size
np_Y = 1
if x_offsets is None:
x_offsets = [lining_to_panel_offset_x] * 4
# need to check offsets to decide whether lining should be rectangle
# or L shaped
l_shape_check = window_l_shape_check(
lining_to_panel_offset_y_full,
lining_size[np_Y],
x_offsets,
lining_thickness,
)
if l_shape_check:
main_lining_size = lining_size.copy()
main_lining_size[np_Y] = lining_to_panel_offset_y_full
second_lining_size = lining_size.copy()
second_lining_size[np_Y] = lining_size[np_Y] - lining_to_panel_offset_y_full
second_lining_position = V(0, lining_to_panel_offset_y_full, 0)
second_lining_thickness = [min(th, x_offset) for th, x_offset in zip(lining_thickness, x_offsets, strict=True)]
second_lining_items = create_ifc_window_frame_simple(
builder, second_lining_size, second_lining_thickness, second_lining_position
)
lining_items.extend(second_lining_items)
main_lining_items = create_ifc_window_frame_simple(builder, main_lining_size, lining_thickness)
lining_items.extend(main_lining_items)
frame_position = V(
x_offsets[0],
lining_to_panel_offset_y_full,
x_offsets[3],
)
frame_extruded_items = create_ifc_window_frame_simple(builder, frame_size, frame_thickness, frame_position)
glass_position = frame_position + V(0, frame_size[np_Y] / 2 - glass_thickness / 2, 0)
glass_rect = builder.deep_copy(frame_extruded_items[0].SweptArea.InnerCurves[0])
glass = builder.extrude(glass_rect, glass_thickness, position=glass_position, **builder.extrude_kwargs("Y"))
output_items = (lining_items, frame_extruded_items, [glass])
builder.translate(chain(*output_items), position)
return {"Lining": lining_items, "Framing": frame_extruded_items, "Glazing": [glass]}
# we use dataclass as we need default values for arguments
# it's okay to use slots since we don't need dynamic attributes
@dataclasses.dataclass(slots=True)
class WindowLiningProperties:
LiningDepth: Optional[float] = None
"""Optional, defaults to 50mm."""
LiningThickness: Optional[float] = None
"""Optional, defaults to 50mm."""
LiningOffset: Optional[float] = None
"""Offset to the wall. Optional, defaults to 50mm."""
LiningToPanelOffsetX: Optional[float] = None
"""Offset from the wall. Optional, defaults to 25mm."""
# that way it allows you to define overall_depth constant between all panels
# and still have panels with different size:
# overall_depth = lining_depth + offset_y
# full offset from X axis = overall_depth - frame_depth.
LiningToPanelOffsetY: Optional[float] = None
"""Offset from the lining. Optional, defaults to 25mm."""
MullionThickness: Optional[float] = None
"""Mullion thickness (horizontal distance between panels).
Applies to windows of types: DoublePanelVertical, TriplePanelBottom, TriplePanelTop,
TriplePanelLeft, TriplePanelRight.
Optional, defaults to 50mm."""
FirstMullionOffset: Optional[float] = None
"""Distance from the first lining to the mullion center. Optional, defaults to 300mm."""
SecondMullionOffset: Optional[float] = None
"""Distance from the first lining to the second mullion center.
Applies to windows of type: TriplePanelVertical.
Optional, defaults to 450mm."""
TransomThickness: Optional[float] = None
"""Transom thickness (vertical distance between panels), works similar way to mullions.
Applies to windows of types:DoublePanelHorizontal, TriplePanelBottom, TriplePanelTop,
TriplePanelLeft, TriplePanelRight.
Optional, defaults to 50mm."""
FirstTransomOffset: Optional[float] = None
"""Optional, defaults to 300mm."""
SecondTransomOffset: Optional[float] = None
"""
Applies to windows of type: TriplePanelHorizontal.
Optional, defaults to 600mm."""
ShapeAspectStyle: None = None
"""Optional. Deprecated argument."""
def initialize_properties(self, unit_scale: float) -> None:
# in meters
# fmt: off
default_values: dict[str, float] = dict(
LiningDepth = mm(50),
LiningThickness = mm(50),
LiningOffset = mm(50),
LiningToPanelOffsetX = mm(25),
LiningToPanelOffsetY = mm(25),
MullionThickness = mm(50),
FirstMullionOffset = mm(300),
SecondMullionOffset = mm(450),
TransomThickness = mm(50),
FirstTransomOffset = mm(300),
SecondTransomOffset = mm(600),
)
# fmt: on
si_conversion = 1 / unit_scale
for attr, default_value in default_values.items():
if getattr(self, attr) is not None:
continue
setattr(self, attr, default_value * si_conversion)
@dataclasses.dataclass(slots=True)
class WindowPanelProperties:
FrameDepth: Optional[float] = None
"""Frame thickness by Y axis. Optional, defaults to 35 mm."""
FrameThickness: Optional[float] = None
"""Frame thickness by X axis. Optional, defaults to 35 mm."""
PanelPosition: None = None
"""Optional, value is never used"""
PanelOperation: None = None
"""Optional, value is never used.
Defines the basic ways to describe how window panels operate."""
ShapeAspectStyle: None = None
"""Optional. Deprecated argument."""
def initialize_properties(self, unit_scale: float) -> None:
# in meters
# fmt: off
default_values: dict[str, float] = dict(
FrameDepth = mm(35),
FrameThickness = mm(35),
)
# fmt: on
si_conversion = 1 / unit_scale
for attr, default_value in default_values.items():
if getattr(self, attr) is not None:
continue
setattr(self, attr, default_value * si_conversion)
def add_window_representation(
file: ifcopenshell.file,
*, # keywords only as this API implementation is probably not final
context: ifcopenshell.entity_instance,
overall_height: Optional[float] = None,
overall_width: Optional[float] = None,
partition_type: WINDOW_TYPE = "SINGLE_PANEL",
lining_properties: Optional[Union[WindowLiningProperties, dict[str, Any]]] = None,
panel_properties: Optional[list[Union[WindowPanelProperties, dict[str, Any]]]] = None,
part_of_product: Optional[ifcopenshell.entity_instance] = None,
unit_scale: Optional[float] = None,
) -> ifcopenshell.entity_instance:
"""units in usecase_settings expected to be in ifc project units
:param context: IfcGeometricRepresentationContext for the representation.
:param overall_height: Overall window height. Defaults to 0.9m.
:param overall_width: Overall window width. Defaults to 0.6m.
:param partition_type: Type of the window. Defaults to SINGLE_PANEL.
:param lining_properties: WindowLiningProperties or a dictionary to create one.
See WindowLiningProperties description for details.
:param panel_properties: A list of WindowPanelProperties or dictionaries to create one.
See WindowPanelProperties description for details.
:param unit_scale: The unit scale as calculated by
ifcopenshell.util.unit.calculate_unit_scale. If not provided, it
will be automatically calculated for you.
:return: IfcShapeRepresentation for a window.
"""
usecase = Usecase()
usecase.file = file
# http://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcWindow.htm
# http://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcWindowTypePartitioningEnum.htm
# http://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcWindowLiningProperties.htm
# http://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcWindowPanelProperties.htm
# define unit_scale first as it's going to be used setting default arguments
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file) if unit_scale is None else unit_scale
settings: dict[str, Any] = {"unit_scale": unit_scale}
if lining_properties is None:
lining_properties = WindowLiningProperties()
elif not isinstance(lining_properties, WindowLiningProperties):
lining_properties = WindowLiningProperties(**lining_properties)
lining_properties.initialize_properties(unit_scale)
lining_properties = dataclasses.asdict(lining_properties)
if panel_properties is None:
panel_properties = [WindowPanelProperties()]
for i in range(len(panel_properties)):
properties = panel_properties[i]
if not isinstance(properties, WindowPanelProperties):
properties = WindowPanelProperties(**properties)
properties.initialize_properties(unit_scale)
panel_properties[i] = dataclasses.asdict(properties)
settings.update(
{
"context": context,
"overall_height": overall_height if overall_height is not None else usecase.convert_si_to_unit(0.9),
"overall_width": overall_width if overall_width is not None else usecase.convert_si_to_unit(0.6),
"partition_type": partition_type,
"lining_properties": lining_properties,
"panel_properties": panel_properties,
"part_of_product": part_of_product,
}
)
usecase.settings = settings
usecase.settings["panel_schema"] = DEFAULT_PANEL_SCHEMAS[usecase.settings["partition_type"]]
return usecase.execute()
class Usecase:
file: ifcopenshell.file
settings: dict[str, Any]
def execute(self):
builder = ShapeBuilder(self.file)
np_X, np_Y, np_Z = 0, 1, 2
overall_height: float = self.settings["overall_height"]
overall_width: float = self.settings["overall_width"]
if self.settings["context"].TargetView == "ELEVATION_VIEW":
rect = builder.rectangle(V(overall_width, 0, overall_height))
representation_evelevation = builder.get_representation(self.settings["context"], rect)
return representation_evelevation
panel_schema: list[list[int]] = self.settings["panel_schema"]
panels: list[dict[str, Any]] = self.settings["panel_properties"]
accumulated_height: list[float] = [0] * len(panel_schema[0])
built_panels: list[int] = []
window_items: list[ifcopenshell.entity_instance] = []
lining_items: list[ifcopenshell.entity_instance] = []
framing_items: list[ifcopenshell.entity_instance] = []
glazing_items: list[ifcopenshell.entity_instance] = []
lining_props: dict[str, Any] = self.settings["lining_properties"]
lining_thickness: float = lining_props["LiningThickness"]
lining_depth: float = lining_props["LiningDepth"]
lining_offset: float = lining_props["LiningOffset"]
lining_to_panel_offset_x: float = lining_props["LiningToPanelOffsetX"]
lining_to_panel_offset_y: float = lining_props["LiningToPanelOffsetY"]
overall_depth: float = lining_depth + lining_to_panel_offset_y
mullion_thickness: float = lining_props["MullionThickness"] / 2
first_mullion_offset: float = lining_props["FirstMullionOffset"]
second_mullion_offset: float = lining_props["SecondMullionOffset"]
transom_thickness: float = lining_props["TransomThickness"] / 2
first_transom_offset: float = lining_props["FirstTransomOffset"]
second_transom_offset: float = lining_props["SecondTransomOffset"]
glass_thickness: float = self.convert_si_to_unit(0.01)
panel_schema = list(reversed(panel_schema))
# create 2d representation
def create_ifc_window_2d_representation() -> ifcopenshell.entity_instance:
items_2d: list[ifcopenshell.entity_instance] = []
top_row = panel_schema[-1]
unique_cols = len(set(top_row))
built_panels: list[int] = []
accumulated_width: float = 0
for column_i, panel_i in enumerate(top_row):
cur_panel_items: list[ifcopenshell.entity_instance] = []
# lists represent left and right linings
window_lining_thickness = [lining_thickness] * 2
closed_lining = [True] * 2
if panel_i in built_panels:
continue
# detect mullion
has_mullion = unique_cols > 1
first_column = column_i == 0
last_column = column_i == unique_cols - 1
left_to_mullion = has_mullion and not last_column
right_to_mullion = has_mullion and not first_column
if has_mullion:
if first_column:
panel_width = first_mullion_offset
elif last_column:
panel_width = overall_width - accumulated_width
else:
panel_width = second_mullion_offset - accumulated_width
# mullion thickness
if not first_column:
window_lining_thickness[0] = mullion_thickness # left column
closed_lining[0] = False
if not last_column:
window_lining_thickness[1] = mullion_thickness # right column
closed_lining[1] = False
else:
panel_width = overall_width
frame_depth: float = panels[panel_i]["FrameDepth"]
frame_thickness: float = panels[panel_i]["FrameThickness"]
lining_to_panel_offset_y_full = (lining_depth - frame_depth) + lining_to_panel_offset_y
base_frame_clear = lining_to_panel_offset_x + frame_thickness - lining_thickness
current_offset_x = base_frame_clear - frame_thickness + mullion_thickness
# add lining
cur_panel_items.append(
builder.polyline(
[
(window_lining_thickness[0], 0),
(panel_width - window_lining_thickness[1], 0),
]
)
)
def get_lining_shape(
lining_thickness: float, closed: bool = True, mirror: bool = False, x_offset: Optional[float] = None
) -> ifcopenshell.entity_instance:
if x_offset is None:
x_offset = lining_to_panel_offset_x
l_shape_check = window_l_shape_check(
lining_to_panel_offset_y_full,
lining_depth,
[x_offset],
[lining_thickness],
)
if l_shape_check:
lining_shape = builder.polyline(
[
(0, lining_depth),
(x_offset, lining_depth),
(x_offset, lining_to_panel_offset_y_full),
(lining_thickness, lining_to_panel_offset_y_full),
(lining_thickness, 0),
(0, 0),
],
closed=closed,
)
else:
lining_shape = builder.polyline(
[
(0, lining_depth),
(lining_thickness, lining_depth),
(lining_thickness, 0),
(0, 0),
],
closed=closed,
)
if mirror:
builder.mirror(
lining_shape,
mirror_axes=(1, 0),
mirror_point=(panel_width / 2, 0),
)
return lining_shape
cur_panel_items.extend(
[
get_lining_shape(
window_lining_thickness[0],
closed=closed_lining[0],
x_offset=current_offset_x if right_to_mullion else None,
),
get_lining_shape(
window_lining_thickness[1],
closed=closed_lining[1],
x_offset=current_offset_x if left_to_mullion else None,
mirror=True,
),
]
)
# add frame
frame_items: list[ifcopenshell.entity_instance] = []
frame_position = (
current_offset_x if right_to_mullion else lining_to_panel_offset_x,
lining_to_panel_offset_y_full,
)
frame_width = panel_width
frame_width -= current_offset_x if left_to_mullion else lining_to_panel_offset_x
frame_width -= current_offset_x if right_to_mullion else lining_to_panel_offset_x
frame_vertical = builder.rectangle(size=(frame_thickness, frame_depth))
frame_items.extend(
[
frame_vertical,
builder.mirror(
frame_vertical,
mirror_axes=(1, 0),
mirror_point=(frame_width / 2, 0),
create_copy=True,
),
]
)
frame_horizontal = builder.polyline([(frame_thickness, 0), (frame_width - frame_thickness, 0)])
frame_items.extend(
[
frame_horizontal,
builder.translate(frame_horizontal, (0, frame_depth), create_copy=True),
]
)
# glass
frame_items.append(builder.translate(frame_horizontal, (0, frame_depth / 2), create_copy=True))
builder.translate(frame_items, frame_position)
cur_panel_items.extend(frame_items)
builder.translate(cur_panel_items, (accumulated_width, 0))
accumulated_width += panel_width
built_panels.append(panel_i)
items_2d.extend(cur_panel_items)
builder.translate(items_2d, (0, lining_offset))
representation_2d = builder.get_representation(self.settings["context"], items_2d)
return representation_2d
if self.settings["context"].TargetView == "PLAN_VIEW":
return create_ifc_window_2d_representation()
# TODO: need more readable way to define panel width and height
unique_rows_in_col = [
len(set(row[column_i] for row in panel_schema)) for column_i in range(len(panel_schema[0]))
]
for row_i, panel_row in enumerate(panel_schema):
accumulated_width = 0
unique_cols = len(set(panel_row))
for column_i, panel_i in enumerate(panel_row):
# detect mullion
has_mullion = unique_cols > 1
first_column = column_i == 0
last_column = column_i == unique_cols - 1
left_to_mullion = has_mullion and not last_column
right_to_mullion = has_mullion and not first_column
# detect transom
has_transom = unique_rows_in_col[column_i] > 1
first_row = row_i == 0
last_row = row_i == unique_rows_in_col[column_i] - 1
top_to_transom = has_transom and not first_row
bottom_to_transom = has_transom and not last_row
# calculate current panel dimensions
if has_mullion:
# panel_width
if first_column:
panel_width = first_mullion_offset
elif last_column:
panel_width = overall_width - accumulated_width
else:
panel_width = second_mullion_offset - accumulated_width
else:
panel_width = overall_width
if has_transom:
if first_row:
panel_height = first_transom_offset
elif last_row:
panel_height = overall_height - accumulated_height[column_i]
else:
panel_height = second_transom_offset - accumulated_height[column_i]
else:
panel_height = overall_height
if panel_i in built_panels:
accumulated_height[column_i] += panel_height
accumulated_width += panel_width
continue
cur_panel = panels[panel_i]
frame_depth = cur_panel["FrameDepth"]
frame_thickness = cur_panel["FrameThickness"]
lining_to_panel_offset_y_full = (lining_depth - frame_depth) + lining_to_panel_offset_y
# fmt: off
# calculate lining thickness and frame size / offset
# taking into account mullions and transoms
window_lining_thickness = [
mullion_thickness if right_to_mullion else lining_thickness,
transom_thickness if bottom_to_transom else lining_thickness,
mullion_thickness if left_to_mullion else lining_thickness,
transom_thickness if top_to_transom else lining_thickness,
]
# x offsets can differ if there are mullions or transoms because we're trying to maintain symmetry
base_frame_clear = lining_to_panel_offset_x + frame_thickness - lining_thickness
current_offset_x = base_frame_clear - frame_thickness + mullion_thickness
current_offset_z = base_frame_clear - frame_thickness + transom_thickness
x_offsets = [
current_offset_x if right_to_mullion else lining_to_panel_offset_x, # LEFT
current_offset_z if bottom_to_transom else lining_to_panel_offset_x, # TOP
current_offset_x if left_to_mullion else lining_to_panel_offset_x, # RIGHT
current_offset_z if top_to_transom else lining_to_panel_offset_x, # BOTTOM
]
# fmt: on
window_lining_size = V(panel_width, lining_depth, panel_height)
frame_size = window_lining_size.copy()
frame_size[np_Y] = frame_depth
frame_size[np_X] -= x_offsets[0] + x_offsets[2]
frame_size[np_Z] -= x_offsets[1] + x_offsets[3]
window_panel_position = V(accumulated_width, 0, accumulated_height[column_i])
# create window panel
current_window_items = create_ifc_window(
builder,
window_lining_size,
window_lining_thickness,
lining_to_panel_offset_x,
lining_to_panel_offset_y_full,
frame_size,
frame_thickness,
glass_thickness,
window_panel_position,
x_offsets,
)
built_panels.append(panel_i)
window_items.extend(chain(*current_window_items.values()))
lining_items.extend(current_window_items["Lining"])
framing_items.extend(current_window_items["Framing"])
glazing_items.extend(current_window_items["Glazing"])
accumulated_height[column_i] += panel_height
accumulated_width += panel_width
builder.translate(window_items, (0, lining_offset, 0)) # wall offset
representation = builder.get_representation(self.settings["context"], window_items)
if self.settings["part_of_product"]:
ifcopenshell.api.geometry.add_shape_aspect(
self.file,
"Lining",
items=lining_items,
representation=representation,
part_of_product=self.settings["part_of_product"],
)
ifcopenshell.api.geometry.add_shape_aspect(
self.file,
"Framing",
items=framing_items,
representation=representation,
part_of_product=self.settings["part_of_product"],
)
ifcopenshell.api.geometry.add_shape_aspect(
self.file,
"Glazing",
items=glazing_items,
representation=representation,
part_of_product=self.settings["part_of_product"],
)
return representation
@overload
def convert_si_to_unit(self, value: float) -> float: ...
@overload
def convert_si_to_unit(self, value: np.ndarray) -> np.ndarray: ...
def convert_si_to_unit(self, value: Union[float, np.ndarray]) -> Union[float, np.ndarray]:
si_conversion = 1 / self.settings["unit_scale"]
return value * si_conversion
@@ -0,0 +1,95 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2021 Dion Moult <dion@thinkmoult.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.geometry
import ifcopenshell.api.owner
import ifcopenshell.util.element
def assign_representation(
file: ifcopenshell.file, product: ifcopenshell.entity_instance, representation: ifcopenshell.entity_instance
) -> None:
usecase = Usecase()
usecase.file = file
return usecase.execute(product, representation)
class Usecase:
file: ifcopenshell.file
def execute(self, product: ifcopenshell.entity_instance, representation: ifcopenshell.entity_instance) -> None:
if product.is_a("IfcProduct"):
product_type = ifcopenshell.util.element.get_type(product)
if (
product_type
and product_type.RepresentationMaps
and representation.RepresentationType != "MappedRepresentation"
# Revit is adding a non-mapped representation to the exported profile-based types,
# so assigning representation to occurrence by accident was assigning it to the type.
# We guard from this by skipping profile and layer-based types.
# See 6934 for example.
and not (
(material := ifcopenshell.util.element.get_material(product_type))
and material.is_a() in ("IfcMaterialProfileSet", "IfcMaterialLayerSet")
)
):
product = product_type
if product.is_a("IfcProduct"):
self.assign_product_representation(product, representation)
elif product.is_a("IfcTypeProduct"):
if product.RepresentationMaps:
maps = list(product.RepresentationMaps)
else:
maps = []
self.zero = self.file.createIfcCartesianPoint((0.0, 0.0, 0.0))
self.x_axis = self.file.createIfcDirection((1.0, 0.0, 0.0))
self.z_axis = self.file.createIfcDirection((0.0, 0.0, 1.0))
maps.append(
self.file.create_entity(
"IfcRepresentationMap",
**{
"MappingOrigin": self.file.createIfcAxis2Placement3D(self.zero, self.z_axis, self.x_axis),
"MappedRepresentation": representation,
},
)
)
product.RepresentationMaps = maps
if self.file.schema == "IFC2X3":
types = product.ObjectTypeOf
else:
types = product.Types
if types:
for element in types[0].RelatedObjects:
mapped_representation = ifcopenshell.api.geometry.map_representation(
self.file, representation=representation
)
self.assign_product_representation(element, mapped_representation)
ifcopenshell.api.owner.update_owner_history(self.file, element=product)
def assign_product_representation(
self, product: ifcopenshell.entity_instance, representation: ifcopenshell.entity_instance
) -> None:
definition = product.Representation
if not definition:
definition = self.file.createIfcProductDefinitionShape()
product.Representation = definition
representations = list(definition.Representations) if definition.Representations else []
representations.append(representation)
definition.Representations = representations
@@ -0,0 +1,86 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.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/>.
from __future__ import annotations
import json
from typing import Optional, Sequence
import ifcopenshell.api.pset
import ifcopenshell.util.element
import ifcopenshell.util.unit
from ifcopenshell.util.data import Clipping
def clip_solid(
file: ifcopenshell.file,
item: ifcopenshell.entity_instance,
location: Sequence[float],
normal: Sequence[float],
element: Optional[ifcopenshell.entity_instance] = None,
) -> ifcopenshell.entity_instance:
"""Clip a solid with a half-space plane, returning an IfcBooleanClippingResult.
Convenience wrapper around :class:`ifcopenshell.util.data.Clipping` for
use with any solid. This is the same convention used by the ``clippings``
parameter of :func:`add_wall_representation`.
.. warning::
The ``normal`` points toward the **removed** material (the discarded
side), not toward the kept material. For a slope clip the normal
points upward into the removed wedge above the slope line. For a
side mitre the normal points outward away from the wall body.
After clipping, set the parent ``IfcShapeRepresentation``
``RepresentationType`` to ``"Clipping"``.
Example — trim an extruded solid to a lean-to slope (removed material is
above the slope)::
bcr = ifcopenshell.api.run(
"geometry.clip_solid", model,
item=extrusion,
location=[0.0, 0.0, 3.26],
normal=[0.419, 0.0, 0.908], # points UP toward removed material
)
:param item: The solid to clip (``IfcSweptAreaSolid``, ``IfcSweptDiskSolid``,
or ``IfcBooleanClippingResult``).
:param location: A point on the clipping plane in the representation's
local coordinate system.
:param normal: Plane normal pointing toward the material to be removed
(see warning above).
:param element: If provided, the resulting ``IfcBooleanClippingResult`` is
registered in the element's ``BBIM_Boolean`` property set so that
:func:`regenerate_wall_representation` preserves it during regeneration.
:return: The resulting ``IfcBooleanClippingResult``.
"""
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file)
clipping = Clipping(location=tuple(location), normal=tuple(normal))
result = clipping.apply(file, item, unit_scale)
if element is not None:
pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Boolean")
if pset_data:
pset = file.by_id(pset_data["id"])
data = list(set(json.loads(pset_data["Data"]) + [result.id()]))
else:
pset = ifcopenshell.api.pset.add_pset(file, product=element, name="BBIM_Boolean")
data = [result.id()]
ifcopenshell.api.pset.edit_pset(file, pset=pset, properties={"Data": json.dumps(data)})
return result
@@ -0,0 +1,116 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.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/>.
from __future__ import annotations
import json
from typing import Optional, Sequence
import numpy as np
import ifcopenshell.api.pset
import ifcopenshell.util.element
import ifcopenshell.util.unit
from ifcopenshell.util.shape_builder import ShapeBuilder
def clip_solid_bounded(
file: ifcopenshell.file,
item: ifcopenshell.entity_instance,
location: Sequence[float],
normal: Sequence[float],
boundary_points: Sequence[Sequence[float]],
boundary_position: Sequence[float] = (0.0, 0.0, 0.0),
element: Optional[ifcopenshell.entity_instance] = None,
) -> ifcopenshell.entity_instance:
"""Clip a solid with a polygonally bounded half-space, returning an IfcBooleanClippingResult.
Like :func:`clip_solid`, but the boolean subtraction is restricted to the
region enclosed by ``boundary_points`` rather than extending across the
entire half-space. The clipping plane is still infinite, but material is
only removed within the extruded footprint of the polygon.
The ``normal`` convention is the same as :func:`clip_solid`: it points
toward the **removed** material.
After clipping, set the parent ``IfcShapeRepresentation``
``RepresentationType`` to ``"Clipping"``.
Example::
bcr = ifcopenshell.api.run(
"geometry.clip_solid_bounded", model,
item=extrusion,
location=[2.5, 0.0, 2.0],
normal=[0.6, 0.0, 0.8],
boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
)
:param item: The solid to clip (``IfcSweptAreaSolid``, ``IfcSweptDiskSolid``,
or ``IfcBooleanClippingResult``).
:param location: A point on the clipping plane in the representation's
local coordinate system.
:param normal: Plane normal pointing toward the material to be removed.
:param boundary_points: 2D ``[x, y]`` points defining the closed polygonal
boundary in the coordinate system of ``boundary_position``. The polygon
is automatically closed — do not repeat the first point.
:param boundary_position: 3D origin of the boundary coordinate system
(axes default to the global X/Y/Z directions). Defaults to the origin.
:param element: If provided, the resulting ``IfcBooleanClippingResult`` is
registered in the element's ``BBIM_Boolean`` property set so that
:func:`regenerate_wall_representation` preserves it during regeneration.
:return: The resulting ``IfcBooleanClippingResult``.
"""
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file)
builder = ShapeBuilder(file)
normal_arr = np.array(normal)
if np.allclose(normal_arr, [0.0, 0.0, 1.0], atol=1e-2) or np.allclose(normal_arr, [0.0, 0.0, -1.0], atol=1e-2):
arbitrary_vector = np.array([0.0, 1.0, 0.0])
else:
arbitrary_vector = np.array([0.0, 0.0, 1.0])
x_axis = np.cross(normal_arr, arbitrary_vector)
x_axis /= np.linalg.norm(x_axis)
scaled_location = [i / unit_scale for i in location]
plane_placement = builder.create_axis2_placement_3d(scaled_location, normal, x_axis)
plane = file.create_entity("IfcPlane", plane_placement)
scaled_boundary_position = [i / unit_scale for i in boundary_position]
boundary_pos_entity = file.create_entity(
"IfcAxis2Placement3D",
file.create_entity("IfcCartesianPoint", scaled_boundary_position),
)
scaled_pts = [[p[0] / unit_scale, p[1] / unit_scale] for p in boundary_points]
scaled_pts.append(scaled_pts[0]) # close the polygon
ifc_pts = [file.create_entity("IfcCartesianPoint", p) for p in scaled_pts]
boundary = file.createIfcPolyline(ifc_pts)
half_space = file.create_entity("IfcPolygonalBoundedHalfSpace", plane, False, boundary_pos_entity, boundary)
result = file.create_entity("IfcBooleanClippingResult", "DIFFERENCE", item, half_space)
if element is not None:
pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Boolean")
if pset_data:
pset = file.by_id(pset_data["id"])
data = list(set(json.loads(pset_data["Data"]) + [result.id()]))
else:
pset = ifcopenshell.api.pset.add_pset(file, product=element, name="BBIM_Boolean")
data = [result.id()]
ifcopenshell.api.pset.edit_pset(file, pset=pset, properties={"Data": json.dumps(data)})
return result
@@ -0,0 +1,61 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2022 Dion Moult <dion@thinkmoult.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/>.
from typing import Optional
import ifcopenshell
import ifcopenshell.api.owner
import ifcopenshell.guid
import ifcopenshell.util.element
def connect_element(
file: ifcopenshell.file,
relating_element: ifcopenshell.entity_instance,
related_element: ifcopenshell.entity_instance,
description: Optional[str] = None,
) -> ifcopenshell.entity_instance:
incompatible_connections = []
for rel in relating_element.ConnectedFrom:
if rel.is_a() == "IfcRelConnectsElements" and rel.RelatingElement == related_element:
incompatible_connections.append(rel)
for rel in related_element.ConnectedTo:
if rel.is_a() == "IfcRelConnectsElements" and rel.RelatedElement == relating_element:
incompatible_connections.append(rel)
if incompatible_connections:
for connection in set(incompatible_connections):
history = connection.OwnerHistory
file.remove(connection)
if history:
ifcopenshell.util.element.remove_deep2(file, history)
for rel in relating_element.ConnectedTo:
if rel.is_a() == "IfcRelConnectsElements" and rel.RelatedElement == related_element:
rel.Description = description
return rel
return file.createIfcRelConnectsElements(
ifcopenshell.guid.new(),
OwnerHistory=ifcopenshell.api.owner.create_owner_history(file),
Description=description,
RelatingElement=relating_element,
RelatedElement=related_element,
)
@@ -0,0 +1,84 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2022 Dion Moult <dion@thinkmoult.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/>.
from typing import Optional
import ifcopenshell
import ifcopenshell.api.owner
import ifcopenshell.guid
import ifcopenshell.util.element
def connect_path(
file: ifcopenshell.file,
relating_element: ifcopenshell.entity_instance,
related_element: ifcopenshell.entity_instance,
relating_connection: str = "NOTDEFINED",
related_connection: str = "NOTDEFINED",
description: Optional[str] = None,
connection_geometry: Optional[ifcopenshell.entity_instance] = None,
) -> ifcopenshell.entity_instance:
incompatible_connections: list[ifcopenshell.entity_instance] = []
for rel in relating_element.ConnectedTo:
if not rel.is_a("IfcRelConnectsPathElements"):
continue
if rel.RelatedElement == related_element:
incompatible_connections.append(rel)
elif rel.RelatingConnectionType in ["ATSTART", "ATEND"] and rel.RelatingConnectionType == relating_connection:
incompatible_connections.append(rel)
for rel in relating_element.ConnectedFrom:
if not rel.is_a("IfcRelConnectsPathElements"):
continue
if rel.RelatedConnectionType in ["ATSTART", "ATEND"] and rel.RelatedConnectionType == relating_connection:
incompatible_connections.append(rel)
for rel in related_element.ConnectedFrom:
if not rel.is_a("IfcRelConnectsPathElements"):
continue
if rel.RelatedConnectionType in ["ATSTART", "ATEND"] and rel.RelatedConnectionType == related_connection:
incompatible_connections.append(rel)
for rel in related_element.ConnectedTo:
if not rel.is_a("IfcRelConnectsPathElements"):
continue
if rel.RelatedElement == relating_element:
incompatible_connections.append(rel)
elif rel.RelatingConnectionType in ["ATSTART", "ATEND"] and rel.RelatingConnectionType == related_connection:
incompatible_connections.append(rel)
if incompatible_connections:
for connection in set(incompatible_connections):
history = connection.OwnerHistory
file.remove(connection)
if history:
ifcopenshell.util.element.remove_deep2(file, history)
return file.create_entity(
"IfcRelConnectsPathElements",
ifcopenshell.guid.new(),
OwnerHistory=ifcopenshell.api.owner.create_owner_history(file),
Description=description,
ConnectionGeometry=connection_geometry,
RelatingElement=relating_element,
RelatedElement=related_element,
RelatingConnectionType=relating_connection,
RelatedConnectionType=related_connection,
RelatingPriorities=[],
RelatedPriorities=[],
)
@@ -0,0 +1,64 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Dion Moult <dion@thinkmoult.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/>.
from typing import Optional
import numpy as np
import ifcopenshell
import ifcopenshell.api.geometry
import ifcopenshell.util.placement
import ifcopenshell.util.representation
import ifcopenshell.util.shape_builder
def connect_wall(
file: ifcopenshell.file,
wall1: ifcopenshell.entity_instance,
wall2: ifcopenshell.entity_instance,
is_atpath: bool = False,
) -> Optional[ifcopenshell.entity_instance]:
matrix1i = np.linalg.inv(ifcopenshell.util.placement.get_local_placement(wall1.ObjectPlacement))
matrix2 = ifcopenshell.util.placement.get_local_placement(wall2.ObjectPlacement)
axis1 = ifcopenshell.util.representation.get_reference_line(wall1)
axis2 = ifcopenshell.util.representation.get_reference_line(wall2)
axis2[0] = (matrix1i @ matrix2 @ np.concatenate((axis2[0], (0, 1))))[:2]
axis2[1] = (matrix1i @ matrix2 @ np.concatenate((axis2[1], (0, 1))))[:2]
midx = (axis1[0][0] + axis1[1][0]) / 2
starty = axis2[0][1]
endy = axis2[1][1]
y = axis1[0][1]
if (x := ifcopenshell.util.shape_builder.intersect_x_axis_2d(*axis2, y=y)) is None:
return
wall1_end = "ATEND" if x > midx else "ATSTART"
if is_atpath:
wall2_end = "ATPATH"
elif abs(y - starty) < abs(y - endy):
wall2_end = "ATSTART"
else:
wall2_end = "ATEND"
return ifcopenshell.api.geometry.connect_path(
file,
relating_element=wall1,
related_element=wall2,
relating_connection=wall1_end,
related_connection=wall2_end,
)
@@ -0,0 +1,76 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.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/>.
from __future__ import annotations
from typing import Optional
import ifcopenshell.api.geometry
import ifcopenshell.util.element
import ifcopenshell.util.representation
def copy_representation(
file: ifcopenshell.file,
source: ifcopenshell.entity_instance,
target: ifcopenshell.entity_instance,
context_identifier: str = "Body",
) -> Optional[ifcopenshell.entity_instance]:
"""Copy a geometric representation from one element to another.
Finds the named representation on ``source``, deep-copies its entity
graph (geometry items, profiles, placements, etc.), and assigns the copy
to ``target``. Representation contexts are shared rather than copied.
If ``target`` already has a matching representation it is removed and
replaced.
If no matching representation is found on ``source``, returns ``None``
and leaves ``target`` unchanged.
:param source: The element to copy the representation from.
:param target: The element to assign the copied representation to.
:param context_identifier: The RepresentationIdentifier to look up on
``source`` (e.g. ``"Body"``, ``"Axis"``, ``"Box"``).
Defaults to ``"Body"``.
:return: The newly created IfcShapeRepresentation, or None if no
matching representation was found on ``source``.
Example:
.. code:: python
wall_a = model.by_id(1)
wall_b = model.by_id(2)
# Give wall_b the same body geometry as wall_a.
ifcopenshell.api.geometry.copy_representation(model,
source=wall_a, target=wall_b)
"""
source_rep = ifcopenshell.util.representation.get_representation(source, "Model", context_identifier)
if source_rep is None:
return None
new_rep = ifcopenshell.util.element.copy_deep(file, source_rep, exclude=["IfcGeometricRepresentationContext"])
existing_rep = ifcopenshell.util.representation.get_representation(target, "Model", context_identifier)
if existing_rep:
ifcopenshell.api.geometry.unassign_representation(file, product=target, representation=existing_rep)
ifcopenshell.api.geometry.remove_representation(file, representation=existing_rep)
ifcopenshell.api.geometry.assign_representation(file, product=target, representation=new_rep)
return new_rep
@@ -0,0 +1,92 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2023 Dion Moult <dion@thinkmoult.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/>.
from typing import Any
import numpy as np
import ifcopenshell.api.geometry
import ifcopenshell.util.unit
def create_2pt_wall(
file: ifcopenshell.file,
element: ifcopenshell.entity_instance,
context: ifcopenshell.entity_instance,
p1: tuple[float, float],
p2: tuple[float, float],
elevation: float,
height: float,
thickness: float,
is_si: bool = True,
) -> ifcopenshell.entity_instance:
"""
Create a wall between two points (p1 and p2).
A shortcut for geometry.add_wall_representation.
:param element: Wall IFC element.
:param context: IfcGeometricRepresentationContext for the representation.
only Model/Body/MODEL_VIEW type of representations are currently supported.
:param p1: The starting point (x, y) of the wall.
:param p2: The ending point (x, y) of the wall.
:param elevation: The base elevation (z-coordinate) for the wall.
:param height: The height of the wall.
:param thickness: The thickness of the wall.
:param is_si: If True, provided arguments units are treated as SI (meters).
If False, values are converted from project units to SI.
:return: IfcShapeRepresentation.
"""
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(file)
p1_ = np.array(p1).astype(float)
p2_ = np.array(p2).astype(float)
length = float(np.linalg.norm(p2_ - p1_))
if not is_si:
length = convert_unit_to_si(length, si_conversion)
height = convert_unit_to_si(height, si_conversion)
thickness = convert_unit_to_si(thickness, si_conversion)
# No need to convert p2 as length is already calculated.
p1_ = convert_unit_to_si(p1_, si_conversion)
elevation = convert_unit_to_si(elevation, si_conversion)
representation = ifcopenshell.api.geometry.add_wall_representation(
file,
context=context,
length=length,
height=height,
thickness=thickness,
)
v = p2_ - p1_
v /= float(np.linalg.norm(v))
matrix = np.array(
[
[v[0], -v[1], 0, p1_[0]],
[v[1], v[0], 0, p1_[1]],
[0, 0, 1, elevation],
[0, 0, 0, 1],
]
)
ifcopenshell.api.geometry.edit_object_placement(file, product=element, matrix=matrix)
return representation
def convert_unit_to_si(co: Any, si_conversion: float) -> Any:
return co * si_conversion
@@ -0,0 +1,55 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2022 Dion Moult <dion@thinkmoult.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.util.element
def disconnect_element(
file: ifcopenshell.file,
relating_element: ifcopenshell.entity_instance,
related_element: ifcopenshell.entity_instance,
) -> None:
# TODO: arguments relating_element, related_element probably
# should be renamed to element1, element2
# as api call doesn't really treat them as "relating" and "related"
# and just purging all connections between them
incompatible_connections = []
for rel in relating_element.ConnectedTo:
if rel.is_a() == "IfcRelConnectsElements" and rel.RelatedElement == related_element:
incompatible_connections.append(rel)
for rel in relating_element.ConnectedFrom:
if rel.is_a() == "IfcRelConnectsElements" and rel.RelatingElement == related_element:
incompatible_connections.append(rel)
for rel in related_element.ConnectedTo:
if rel.is_a() == "IfcRelConnectsElements" and rel.RelatedElement == relating_element:
incompatible_connections.append(rel)
for rel in related_element.ConnectedFrom:
if rel.is_a() == "IfcRelConnectsElements" and rel.RelatingElement == relating_element:
incompatible_connections.append(rel)
if incompatible_connections:
for connection in set(incompatible_connections):
history = connection.OwnerHistory
file.remove(connection)
if history:
ifcopenshell.util.element.remove_deep2(file, history)
@@ -0,0 +1,58 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2022 Dion Moult <dion@thinkmoult.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/>.
from typing import Optional
import ifcopenshell
import ifcopenshell.util.element
def disconnect_path(
file: ifcopenshell.file,
element: Optional[ifcopenshell.entity_instance] = None,
connection_type: Optional[str] = None,
relating_element: Optional[ifcopenshell.entity_instance] = None,
related_element: Optional[ifcopenshell.entity_instance] = None,
) -> None:
"""There are two options to use this API method:
- provide `element` (connected from) and `connection_type` that should be disconnected.
- provide connected elements to disconnect explicitly:
`relating_element` (connected from) and `related_element` (connected to)
"""
if connection_type and element:
connections = [
r
for r in element.ConnectedTo
if r.is_a("IfcRelConnectsPathElements") and r.RelatingConnectionType == connection_type
] + [
r
for r in element.ConnectedFrom
if r.is_a("IfcRelConnectsPathElements") and r.RelatedConnectionType == connection_type
]
elif related_element:
connections = [
r
for r in relating_element.ConnectedTo
if r.is_a("IfcRelConnectsPathElements") and r.RelatedElement == related_element
]
for connection in set(connections):
history = connection.OwnerHistory
file.remove(connection)
if history:
ifcopenshell.util.element.remove_deep2(file, history)
@@ -0,0 +1,201 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2021 Dion Moult <dion@thinkmoult.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/>.
from typing import Any, Optional, Union
import numpy as np
import numpy.typing as npt
import ifcopenshell.api.owner
import ifcopenshell.util.element
import ifcopenshell.util.placement
import ifcopenshell.util.unit
from ifcopenshell.util.shape_builder import ShapeBuilder
NPArrayOfFloats = npt.NDArray[np.float64]
def edit_object_placement(
file: ifcopenshell.file,
product: ifcopenshell.entity_instance,
matrix: Optional[NPArrayOfFloats] = None,
is_si: bool = True,
should_transform_children: bool = False,
) -> ifcopenshell.entity_instance:
"""Changes the object placement matrix of an element
The placement matrix is a 4x4 matrix describing the location and
orientation of an element in 3D. See
https://docs.ifcopenshell.org/ifcopenshell-python/geometry_creation.html#object-placements
for more details.
This only supports local placements. Grid and linear placements are not
supported.
:param matrix: A 4x4 matrix in numpy. If left blank, it is the identity
matrix (equivalent to ``np.eye(4)``).
:param is_si: If True, the matrix is given in SI units. If false, in
project units.
:param should_transform_children: A child element is a nested element,
opening, filling, etc. If True, child elements move along with the
parent; pass True when moving an assembly (roof, furniture group, etc.)
and you want all children to follow. If False (default), child elements
keep their current world positions; their local placements are rewritten
to compensate for the parent move.
:return: The new or updated IfcLocalPlacement entity
"""
usecase = Usecase()
usecase.file = file
usecase.settings = {
"product": product,
"matrix": matrix if matrix is not None else np.eye(4),
"is_si": is_si,
"should_transform_children": should_transform_children,
}
return usecase.execute()
class Usecase:
file: ifcopenshell.file
settings: dict[str, Any]
def execute(self):
if not hasattr(self.settings["product"], "ObjectPlacement"):
return
self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(self.file)
self.builder = ShapeBuilder(self.file)
if not self.settings["is_si"]:
self.convert_matrix_to_si(self.settings["matrix"])
children_settings = []
if not self.settings["should_transform_children"]:
children_settings = self.get_children_settings(self.settings["product"].ObjectPlacement)
placement_rel_to = self.get_placement_rel_to()
relative_placement = self.get_relative_placement(placement_rel_to)
new_placement = self.file.createIfcLocalPlacement(RelativePlacement=relative_placement)
old_placement = self.settings["product"].ObjectPlacement
if old_placement:
for inverse in self.file.get_inverse(old_placement):
if inverse.is_a("IfcLocalPlacement"):
ifcopenshell.util.element.replace_attribute(inverse, old_placement, new_placement)
if self.file.get_total_inverses(old_placement) == 1:
self.settings["product"].ObjectPlacement = None
old_placement.PlacementRelTo = None
ifcopenshell.util.element.remove_deep2(self.file, old_placement)
new_placement.PlacementRelTo = placement_rel_to
self.settings["product"].ObjectPlacement = new_placement
ifcopenshell.api.owner.update_owner_history(self.file, element=self.settings["product"])
for settings in children_settings:
self.settings = settings
self.execute()
return new_placement
def convert_matrix_to_si(self, matrix: NPArrayOfFloats):
matrix[0][3] *= self.unit_scale
matrix[1][3] *= self.unit_scale
matrix[2][3] *= self.unit_scale
def get_placement_rel_to(self) -> Union[ifcopenshell.entity_instance, None]:
product = self.settings["product"]
relating_object = None
if rels := getattr(product, "Decomposes", None):
relating_object = rels[0].RelatingObject
elif rels := getattr(product, "Nests", None):
relating_object = rels[0].RelatingObject
elif rels := getattr(product, "ContainedIn", None):
relating_object = rels[0].RelatedElement
elif rels := getattr(product, "VoidsElements", None):
relating_object = rels[0].RelatingBuildingElement
elif rels := getattr(product, "FillsVoids", None):
relating_object = rels[0].RelatingOpeningElement
elif rels := getattr(product, "ProjectsElements", None):
relating_object = rels[0].RelatingElement
# TODO: add tests when there will be adherence api
elif rels := getattr(product, "AdheresToElement", None):
relating_object = rels[0].RelatingElement
elif rels := getattr(product, "ContainedInStructure", None):
return rels[0].RelatingStructure.ObjectPlacement
if relating_object:
return getattr(relating_object, "ObjectPlacement", None)
def get_children_settings(self, placement: Union[ifcopenshell.entity_instance, None]) -> list[dict]:
if not placement:
return []
results = []
# NOTE: we ignore subchildren as we already adjust position for their parent
# therefore they're not present in `results` and `should_transform_children` should be `True`
for referenced_placement in placement.ReferencedByPlacements:
matrix = ifcopenshell.util.placement.get_local_placement(referenced_placement)
for obj in referenced_placement.PlacesObject:
if obj.is_a("IfcDistributionPort"):
# Although a port is technically a nested child, it is generally
# more intuitive that the ports always move with the parent.
continue
elif obj.is_a("IfcFeatureElement"):
# Feature elements affect the geometry of their parent, and
# so logically should always move with the parent. However,
# subchildren (fillings) shouldn't move.
placement2 = obj.ObjectPlacement
for referenced_placement2 in placement2.ReferencedByPlacements:
matrix2 = ifcopenshell.util.placement.get_local_placement(referenced_placement2)
for obj2 in referenced_placement2.PlacesObject:
results.append(
{"product": obj2, "matrix": matrix2, "is_si": False, "should_transform_children": True}
)
continue
results.append({"product": obj, "matrix": matrix, "is_si": False, "should_transform_children": True})
return results
def get_relative_placement(
self, placement_rel_to: Union[ifcopenshell.entity_instance, None]
) -> ifcopenshell.entity_instance:
if placement_rel_to:
relating_object_matrix = ifcopenshell.util.placement.get_local_placement(placement_rel_to)
relating_object_matrix[0][3] = self.convert_unit_to_si(relating_object_matrix[0][3])
relating_object_matrix[1][3] = self.convert_unit_to_si(relating_object_matrix[1][3])
relating_object_matrix[2][3] = self.convert_unit_to_si(relating_object_matrix[2][3])
else:
relating_object_matrix = np.eye(4)
m = self.settings["matrix"]
x = np.array((m[0][0], m[1][0], m[2][0]))
z = np.array((m[0][2], m[1][2], m[2][2]))
o = np.array((m[0][3], m[1][3], m[2][3]))
object_matrix = ifcopenshell.util.placement.a2p(o, z, x)
relative_placement_matrix = np.linalg.inv(relating_object_matrix) @ object_matrix
return self.builder.create_axis2_placement_3d(
self.convert_si_to_unit(relative_placement_matrix[:, 3][0:3]),
relative_placement_matrix[:, 2][0:3],
relative_placement_matrix[:, 0][0:3],
)
def convert_si_to_unit(self, co: NPArrayOfFloats) -> NPArrayOfFloats:
return co / self.unit_scale
def convert_unit_to_si(self, co: NPArrayOfFloats) -> NPArrayOfFloats:
return co * self.unit_scale
@@ -0,0 +1,64 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2021 Dion Moult <dion@thinkmoult.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
def map_representation(
file: ifcopenshell.file, representation: ifcopenshell.entity_instance
) -> ifcopenshell.entity_instance:
usecase = Usecase()
usecase.file = file
return usecase.execute(representation)
class Usecase:
file: ifcopenshell.file
def execute(self, representation: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance:
self.representation = representation
mapping_source = self.get_mapping_source()
zero = self.file.createIfcCartesianPoint((0.0, 0.0, 0.0))
x_axis = self.file.createIfcDirection((1.0, 0.0, 0.0))
y_axis = self.file.createIfcDirection((0.0, 1.0, 0.0))
z_axis = self.file.createIfcDirection((0.0, 0.0, 1.0))
mapping_target = self.file.createIfcCartesianTransformationOperator3D(x_axis, y_axis, zero, 1, z_axis)
mapped_item = self.file.createIfcMappedItem(mapping_source, mapping_target)
return self.file.create_entity(
"IfcShapeRepresentation",
**{
"ContextOfItems": representation.ContextOfItems,
"RepresentationIdentifier": representation.RepresentationIdentifier,
"RepresentationType": "MappedRepresentation",
"Items": [mapped_item],
}
)
def get_mapping_source(self) -> ifcopenshell.entity_instance:
for inverse in self.file.get_inverse(self.representation):
if inverse.is_a("IfcRepresentationMap"):
return inverse
zero = self.file.createIfcCartesianPoint((0.0, 0.0, 0.0))
x_axis = self.file.createIfcDirection((1.0, 0.0, 0.0))
z_axis = self.file.createIfcDirection((0.0, 0.0, 1.0))
mapping_origin = self.file.createIfcAxis2Placement3D(zero, z_axis, x_axis)
return self.file.createIfcRepresentationMap(
MappingOrigin=mapping_origin, MappedRepresentation=self.representation
)
@@ -0,0 +1,643 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Dion Moult <dion@thinkmoult.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 json
from collections import namedtuple
from math import cos, sin
from typing import Optional
import numpy as np
import ifcopenshell
import ifcopenshell.api.context
import ifcopenshell.api.geometry
import ifcopenshell.util.element
import ifcopenshell.util.placement
import ifcopenshell.util.representation
import ifcopenshell.util.shape_builder
import ifcopenshell.util.unit
# https://stackoverflow.com/a/9184560/9627415
# Possible optimisation to linalg.norm?
PrioritisedLayer = namedtuple("PrioritisedLayer", "priority thickness")
def regenerate_wall_representation(
file: ifcopenshell.file,
wall: ifcopenshell.entity_instance,
length: float = 1.0,
height: float = 1.0,
angle: Optional[float] = None,
) -> ifcopenshell.entity_instance:
"""
Regenerate the body representation of a wall taking into account connections.
IFC defines how a standard (case) wall should behave that has a material
layer set and connections to other walls using IfcRelConnectsPathElements.
This function will regenerate the body geometry of a wall taking into
account the notches, butts, mitres, etc in the wall due to connections with
other walls.
A standard wall has a 2D axis line as well as parameters defined in terms
of layer thicknesses and priorities. The body geometry is defined as a 2D
XY profile which is extruded in the +Z direction. For this function to
work, a wall must have these defined and the project must have an axis and
body representation context.
For non-sloped walls, a 2D profile is generated and extruded in the +Z
direction. The profile may be a composite profile, if the wall is split due
to wall joins along the path of the wall that protrude all the way through
the wall.
For sloped walls, a basic rectangular 2D profile is extruded, and then
additional extrusions are generated for each connection that boolean
difference the base extrusion.
Clippings applied via :func:`geometry.clip_solid` or
:func:`geometry.clip_solid_bounded` are preserved only if the ``element``
parameter was passed when creating them, which registers the result in the
``BBIM_Boolean`` property set. Clippings created without that parameter
are silently discarded during regeneration.
This will also update the axis line representation (e.g. trim the axis line
to any connections).
The wall's object placement will also be updated such that the placement is
equivalent to the axis line's start point (which therefore becomes (0.0,
0.0)). This is a logical, consistent, and useful placement coordinate
(especially for apps that can pivot using this point).
All this functionality relies on the Plan/Axis/GRAPH_VIEW representation
context. It will be created if it does not exist.
:param wall: The IfcWall for the representation,
only Model/Body/MODEL_VIEW type of representations are currently supported.
:param length: If the wall doesn't have an axis length, this is the default
length in SI units.
:param height: If the wall doesn't already have a height, this is the
default height in SI units.
:param angle: If the wall doesn't already have a slope, this is the default
angle in radians. Left as none or 0 defines no slope.
:return: The newly generated body IfcShapeRepresentation
"""
return Regenerator(file).regenerate(wall, length=length, height=height, angle=angle)
class Regenerator:
def __init__(self, file):
self.file = file
self.body = ifcopenshell.util.representation.get_context(file, "Model", "Body", "MODEL_VIEW")
self.axis = ifcopenshell.util.representation.get_context(file, "Plan", "Axis", "GRAPH_VIEW")
self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file)
self.is_angled = False
if not self.axis:
if not (plan := ifcopenshell.util.representation.get_context(file, "Plan")):
plan = ifcopenshell.api.context.add_context(file, context_type="Plan")
self.axis = ifcopenshell.api.context.add_context(
file, context_type="Plan", context_identifier="Axis", target_view="GRAPH_VIEW", parent=plan
)
def regenerate(self, wall, length=1.0, height=1.0, angle=None):
self.fallback_length = length / self.unit_scale
self.fallback_height = height / self.unit_scale
self.fallback_angle = angle
layers = self.get_layers(wall)
if not layers:
return
reference = ifcopenshell.util.representation.get_reference_line(wall, self.fallback_length)
self.reference_p1, self.reference_p2 = reference
self.wall_vectors = self.get_wall_vectors(wall)
axes = self.get_axes(wall, reference, layers, self.wall_vectors["a"])
self.miny = axes[0][0][1]
self.maxy = axes[-1][0][1]
self.end_point = None
self.start_points = []
self.start_vector = np.array((0.0, 0.0, 1.0))
self.start_offset = 0.0
self.atpath_points = []
self.split_points = []
self.maxpath_points = []
self.minpath_points = []
self.end_points = []
self.end_vector = np.array((0.0, 0.0, 1.0))
self.end_offset = 0.0
manual_booleans = self.get_manual_booleans(wall)
for rel in wall.ConnectedTo:
if rel.is_a("IfcRelConnectsPathElements"):
wall2 = rel.RelatedElement
layers1 = self.combine_layers(layers.copy(), rel.RelatingPriorities)
layers2 = self.combine_layers(self.get_layers(wall2), rel.RelatedPriorities)
if not layers1 or not layers2:
continue
self.join(wall, wall2, layers1, layers2, rel.RelatingConnectionType, rel.RelatedConnectionType)
for rel in wall.ConnectedFrom:
if rel.is_a("IfcRelConnectsPathElements"):
wall2 = rel.RelatingElement
layers1 = self.combine_layers(layers.copy(), rel.RelatedPriorities)
layers2 = self.combine_layers(self.get_layers(wall2), rel.RelatingPriorities)
if not layers1 or not layers2:
continue
self.join(wall, wall2, layers1, layers2, rel.RelatedConnectionType, rel.RelatingConnectionType)
miny = axes[-2][0][1]
maxy = axes[-1][0][1]
if not self.start_points:
minx = axes[0][0][0]
self.start_points = [
np.array((minx, axes[0][0][1])),
np.array((minx, axes[-1][0][1])),
]
if not self.end_points:
maxx = axes[0][1][0]
self.end_points = [
np.array((maxx, axes[0][0][1])),
np.array((maxx, axes[-1][0][1])),
]
if self.start_points[0][1] > self.start_points[-1][1]: # Canonicalise to the +Y direction
self.start_points.reverse()
if self.end_points[0][1] > self.end_points[-1][1]: # Canonicalise to the +Y direction
self.end_points.reverse()
builder = ifcopenshell.util.shape_builder.ShapeBuilder(self.file)
# Don't offset wall if there are manual booleans, because that'll also shift operands
offset = None if manual_booleans else self.reference_p1 * -1
if self.is_angled:
start_points = [p.copy() for p in self.start_points]
end_points = [p.copy() for p in self.end_points]
if self.end_offset > 0:
for point in end_points:
point[0] += self.end_offset
if self.start_offset < 0:
for point in start_points:
point[0] += self.start_offset
points = []
points.extend(start_points)
end_points.reverse()
points.extend(end_points)
item = builder.extrude(
builder.polyline(points, closed=True, position_offset=offset),
magnitude=self.wall_vectors["d"],
extrusion_vector=self.wall_vectors["z"],
)
operands = []
if not np.allclose(self.start_vector, np.array((0.0, 0.0, 1.0))):
points = self.start_points.copy()
while ifcopenshell.util.shape_builder.is_x(points[0][1], points[1][1]):
points.pop(0)
while ifcopenshell.util.shape_builder.is_x(points[-1][1], points[-2][1]):
points.pop()
newx = min([p[0] for p in points]) - abs(self.start_offset)
p1 = points[-1].copy()
p1[0] = newx
p2 = p1.copy()
p2[1] = points[0][1]
points.extend((p1, p2))
magnitude = np.linalg.norm(self.start_vector * (self.wall_vectors["h"] / self.start_vector[2]))
operands.append(
builder.extrude(
builder.polyline(points, closed=True, position_offset=offset),
magnitude=magnitude,
extrusion_vector=self.start_vector,
)
)
if not np.allclose(self.end_vector, np.array((0.0, 0.0, 1.0))):
points = self.end_points.copy()
while ifcopenshell.util.shape_builder.is_x(points[0][1], points[1][1]):
points.pop(0)
while ifcopenshell.util.shape_builder.is_x(points[-1][1], points[-2][1]):
points.pop()
newx = max([p[0] for p in points]) + abs(self.end_offset)
p1 = points[-1].copy()
p1[0] = newx
p2 = p1.copy()
p2[1] = points[0][1]
points.extend((p1, p2))
magnitude = np.linalg.norm(self.end_vector * (self.wall_vectors["h"] / self.end_vector[2]))
operands.append(
builder.extrude(
builder.polyline(points, closed=True, position_offset=offset),
magnitude=magnitude,
extrusion_vector=self.end_vector,
)
)
for atpath_vector, points in self.atpath_points:
if len(points) <= 2:
continue
magnitude = np.linalg.norm(atpath_vector * (self.wall_vectors["h"] / atpath_vector[2]))
operands.append(
builder.extrude(
builder.polyline(points, closed=True, position_offset=offset),
magnitude=magnitude,
extrusion_vector=atpath_vector,
)
)
if operands:
item = ifcopenshell.api.geometry.add_boolean(self.file, first_item=item, second_items=operands)[-1]
else:
# A wall footprint may be multiple profiles if the wall is split into two due to an ATPATH connection
profiles = []
minx = max([p[0] for p in self.start_points])
maxx = min([p[0] for p in self.end_points])
split_points = []
for points in sorted(self.split_points, key=lambda x: x[0][0]): # Sort islands in the +X direction
if any([p[0] > maxx or p[0] < minx for p in points]): # Can't have anything outside our start/end
continue
split_points.append(points)
start_points = [p.copy() for p in self.start_points]
end_points = [p.copy() for p in self.end_points]
split_points.insert(0, start_points)
split_points.append(end_points)
split_points = iter(split_points)
if maxy < miny:
self.maxpath_points, self.minpath_points = self.minpath_points, self.maxpath_points
if self.maxpath_points:
self.maxpath_points[0] = list(reversed(self.maxpath_points[0]))
if self.minpath_points:
self.minpath_points[0] = list(reversed(self.minpath_points[0]))
while True:
# Draw each profile as clockwise starting from (minx, miny)
start_split = next(split_points, None)
if not start_split:
break
end_split = next(split_points, None)
if not end_split:
break
maxy_minx = start_split[-1][0]
maxy_maxx = end_split[-1][0]
miny_minx = start_split[0][0]
miny_maxx = end_split[0][0]
# Do more defensive checks here?
points = start_split
remaining_path_points = []
for maxpath_points in self.maxpath_points:
if maxpath_points[0][0] > maxy_minx and maxpath_points[-1][0] < maxy_maxx:
points.extend(maxpath_points)
else:
remaining_path_points.append(maxpath_points)
self.maxpath_points = remaining_path_points
points.extend(end_split[::-1])
remaining_path_points = []
for minpath_points in self.minpath_points:
if minpath_points[0][0] < miny_maxx and minpath_points[-1][0] > miny_minx:
points.extend(minpath_points)
else:
remaining_path_points.append(minpath_points)
self.minpath_points = remaining_path_points
profiles.append(builder.profile(builder.polyline(points, closed=True, position_offset=offset)))
for points in self.maxpath_points + self.minpath_points:
profiles.append(builder.profile(builder.polyline(points, closed=True, position_offset=offset)))
if len(profiles) > 1:
profile = self.file.createIfcCompositeProfileDef("AREA", Profiles=profiles)
else:
profile = profiles[0]
item = builder.extrude(profile, magnitude=self.wall_vectors["d"], extrusion_vector=self.wall_vectors["z"])
for boolean in self.get_manual_booleans(wall):
boolean.FirstOperand = item
item = boolean
body_rep = builder.get_representation(self.body, items=[item])
if old_rep := ifcopenshell.util.representation.get_representation(wall, self.body):
ifcopenshell.util.element.replace_element(old_rep, body_rep)
ifcopenshell.util.element.remove_deep2(self.file, old_rep)
else:
ifcopenshell.api.geometry.assign_representation(self.file, product=wall, representation=body_rep)
item = builder.polyline([self.reference_p1, self.reference_p2], position_offset=offset)
axis_rep = builder.get_representation(self.axis, items=[item])
if old_rep := ifcopenshell.util.representation.get_representation(wall, self.axis):
ifcopenshell.util.element.replace_element(old_rep, axis_rep)
ifcopenshell.util.element.remove_deep2(self.file, old_rep)
else:
ifcopenshell.api.geometry.assign_representation(self.file, product=wall, representation=axis_rep)
if not np.allclose(self.reference_p1, np.array((0.0, 0.0))) and not manual_booleans:
children = []
for referenced_placement in wall.ObjectPlacement.ReferencedByPlacements:
matrix = ifcopenshell.util.placement.get_local_placement(referenced_placement)
children.append((matrix, referenced_placement.PlacesObject))
matrix = ifcopenshell.util.placement.get_local_placement(wall.ObjectPlacement)
matrix[:, 3] = matrix @ np.concatenate((self.reference_p1, (0, 1)))
ifcopenshell.api.geometry.edit_object_placement(
self.file, product=wall, matrix=matrix, is_si=False, should_transform_children=True
)
# Restore children to their previous location
for matrix, elements in children:
for element in elements:
ifcopenshell.api.geometry.edit_object_placement(
self.file, product=element, matrix=matrix, is_si=False, should_transform_children=True
)
return body_rep
def join(self, wall1, wall2, layers1, layers2, connection1, connection2):
if connection1 == "NOTDEFINED" or connection2 == "NOTDEFINED":
return
if connection1 == "ATPATH" and connection2 == "ATPATH":
return
reference1 = ifcopenshell.util.representation.get_reference_line(wall1, self.fallback_length)
reference2 = ifcopenshell.util.representation.get_reference_line(wall2, self.fallback_length)
wall_vectors2 = self.get_wall_vectors(wall2)
axes1 = self.get_axes(wall1, reference1, layers1, self.wall_vectors["a"])
axes2 = self.get_axes(wall2, reference2, layers2, wall_vectors2["a"])
matrix1i = np.linalg.inv(ifcopenshell.util.placement.get_local_placement(wall1.ObjectPlacement))
matrix2 = ifcopenshell.util.placement.get_local_placement(wall2.ObjectPlacement)
# Convert wall2 data to wall1 local coordinates
for axis in axes2:
axis[0] = (matrix1i @ matrix2 @ np.concatenate((axis[0], (0, 1))))[:2]
axis[1] = (matrix1i @ matrix2 @ np.concatenate((axis[1], (0, 1))))[:2]
reference2[0] = (matrix1i @ matrix2 @ np.concatenate((reference2[0], (0, 1))))[:2]
reference2[1] = (matrix1i @ matrix2 @ np.concatenate((reference2[1], (0, 1))))[:2]
wall_vectors2["z"] = (matrix1i @ matrix2 @ np.append(wall_vectors2["z"], 0.0))[:3]
wall_vectors2["y"] = (matrix1i @ matrix2 @ np.append(wall_vectors2["y"], 0.0))[:3]
axis2 = axes2[0] # Take an arbitrary axis of wall2
if ifcopenshell.util.shape_builder.is_x(axis2[0][1], axis2[1][1]):
return # Parallel
# Sort axes from interior to exterior
if connection1 == "ATEND":
if axes2[0][0][0] > axes2[-1][0][0]: # We process layers in a +X direction
axes2 = list(reversed(axes2))
layers2 = list(reversed(layers2))
elif connection1 == "ATSTART":
if axes2[-1][0][0] > axes2[0][0][0]: # We process layers in a -X direction
axes2 = list(reversed(axes2))
layers2 = list(reversed(layers2))
axis2 = axes2[0] # Take an arbitrary axis of wall2
if connection2 == "ATSTART":
axis2 = [axis2[1], axis2[0]] # Flip direction so the axis "points" in the direction of join
if axis2[0][1] < axis2[1][1]: # Pointing +Y
if axes1[-1][0][1] < axes1[0][0][1]: # We process layers1 in a +Y direction
axes1 = list(reversed(axes1))
layers1 = list(reversed(layers1))
else: # Pointing -Y
if axes1[0][0][1] < axes1[-1][0][1]: # We process layers1 in a -Y direction
axes1 = list(reversed(axes1))
layers1 = list(reversed(layers1))
if connection1 == "ATPATH":
first_axis2 = axes2[0]
last_axis2 = axes2[-1]
first_y = axes1[0][0][1]
last_y = axes1[-1][0][1]
p0 = np.array((ifcopenshell.util.shape_builder.intersect_x_axis_2d(*first_axis2, y=first_y), first_y))
pN = np.array((ifcopenshell.util.shape_builder.intersect_x_axis_2d(*last_axis2, y=first_y), first_y))
# Generate CurveOnRelating/RelatedElement
points = [p0]
axes2 = iter(axes2)
axis2 = next(axes2)
for layer2 in layers2:
ys = iter([a[0][1] for a in axes1])
y = next(ys)
for layer1 in layers1:
if layer2.priority <= layer1.priority:
break
y = next(ys)
p1 = np.array((ifcopenshell.util.shape_builder.intersect_x_axis_2d(*axis2, y=y), y))
axis2 = next(axes2)
p2 = np.array((ifcopenshell.util.shape_builder.intersect_x_axis_2d(*axis2, y=y), y))
if points and np.allclose(points[-1], p1):
points[-1] = p2 # Just slide along previous point
else:
points.extend((p1, p2))
# The curve must end at pN
if not np.allclose(points[-1], pN):
points.append(pN)
# Categorise our points into a segment that either splits or cuts the wall
split_ys = {first_y, last_y}
segment = []
atpath_vector = self.get_join_vector(self.wall_vectors["y"], wall_vectors2["y"])
self.atpath_points.append((atpath_vector, points))
for point in points:
segment.append(point)
if len(segment) == 1: # Not enough points to categorise the segment
continue
elif {segment[0][1], segment[-1][1]} == split_ys: # This segment splits the wall
if segment[0][1] > segment[-1][1]: # Go in the +Y direction
segment.reverse()
self.split_points.append(segment)
segment = []
elif segment[0][1] == segment[-1][1]: # This segment cuts some of the wall
if segment[0][1] == self.maxy: # Go in the +X direction
if segment[0][0] > segment[-1][0]:
segment.reverse()
self.maxpath_points.append(segment)
elif segment[0][1] == self.miny: # Go in the -X direction
if segment[-1][0] > segment[0][0]:
segment.reverse()
self.minpath_points.append(segment)
segment = []
elif connection2 == "ATPATH":
points = []
ys = iter([a[0][1] for a in axes1])
y = next(ys)
for layer1 in layers1:
axes2_iter = iter(axes2)
axis2 = next(axes2_iter)
for layer2 in layers2:
if layer1.priority <= layer2.priority:
break
axis2 = next(axes2_iter)
x = ifcopenshell.util.shape_builder.intersect_x_axis_2d(*axis2, y=y)
p1 = np.array((x, y))
y = next(ys)
x = ifcopenshell.util.shape_builder.intersect_x_axis_2d(*axis2, y=y)
p2 = np.array((x, y))
if points and np.allclose(points[-1], p1):
points.append(p2)
else:
points.extend((p1, p2))
if connection1 == "ATSTART":
self.start_points = points
self.start_vector = self.get_join_vector(self.wall_vectors["y"], wall_vectors2["y"])
self.start_offset = (self.start_vector * (self.wall_vectors["h"] / self.start_vector[2]))[0]
self.reference_p1[0] = ifcopenshell.util.shape_builder.intersect_x_axis_2d(
*reference2, y=reference1[0][1]
)
elif connection1 == "ATEND":
self.end_points = points
self.end_vector = self.get_join_vector(self.wall_vectors["y"], wall_vectors2["y"])
self.end_offset = (self.end_vector * (self.wall_vectors["h"] / self.end_vector[2]))[0]
self.reference_p2[0] = ifcopenshell.util.shape_builder.intersect_x_axis_2d(
*reference2, y=reference1[0][1]
)
else: # A connection at either end of both walls
last_y = axes1[-1][0][1]
ys = iter([a[0][1] for a in axes1])
last_axis2 = axes2[-1]
axes2 = iter(axes2)
axis2 = next(axes2)
y = next(ys)
x = ifcopenshell.util.shape_builder.intersect_x_axis_2d(*axis2, y=y)
points = [np.array((x, y))]
layers1 = iter(layers1)
layers2 = iter(layers2)
layer1 = next(layers1, None)
layer2 = next(layers2, None)
# This creates "mitering" behaviour which is an ambiguity by bSI.
while layer1 and layer2:
if layer1.priority > layer2.priority:
axis2 = next(axes2)
x = ifcopenshell.util.shape_builder.intersect_x_axis_2d(*axis2, y=y)
layer2 = next(layers2, None)
elif layer2.priority > layer1.priority:
y = next(ys)
x = ifcopenshell.util.shape_builder.intersect_x_axis_2d(*axis2, y=y)
layer1 = next(layers1, None)
else:
y = next(ys)
x = ifcopenshell.util.shape_builder.intersect_x_axis_2d(*next(axes2), y=y)
layer1 = next(layers1, None)
layer2 = next(layers2, None)
points.append(np.array((x, y)))
if points[-1][1] != last_y:
points.append(
np.array((ifcopenshell.util.shape_builder.intersect_x_axis_2d(*last_axis2, y=last_y), last_y))
)
if connection1 == "ATSTART":
self.start_points = points
self.start_vector = self.get_join_vector(self.wall_vectors["y"], wall_vectors2["y"])
self.start_offset = (self.start_vector * (self.wall_vectors["h"] / self.start_vector[2]))[0]
self.reference_p1[0] = ifcopenshell.util.shape_builder.intersect_x_axis_2d(
*reference2, y=reference1[0][1]
)
elif connection1 == "ATEND":
self.end_points = points
self.end_vector = self.get_join_vector(self.wall_vectors["y"], wall_vectors2["y"])
self.end_offset = (self.end_vector * (self.wall_vectors["h"] / self.end_vector[2]))[0]
self.reference_p2[0] = ifcopenshell.util.shape_builder.intersect_x_axis_2d(
*reference2, y=reference1[0][1]
)
def get_layers(self, wall) -> list:
material = ifcopenshell.util.element.get_material(wall, should_skip_usage=True)
if not material or not material.is_a("IfcMaterialLayerSet"):
return []
return [PrioritisedLayer(getattr(l, "Priority", 0) or 0, l.LayerThickness) for l in material.MaterialLayers]
def combine_layers(self, layers, override_priorities):
results = []
if override_priorities:
for i, priority in enumerate(override_priorities[: len(layers)]):
layers[i][0] = priority
if not layers:
return []
results = [layers.pop(0)]
for layer in layers:
if not layer.thickness:
continue
if layer.priority == results[-1].priority:
results[-1] = PrioritisedLayer(layer.priority, results[-1].thickness + layer.thickness)
else:
results.append(layer)
return results
def get_wall_vectors(self, wall):
if body := ifcopenshell.util.representation.get_representation(wall, "Model", "Body", "MODEL_VIEW"):
for item in ifcopenshell.util.representation.resolve_representation(body).Items:
while item.is_a("IfcBooleanResult"):
item = item.FirstOperand
if item.is_a("IfcExtrudedAreaSolid"):
z = np.array(item.ExtrudedDirection.DirectionRatios)
z /= np.linalg.norm(z)
y = np.cross(z, np.array((1.0, 0.0, 0.0)))
d = item.Depth
h = (z * d)[2]
a = ifcopenshell.util.shape_builder.np_angle_signed(np.array((0.0, 1.0)), z[1:])
if not ifcopenshell.util.shape_builder.is_x(a, 0):
self.is_angled = True
return {"z": z, "y": y, "a": a, "d": d, "h": h}
elif self.fallback_angle:
a = self.fallback_angle
z = np.array([0.0, sin(a), cos(a)])
y = np.cross(z, np.array((1.0, 0.0, 0.0)))
h = self.fallback_height
d = np.linalg.norm(z * (h / z[2]))
if not ifcopenshell.util.shape_builder.is_x(a, 0):
self.is_angled = True
return {"z": z, "y": y, "a": a, "d": d, "h": h}
return {
"z": np.array((0.0, 0.0, 1.0)),
"y": np.array((0.0, 1.0, 0.0)),
"a": 0.0,
"d": self.fallback_height,
"h": self.fallback_height,
}
def get_join_vector(self, y1, y2):
result = np.cross(y1, y2)
if result[2] < 0:
return result * -1
return result
def get_axes(self, wall: ifcopenshell.entity_instance, reference, layers: list[PrioritisedLayer], angle: float):
axes = [[p.copy() for p in reference]]
# Apply usage to convert the Reference line into MlsBase
sense_factor = 1
if (usage := ifcopenshell.util.element.get_material(wall)) and usage.is_a("IfcMaterialLayerSetUsage"):
for point in axes[0]:
point[1] += usage.OffsetFromReferenceLine
sense_factor = 1 if usage.DirectionSense == "POSITIVE" else -1
for layer in layers:
y_offset = (layer.thickness * sense_factor) / cos(angle)
axes.append([p.copy() + np.array((0.0, y_offset)) for p in axes[-1]])
return axes
def get_manual_booleans(self, element: ifcopenshell.entity_instance):
if pset := ifcopenshell.util.element.get_pset(element, "BBIM_Boolean"):
try:
return [self.file.by_id(boolean_id) for boolean_id in json.loads(pset["Data"])]
except:
return []
return []
@@ -0,0 +1,61 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2022 Dion Moult <dion@thinkmoult.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.util.element
def remove_boolean(file: ifcopenshell.file, item: ifcopenshell.entity_instance) -> None:
"""Removes a boolean operation without deleting the operands
The first operand will replace the boolean result itself, and the second
operand will be reset as a top level representation item.
This may affect the Items of IfcShapeRepresentation, so it is recommended
to run :func:`ifcopenshell.api.geometry.validate_type` after all boolean
modifications are complete.
:param item: This may either be an IfcBooleanResult or an
IfcRepresentationItem that is participating in one or more boolean
results (in which case all are removed).
"""
if not item.is_a("IfcBooleanResult"):
for inverse in file.get_inverse(item):
if inverse.is_a("IfcBooleanResult"):
remove_boolean(file, inverse)
return
representations = []
queue = list(file.get_inverse(item))
while queue:
inverse = queue.pop()
if inverse.is_a("IfcShapeRepresentation"):
representations.append(inverse)
elif inverse.is_a("IfcBooleanResult"):
queue.extend(file.get_inverse(inverse))
elif inverse.is_a("IfcCsgSolid"):
queue.extend(file.get_inverse(inverse))
first = item.FirstOperand
second = item.SecondOperand
for inverse in file.get_inverse(item):
ifcopenshell.util.element.replace_attribute(inverse, item, first)
for representation in set(representations):
representation.Items = list(representation.Items) + [second]
file.remove(item)
@@ -0,0 +1,92 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2021 Dion Moult <dion@thinkmoult.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.util.element
def remove_representation(
file: ifcopenshell.file, representation: ifcopenshell.entity_instance, should_keep_named_profiles: bool = True
) -> None:
"""Remove a representation.
Also purges representation items and their related elements
like IfcStyledItem, tessellated facesets colours and UV map.
By default, named profiles are assumed to be significant (i.e. curated as
part of a profile library) and will not be removed.
:param representation: IfcRepresentation to remove.
Note that it's expected that IfcRepresentation won't be in use
before calling this method (in such elements as IfcProductRepresentation, IfcShapeAspect)
otherwise representation won't be removed.
:param should_keep_named_profiles: If true, named profile defs will not be
removed as they are assumed to be significant.
"""
is_ifc2x3 = file.schema == "IFC2X3"
styled_items = set()
presentation_layer_assignments_items: set[ifcopenshell.entity_instance] = set()
presentation_layer_assignments_reps: set[ifcopenshell.entity_instance] = set()
textures: set[ifcopenshell.entity_instance] = set()
colours: set[ifcopenshell.entity_instance] = set()
named_profiles: set[ifcopenshell.entity_instance] = set()
for subelement in file.traverse(representation):
if subelement.is_a("IfcRepresentationItem"):
[styled_items.add(s) for s in subelement.StyledByItem or []]
# IFC2X3 is using LayerAssignments
for s in subelement.LayerAssignment if not is_ifc2x3 else subelement.LayerAssignments:
presentation_layer_assignments_items.add(s)
# IfcTessellatedFaceSet inverses
if subelement.is_a("IfcTessellatedFaceSet"):
textures.update(subelement.HasTextures)
colours.update(subelement.HasColours)
elif subelement.is_a("IfcRepresentation"):
for layer in subelement.LayerAssignments:
presentation_layer_assignments_reps.add(layer)
elif subelement.is_a("IfcProfileDef") and subelement.ProfileName:
named_profiles.add(subelement)
do_not_delete = file.by_type("IfcGeometricRepresentationContext")
if should_keep_named_profiles:
do_not_delete += named_profiles
# Order matters - layer assignments may reference representation directly.
also_consider = list(presentation_layer_assignments_reps)
also_consider.extend(presentation_layer_assignments_items - presentation_layer_assignments_reps)
also_consider.extend(styled_items)
also_consider.extend(textures)
ifcopenshell.util.element.remove_deep2(
file,
representation,
also_consider=also_consider,
do_not_delete=set(do_not_delete),
)
for texture in textures:
ifcopenshell.util.element.remove_deep2(file, texture)
for colour in colours:
ifcopenshell.util.element.remove_deep2(file, colour)
to_delete = file.to_delete or set()
for element in styled_items:
item = element.Item
if not item or item in to_delete:
file.remove(element)
presentation_layer_assignments = presentation_layer_assignments_reps | presentation_layer_assignments_items
for element in presentation_layer_assignments:
if all(item in to_delete for item in element.AssignedItems):
file.remove(element)
@@ -0,0 +1,113 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2021 Dion Moult <dion@thinkmoult.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/>.
from typing import Any
import ifcopenshell.api.geometry
import ifcopenshell.util.element
def unassign_representation(
file: ifcopenshell.file, product: ifcopenshell.entity_instance, representation: ifcopenshell.entity_instance
) -> None:
usecase = Usecase()
usecase.file = file
usecase.settings = {"product": product, "representation": representation}
return usecase.execute()
class Usecase:
file: ifcopenshell.file
settings: dict[str, Any]
def execute(self) -> None:
product: ifcopenshell.entity_instance = self.settings["product"]
representation: ifcopenshell.entity_instance = self.settings["representation"]
if product.is_a("IfcProduct"):
self.unassign_product_representation(product, representation)
elif product.is_a("IfcTypeProduct"):
self.unassign_type_representation()
def unassign_product_representation(
self, product: ifcopenshell.entity_instance, representation: ifcopenshell.entity_instance
) -> None:
representations = list(product.Representation.Representations or [])
if representation not in representations:
return
representations.remove(representation)
if not representations:
product_def = product.Representation
# TODO: should somehow find matching shape aspect and remove it
# even before the last representation is removed.
self.process_shape_aspects(product_def)
self.file.remove(product_def)
else:
product.Representation.Representations = representations
def unassign_type_representation(self) -> None:
matching_representation_map = None
for representation_map in self.settings["product"].RepresentationMaps or []:
if representation_map.MappedRepresentation == self.settings["representation"]:
matching_representation_map = representation_map
break
if matching_representation_map:
self.unassign_products_using_mapped_representation(matching_representation_map)
self.settings["product"].RepresentationMaps = [
rm for rm in self.settings["product"].RepresentationMaps if rm != matching_representation_map
] or None
self.process_shape_aspects(matching_representation_map)
self.remove_representation_map_only(matching_representation_map)
def process_shape_aspects(self, product_representation: ifcopenshell.entity_instance) -> None:
# Technically IfcShapeAspect doesn't become invalid when product representation is removed,
# but shape aspect makes sense only in context of some other representation.
if self.file.schema == "IFC2X3" and product_representation.is_a("IfcRepresentationMap"):
shape_aspects = [
a
for a in self.file.by_type("IfcShapeAspect")
if a.PartOfProductDefinitionShape == product_representation
]
else:
shape_aspects = product_representation.HasShapeAspects
for shape_aspect in shape_aspects:
representations = shape_aspect.ShapeRepresentations
self.file.remove(shape_aspect)
for rep in representations:
ifcopenshell.api.geometry.remove_representation(self.file, rep)
def remove_representation_map_only(self, representation_map: ifcopenshell.entity_instance) -> None:
representation_map.MappedRepresentation = self.file.createIfcShapeRepresentation()
ifcopenshell.util.element.remove_deep2(self.file, representation_map)
def unassign_products_using_mapped_representation(self, representation_map: ifcopenshell.entity_instance) -> None:
mapped_representations: list[dict[str, ifcopenshell.entity_instance]] = []
just_representations: list[ifcopenshell.entity_instance] = []
for map_usage in representation_map.MapUsage or []:
for inverse in self.file.get_inverse(map_usage):
if not inverse.is_a("IfcShapeRepresentation"):
continue
for definition in inverse.OfProductRepresentation or []:
for product in definition.ShapeOfProduct or []:
mapped_representations.append({"product": product, "representation": inverse})
just_representations.append(inverse)
for item in mapped_representations:
self.unassign_product_representation(item["product"], item["representation"])
for representation in just_representations:
ifcopenshell.api.geometry.remove_representation(self.file, representation=representation)
@@ -0,0 +1,91 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Dion Moult <dion@thinkmoult.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/>.
from typing import Union
import ifcopenshell.api.geometry
import ifcopenshell.util.representation
def validate_type(
file: ifcopenshell.file,
representation: ifcopenshell.entity_instance,
preferred_item: Union[ifcopenshell.entity_instance, None] = None,
) -> bool:
"""Validates the RepresentationType of an IfcShapeRepresentation
A shape representation has to identify its geometry using the
RepresentationType attribute. For example, if it holds tessellated
geometry, it should store "Tessellation" as its RepresentationType.
This function checks whether or not the RepresentationType is valid. This
is a wrapper around :func:`ifcopenshell.util.representation.guess_type`. It
will then set RepresentationType to the most appropriate value, or return
False otherwise. In addition, it also attempts to reconcile otherwise
invalid CSG geometry by unioning all remaining top level items to existing
boolean results.
:param representation: The IfcShapeRepresentation with Items
:param preferred_item: If the type is expected to be a CSG, this will be
the preferred item to union all remaining items to. If no preferred
item is provided, the first boolean result will be chosen.
:return: True if the representation type was set and it is a valid
combination, or False otherwise.
"""
def is_operand(item: ifcopenshell.entity_instance) -> bool:
return (
item.is_a("IfcBooleanResult")
or item.is_a("IfcCsgPrimitive3D")
or item.is_a("IfcHalfSpaceSolid")
or item.is_a("IfcSolidModel")
or item.is_a("IfcTessellatedFaceSet")
)
has_boolean = False
remaining_items = []
for item in representation.Items:
if item.is_a("IfcBooleanResult"):
has_boolean = True
if item != preferred_item and is_operand(item):
remaining_items.append(item)
if not has_boolean:
result = ifcopenshell.util.representation.guess_type(representation.Items)
if result:
representation.RepresentationType = result
return True
return False
if not preferred_item:
# Prioritise an existing boolean result
for i in remaining_items:
if i.is_a("IfcBooleanResult"):
preferred_item = i
break
if not preferred_item and remaining_items:
preferred_item = remaining_items[0]
if remaining_items:
ifcopenshell.api.geometry.add_boolean(file, preferred_item, remaining_items, "UNION")
representation.Items = [i for i in representation.Items if i not in remaining_items]
representation.RepresentationType = ifcopenshell.util.representation.guess_type(representation.Items)
if representation.RepresentationType == "CSG":
return True
return False