ifctrano 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ifctrano/__init__.py ADDED
File without changes
ifctrano/base.py ADDED
@@ -0,0 +1,163 @@
1
+ from typing import Tuple, Literal, List, Annotated, Sequence
2
+
3
+ import ifcopenshell.geom
4
+ import numpy as np
5
+ from numpy import ndarray
6
+ from pydantic import BaseModel, BeforeValidator, ConfigDict
7
+
8
+ settings = ifcopenshell.geom.settings() # type: ignore
9
+ Coordinate = Literal["x", "y", "z"]
10
+ AREA_TOLERANCE = 0.5
11
+
12
+
13
+ class BaseModelConfig(BaseModel):
14
+ model_config = ConfigDict(arbitrary_types_allowed=True)
15
+
16
+
17
+ def round_two_decimals(value: float) -> float:
18
+ return round(value, 10)
19
+
20
+
21
+ class BasePoint(BaseModel):
22
+ x: Annotated[float, BeforeValidator(round_two_decimals)]
23
+ y: Annotated[float, BeforeValidator(round_two_decimals)]
24
+ z: Annotated[float, BeforeValidator(round_two_decimals)]
25
+
26
+ @classmethod
27
+ def from_coordinate(cls, point: Tuple[float, float, float]) -> "BasePoint":
28
+ return cls(x=point[0], y=point[1], z=point[2])
29
+
30
+ def to_array(self) -> np.ndarray: # type: ignore
31
+ return np.array([self.x, self.y, self.z])
32
+
33
+ def to_list(self) -> List[float]:
34
+ return [self.x, self.y, self.z]
35
+
36
+ def to_tuple(self) -> Tuple[float, float, float]:
37
+ return (self.x, self.y, self.z)
38
+
39
+ @classmethod
40
+ def from_array(cls, array: np.ndarray) -> "BasePoint": # type: ignore
41
+ try:
42
+ return cls(x=array[0], y=array[1], z=array[2])
43
+ except IndexError as e:
44
+ raise ValueError("Array must have three components") from e
45
+
46
+ def __eq__(self, other: "BasePoint") -> bool: # type: ignore
47
+ return all([self.x == other.x, self.y == other.y, self.z == other.z])
48
+
49
+
50
+ Signs = Literal[-1, 1]
51
+
52
+
53
+ class Sign(BaseModel):
54
+ x: Signs = 1
55
+ y: Signs = 1
56
+ z: Signs = 1
57
+
58
+ def __hash__(self) -> int:
59
+ return hash((self.x, self.y, self.z))
60
+
61
+
62
+ class Vector(BasePoint):
63
+ def __mul__(self, other: "Vector") -> "Vector":
64
+
65
+ array = np.cross(self.to_array(), other.to_array())
66
+ return Vector(x=array[0], y=array[1], z=array[2])
67
+
68
+ def dot(self, other: "Vector") -> float:
69
+ return np.dot(self.to_array(), other.to_array()) # type: ignore
70
+
71
+ def project(self, other: "Vector") -> "Vector":
72
+ a = self.dot(other) / other.dot(other)
73
+ return Vector(x=a * other.x, y=a * other.y, z=a * other.z)
74
+
75
+ def norm(self) -> "Vector":
76
+ normalized_vector = self.to_array() / np.linalg.norm(self.to_array())
77
+ return Vector(
78
+ x=normalized_vector[0], y=normalized_vector[1], z=normalized_vector[2]
79
+ )
80
+
81
+ def to_array(self) -> np.ndarray: # type: ignore
82
+ return np.array([self.x, self.y, self.z])
83
+
84
+ def get_normal_index(self) -> int:
85
+ normal_index_list = [abs(v) for v in self.to_list()]
86
+ return normal_index_list.index(max(normal_index_list))
87
+
88
+ def is_a_zero(self) -> bool:
89
+ return all(abs(value) < 0.1 for value in self.to_list())
90
+
91
+ @classmethod
92
+ def from_array(cls, array: np.ndarray) -> "Vector": # type: ignore
93
+ return cls.model_validate(super().from_array(array).model_dump())
94
+
95
+
96
+ class Point(BasePoint):
97
+ def __sub__(self, other: "Point") -> Vector:
98
+
99
+ return Vector(x=self.x - other.x, y=self.y - other.y, z=self.z - other.z)
100
+
101
+ def __add__(self, other: "Point") -> "Point":
102
+ return Point(x=self.x + other.x, y=self.y + other.y, z=self.z + other.z)
103
+
104
+ def s(self, signs: Sign) -> "Point":
105
+ return Point(x=self.x * signs.x, y=self.y * signs.y, z=self.z * signs.z)
106
+
107
+
108
+ class P(Point):
109
+ pass
110
+
111
+
112
+ class GlobalId(BaseModelConfig):
113
+ global_id: str
114
+
115
+
116
+ class CoordinateSystem(BaseModel):
117
+ x: Vector
118
+ y: Vector
119
+ z: Vector
120
+
121
+ @classmethod
122
+ def from_array(cls, array: np.ndarray) -> "CoordinateSystem": # type: ignore
123
+ return cls(
124
+ x=Vector.from_array(array[0]),
125
+ y=Vector.from_array(array[1]),
126
+ z=Vector.from_array(array[2]),
127
+ )
128
+
129
+ def to_array(self) -> np.ndarray: # type: ignore
130
+ return np.array([self.x.to_array(), self.y.to_array(), self.z.to_array()])
131
+
132
+ def project(self, array: np.array) -> np.array: # type: ignore
133
+ return np.dot(array, self.to_array()) # type: ignore
134
+
135
+ def inverse(self, array: np.array) -> np.ndarray: # type: ignore
136
+ return np.dot(array, np.linalg.inv(self.to_array())) # type: ignore
137
+
138
+
139
+ class Vertices(BaseModel):
140
+ points: List[Point]
141
+
142
+ @classmethod
143
+ def from_arrays(cls, arrays: Sequence[np.ndarray[np.float64]]) -> "Vertices": # type: ignore
144
+ return cls(
145
+ points=[Point(x=array[0], y=array[1], z=array[2]) for array in arrays]
146
+ )
147
+
148
+ def to_array(self) -> ndarray: # type: ignore
149
+ return np.array([point.to_array() for point in self.points])
150
+
151
+ def to_list(self) -> List[List[float]]:
152
+ return self.to_array().tolist() # type: ignore
153
+
154
+
155
+ class CommonSurface(BaseModel):
156
+ area: float
157
+ orientation: Vector
158
+
159
+ def description(self) -> Tuple[float, List[float]]:
160
+ return self.area, self.orientation.to_list()
161
+
162
+
163
+ Libraries = Literal["IDEAS", "Buildings", "reduced_order", "iso_13790"]
@@ -0,0 +1,245 @@
1
+ from logging import getLogger
2
+ from typing import List, Optional, Any, Tuple
3
+
4
+ import ifcopenshell
5
+ import numpy as np
6
+ from ifcopenshell import entity_instance
7
+ import ifcopenshell.geom
8
+ import ifcopenshell.util.shape
9
+ from pydantic import (
10
+ BaseModel,
11
+ Field,
12
+ )
13
+ from shapely import Polygon # type: ignore
14
+
15
+ from ifctrano.base import (
16
+ Point,
17
+ Vector,
18
+ P,
19
+ Sign,
20
+ CoordinateSystem,
21
+ Vertices,
22
+ BaseModelConfig,
23
+ settings,
24
+ CommonSurface,
25
+ AREA_TOLERANCE,
26
+ )
27
+ from ifctrano.exceptions import BoundingBoxFaceError
28
+
29
+ logger = getLogger(__name__)
30
+
31
+
32
+ def get_normal(
33
+ centroid: Point,
34
+ difference: Point,
35
+ face_signs: List[Sign],
36
+ coordinate_system: CoordinateSystem,
37
+ ) -> Vector:
38
+ point_0 = centroid + difference.s(face_signs[0])
39
+ point_1 = centroid + difference.s(face_signs[1])
40
+ point_2 = centroid + difference.s(face_signs[2])
41
+ vector_1 = coordinate_system.project((point_1 - point_0).to_array())
42
+ vector_2 = coordinate_system.project((point_2 - point_0).to_array())
43
+ array = (
44
+ (Vector.from_array(vector_1) * Vector.from_array(vector_2)).norm().to_array()
45
+ )
46
+ return Vector.from_array(array)
47
+
48
+
49
+ class Polygon2D(BaseModelConfig):
50
+ polygon: Polygon
51
+ normal: Vector
52
+ length: float
53
+
54
+
55
+ class BoundingBoxFace(BaseModelConfig):
56
+ vertices: Vertices
57
+ normal: Vector
58
+ coordinate_system: CoordinateSystem
59
+
60
+ @classmethod
61
+ def build(
62
+ cls,
63
+ centroid: Point,
64
+ difference: Point,
65
+ face_signs: List[Sign],
66
+ coordinate_system: CoordinateSystem,
67
+ ) -> "BoundingBoxFace":
68
+ if len(face_signs) != len(set(face_signs)):
69
+ raise BoundingBoxFaceError("Face signs must be unique")
70
+ normal = get_normal(centroid, difference, face_signs, coordinate_system)
71
+ vertices_ = [(centroid + difference.s(s)).to_list() for s in face_signs]
72
+ vertices_ = [*vertices_, vertices_[0]]
73
+ vertices__ = [coordinate_system.project(v) for v in vertices_]
74
+ vertices = Vertices.from_arrays(vertices__)
75
+
76
+ return cls(
77
+ vertices=vertices, normal=normal, coordinate_system=coordinate_system
78
+ )
79
+
80
+ def get_2d_polygon(self, coordinate_system: CoordinateSystem) -> Polygon2D:
81
+ projected_vertices = coordinate_system.inverse(self.vertices.to_array())
82
+ projected_normal_index = Vector.from_array(
83
+ coordinate_system.inverse(self.normal.to_array())
84
+ ).get_normal_index()
85
+ polygon = Polygon(
86
+ [
87
+ [v_ for i, v_ in enumerate(v) if i != projected_normal_index]
88
+ for v in projected_vertices.tolist()
89
+ ]
90
+ )
91
+
92
+ return Polygon2D(
93
+ polygon=polygon,
94
+ normal=self.normal,
95
+ length=projected_vertices.tolist()[0][projected_normal_index],
96
+ )
97
+
98
+
99
+ class BoundingBoxFaces(BaseModel):
100
+ faces: List[BoundingBoxFace]
101
+
102
+ def description(self) -> List[tuple[Any, Tuple[float, float, float]]]:
103
+ return sorted([(f.vertices.to_list(), f.normal.to_tuple()) for f in self.faces])
104
+
105
+ @classmethod
106
+ def build(
107
+ cls, centroid: Point, difference: Point, coordinate_system: CoordinateSystem
108
+ ) -> "BoundingBoxFaces":
109
+ face_signs = [
110
+ [Sign(x=-1, y=-1, z=-1), Sign(y=-1, z=-1), Sign(z=-1), Sign(x=-1, z=-1)],
111
+ [Sign(x=-1, y=-1), Sign(y=-1), Sign(), Sign(x=-1)],
112
+ [
113
+ Sign(x=-1, y=-1, z=-1),
114
+ Sign(x=-1, y=1, z=-1),
115
+ Sign(x=-1, y=1, z=1),
116
+ Sign(x=-1, y=-1, z=1),
117
+ ],
118
+ [
119
+ Sign(x=1, y=-1, z=-1),
120
+ Sign(x=1, y=1, z=-1),
121
+ Sign(x=1, y=1, z=1),
122
+ Sign(x=1, y=-1, z=1),
123
+ ],
124
+ [
125
+ Sign(x=-1, y=-1, z=-1),
126
+ Sign(x=1, y=-1, z=-1),
127
+ Sign(x=1, y=-1, z=1),
128
+ Sign(x=-1, y=-1, z=1),
129
+ ],
130
+ [
131
+ Sign(x=-1, y=1, z=-1),
132
+ Sign(x=1, y=1, z=-1),
133
+ Sign(x=1, y=1, z=1),
134
+ Sign(x=-1, y=1, z=1),
135
+ ],
136
+ ]
137
+ faces = [
138
+ BoundingBoxFace.build(centroid, difference, face_sign, coordinate_system)
139
+ for face_sign in face_signs
140
+ ]
141
+ return cls(faces=faces)
142
+
143
+
144
+ class ExtendCommonSurface(CommonSurface):
145
+ distance: float
146
+
147
+ def to_common_surface(self) -> CommonSurface:
148
+ return CommonSurface(area=self.area, orientation=self.orientation)
149
+
150
+
151
+ class OrientedBoundingBox(BaseModel):
152
+ faces: BoundingBoxFaces
153
+ centroid: Point
154
+ area_tolerance: float = Field(default=AREA_TOLERANCE)
155
+
156
+ def intersect_faces(self, other: "OrientedBoundingBox") -> Optional[CommonSurface]:
157
+ extend_surfaces = []
158
+ for face in self.faces.faces:
159
+
160
+ for other_face in other.faces.faces:
161
+ vector = face.normal * other_face.normal
162
+ if vector.is_a_zero():
163
+
164
+ polygon_1 = other_face.get_2d_polygon(face.coordinate_system)
165
+ polygon_2 = face.get_2d_polygon(face.coordinate_system)
166
+ intersection = polygon_2.polygon.intersection(polygon_1.polygon)
167
+
168
+ if intersection.area > self.area_tolerance:
169
+ distance = abs(polygon_1.length - polygon_2.length)
170
+ area = intersection.area
171
+ direction_vector = (other.centroid - self.centroid).norm()
172
+ orientation = direction_vector.project(face.normal).norm()
173
+ extend_surfaces.append(
174
+ ExtendCommonSurface(
175
+ distance=distance, area=area, orientation=orientation
176
+ )
177
+ )
178
+ if extend_surfaces:
179
+ if not all(
180
+ e.orientation == extend_surfaces[0].orientation for e in extend_surfaces
181
+ ):
182
+ logger.warning("Different orientations found. taking the max area")
183
+ max_area = max([e.area for e in extend_surfaces])
184
+ extend_surfaces = [e for e in extend_surfaces if e.area == max_area]
185
+ extend_surface = sorted(
186
+ extend_surfaces, key=lambda x: x.distance, reverse=True
187
+ )[-1]
188
+ return extend_surface.to_common_surface()
189
+ return None
190
+
191
+ @classmethod
192
+ def from_vertices(
193
+ cls, vertices: np.ndarray[tuple[int, ...], np.dtype[np.float64]]
194
+ ) -> "OrientedBoundingBox":
195
+ vertices_np = np.array(vertices)
196
+ points = np.asarray(vertices_np)
197
+ cov = np.cov(points, y=None, rowvar=0, bias=1) # type: ignore
198
+ v, vect = np.linalg.eig(cov)
199
+ tvect = np.transpose(vect)
200
+ points_r = np.dot(points, np.linalg.inv(tvect))
201
+
202
+ co_min = np.min(points_r, axis=0)
203
+ co_max = np.max(points_r, axis=0)
204
+
205
+ xmin, xmax = co_min[0], co_max[0]
206
+ ymin, ymax = co_min[1], co_max[1]
207
+ zmin, zmax = co_min[2], co_max[2]
208
+
209
+ xdif = (xmax - xmin) * 0.5
210
+ ydif = (ymax - ymin) * 0.5
211
+ zdif = (zmax - zmin) * 0.5
212
+
213
+ cx = xmin + xdif
214
+ cy = ymin + ydif
215
+ cz = zmin + zdif
216
+ corners = np.array(
217
+ [
218
+ [cx - xdif, cy - ydif, cz - zdif],
219
+ [cx - xdif, cy + ydif, cz - zdif],
220
+ [cx - xdif, cy + ydif, cz + zdif],
221
+ [cx - xdif, cy - ydif, cz + zdif],
222
+ [cx + xdif, cy + ydif, cz + zdif],
223
+ [cx + xdif, cy + ydif, cz - zdif],
224
+ [cx + xdif, cy - ydif, cz + zdif],
225
+ [cx + xdif, cy - ydif, cz - zdif],
226
+ ]
227
+ )
228
+ corners = np.dot(corners, tvect)
229
+ coordinate_system = CoordinateSystem.from_array(tvect)
230
+ c = P(x=cx, y=cy, z=cz)
231
+ d = P(x=xdif, y=ydif, z=zdif)
232
+ faces = BoundingBoxFaces.build(c, d, coordinate_system)
233
+ return cls(
234
+ faces=faces,
235
+ centroid=Point.from_array(coordinate_system.project(c.to_array())),
236
+ )
237
+
238
+ @classmethod
239
+ def from_entity(cls, entity: entity_instance) -> "OrientedBoundingBox":
240
+ entity_shape = ifcopenshell.geom.create_shape(settings, entity)
241
+
242
+ vertices = ifcopenshell.util.shape.get_shape_vertices(
243
+ entity_shape, entity_shape.geometry # type: ignore
244
+ )
245
+ return cls.from_vertices(vertices)
ifctrano/building.py ADDED
@@ -0,0 +1,55 @@
1
+ from pathlib import Path
2
+ from typing import List
3
+
4
+ import ifcopenshell
5
+ from ifcopenshell import file, entity_instance
6
+ from pydantic import validate_call
7
+ from trano.elements.library.library import Library # type: ignore
8
+
9
+ from trano.topology import Network # type: ignore
10
+
11
+ from ifctrano.base import BaseModelConfig, Libraries
12
+ from ifctrano.exceptions import IfcFileNotFoundError
13
+ from ifctrano.space_boundary import SpaceBoundaries, initialize_tree
14
+
15
+
16
+ def get_spaces(ifcopenshell_file: file) -> List[entity_instance]:
17
+ return ifcopenshell_file.by_type("IfcSpace")
18
+
19
+
20
+ class Building(BaseModelConfig):
21
+ name: str
22
+ space_boundaries: List[SpaceBoundaries]
23
+ ifc_file: file
24
+ parent_folder: Path
25
+
26
+ @classmethod
27
+ def from_ifc(cls, ifc_file_path: Path) -> "Building":
28
+ if not ifc_file_path.exists():
29
+ raise IfcFileNotFoundError(
30
+ f"File specified {ifc_file_path} does not exist."
31
+ )
32
+ ifc_file = ifcopenshell.open(str(ifc_file_path))
33
+ tree = initialize_tree(ifc_file)
34
+ spaces = get_spaces(ifc_file)
35
+ space_boundaries = [
36
+ SpaceBoundaries.from_space_entity(ifc_file, tree, space) for space in spaces
37
+ ]
38
+ return cls(
39
+ space_boundaries=space_boundaries,
40
+ ifc_file=ifc_file,
41
+ parent_folder=ifc_file_path.parent,
42
+ name=ifc_file_path.stem,
43
+ )
44
+
45
+ @validate_call
46
+ def create_model(self, library: Libraries = "Buildings") -> str:
47
+ network = Network(name=self.name, library=Library.from_configuration(library))
48
+ network.add_boiler_plate_spaces(
49
+ [space_boundary.model() for space_boundary in self.space_boundaries]
50
+ )
51
+ return network.model() # type: ignore
52
+
53
+ def save_model(self, library: Libraries = "Buildings") -> None:
54
+ model_ = self.create_model(library)
55
+ Path(self.parent_folder.joinpath(f"{self.name}.mo")).write_text(model_)
ifctrano/exceptions.py ADDED
@@ -0,0 +1,10 @@
1
+ class NoIntersectionAreaFoundError(Exception):
2
+ """Raised when no intersection area is found between two polygons"""
3
+
4
+
5
+ class BoundingBoxFaceError(Exception):
6
+ pass
7
+
8
+
9
+ class IfcFileNotFoundError(FileNotFoundError):
10
+ pass
ifctrano/main.py ADDED
@@ -0,0 +1,44 @@
1
+ from pathlib import Path
2
+ from typing import Annotated
3
+
4
+ import typer
5
+ from pydantic import validate_call
6
+ from rich.progress import Progress, SpinnerColumn, TextColumn
7
+
8
+ from ifctrano.base import Libraries
9
+ from ifctrano.building import Building
10
+
11
+ app = typer.Typer()
12
+ CHECKMARK = "[green]✔[/green]"
13
+ CROSS_MARK = "[red]✘[/red]"
14
+
15
+
16
+ @app.command()
17
+ @validate_call
18
+ def create(
19
+ model: Annotated[
20
+ str,
21
+ typer.Argument(help="Local path to the ifc file."),
22
+ ],
23
+ library: Annotated[
24
+ Libraries,
25
+ typer.Argument(help="Modelica library to be used for simulation."),
26
+ ] = "Buildings",
27
+ ) -> None:
28
+ with Progress(
29
+ SpinnerColumn(),
30
+ TextColumn("[progress.description]{task.description}"),
31
+ transient=True,
32
+ ) as progress:
33
+ modelica_model_path = Path(model).resolve().with_suffix(".mo")
34
+ task = progress.add_task(
35
+ description=f"Generating model {modelica_model_path.name} with library {library} from {model}",
36
+ total=None,
37
+ )
38
+ building = Building.from_ifc(Path(model))
39
+ modelica_model = building.create_model()
40
+ progress.update(task, completed=True)
41
+ task = progress.add_task(description="Writing model to file...", total=None)
42
+ modelica_model_path.write_text(modelica_model)
43
+ progress.remove_task(task)
44
+ print(f"{CHECKMARK} Model generated at {modelica_model_path}")
@@ -0,0 +1,164 @@
1
+ import multiprocessing
2
+ import re
3
+ from typing import Optional, List, Tuple, Any
4
+
5
+ import ifcopenshell
6
+ import ifcopenshell.geom
7
+ import ifcopenshell.util.shape
8
+ from ifcopenshell import entity_instance, file
9
+ from pydantic import BaseModel, Field
10
+ from trano.elements import Space as TranoSpace, ExternalWall, Window, BaseWall # type: ignore
11
+ from trano.elements.construction import Construction, Layer, Material # type: ignore
12
+ from trano.elements.system import Occupancy # type: ignore
13
+ from trano.elements.types import Azimuth, Tilt # type: ignore
14
+
15
+ from ifctrano.base import GlobalId, settings, BaseModelConfig, CommonSurface
16
+ from ifctrano.bounding_box import OrientedBoundingBox
17
+
18
+
19
+ def initialize_tree(ifc_file: file) -> ifcopenshell.geom.tree:
20
+
21
+ tree = ifcopenshell.geom.tree()
22
+
23
+ iterator = ifcopenshell.geom.iterator(
24
+ settings, ifc_file, multiprocessing.cpu_count()
25
+ )
26
+ if iterator.initialize(): # type: ignore
27
+ while True:
28
+ tree.add_element(iterator.get()) # type: ignore
29
+ if not iterator.next(): # type: ignore
30
+ break
31
+ return tree
32
+
33
+
34
+ def remove_non_alphanumeric(text: str) -> str:
35
+ return re.sub(r"[^a-zA-Z0-9]", "", text).lower()
36
+
37
+
38
+ class Space(GlobalId):
39
+ name: Optional[str] = None
40
+ bounding_box: OrientedBoundingBox
41
+ entity: entity_instance
42
+
43
+ def space_name(self) -> str:
44
+ main_name = (
45
+ remove_non_alphanumeric(self.name)
46
+ if self.name
47
+ else remove_non_alphanumeric(self.entity.GlobalId)
48
+ )
49
+ return f"space_{main_name}_{self.entity.GlobalId}"
50
+
51
+
52
+ material_1 = Material(
53
+ name="material_1",
54
+ thermal_conductivity=0.046,
55
+ specific_heat_capacity=940,
56
+ density=80,
57
+ )
58
+ construction = Construction(
59
+ name="construction_4",
60
+ layers=[
61
+ Layer(material=material_1, thickness=0.18),
62
+ ],
63
+ )
64
+
65
+
66
+ class SpaceBoundary(BaseModelConfig):
67
+ bounding_box: OrientedBoundingBox
68
+ entity: entity_instance
69
+ common_surface: CommonSurface
70
+ adjacent_space: Optional[Space] = None
71
+
72
+ def model_element(self) -> Optional[BaseWall]:
73
+ if "wall" in self.entity.is_a().lower():
74
+ return ExternalWall(
75
+ surface=6.44,
76
+ azimuth=Azimuth.south,
77
+ tilt=Tilt.wall,
78
+ construction=construction,
79
+ )
80
+ if "window" in self.entity.is_a().lower():
81
+ return Window(
82
+ surface=6.44,
83
+ azimuth=Azimuth.south,
84
+ tilt=Tilt.wall,
85
+ construction=construction,
86
+ )
87
+
88
+ return None
89
+
90
+ @classmethod
91
+ def from_space_and_element(
92
+ cls, bounding_box: OrientedBoundingBox, entity: entity_instance
93
+ ) -> Optional["SpaceBoundary"]:
94
+ bounding_box_ = OrientedBoundingBox.from_entity(entity)
95
+ common_surface = bounding_box.intersect_faces(bounding_box_)
96
+ if common_surface:
97
+ return cls(
98
+ bounding_box=bounding_box_, entity=entity, common_surface=common_surface
99
+ )
100
+ return None
101
+
102
+ def description(self) -> Tuple[float, Tuple[float, ...], Any, str]:
103
+ return (
104
+ self.common_surface.area,
105
+ self.common_surface.orientation.to_tuple(),
106
+ self.entity.GlobalId,
107
+ self.entity.is_a(),
108
+ )
109
+
110
+
111
+ class SpaceBoundaries(BaseModel):
112
+ space: Space
113
+ boundaries: List[SpaceBoundary] = Field(default_factory=list)
114
+
115
+ def model(self) -> TranoSpace:
116
+ return TranoSpace(
117
+ name=self.space.space_name(),
118
+ occupancy=Occupancy(),
119
+ external_boundaries=[
120
+ boundary.model_element()
121
+ for boundary in self.boundaries
122
+ if boundary.model_element()
123
+ ],
124
+ )
125
+
126
+ @classmethod
127
+ def from_space_entity(
128
+ cls,
129
+ ifcopenshell_file: file,
130
+ tree: ifcopenshell.geom.tree,
131
+ space: entity_instance,
132
+ ) -> "SpaceBoundaries":
133
+ bounding_box = OrientedBoundingBox.from_entity(space)
134
+ space_ = Space(
135
+ global_id=space.GlobalId,
136
+ name=space.Name,
137
+ bounding_box=bounding_box,
138
+ entity=space,
139
+ )
140
+ clashes = tree.clash_clearance_many(
141
+ [space],
142
+ ifcopenshell_file.by_type("IfcWall")
143
+ + ifcopenshell_file.by_type("IfcSlab")
144
+ + ifcopenshell_file.by_type("IfcRoof")
145
+ + ifcopenshell_file.by_type("IfcDoor")
146
+ + ifcopenshell_file.by_type("IfcWindow"),
147
+ clearance=0.1,
148
+ )
149
+ space_boundaries = []
150
+
151
+ for clash in clashes:
152
+ elements = [
153
+ ifcopenshell_file.by_guid(clash.a.get_argument(0)),
154
+ ifcopenshell_file.by_guid(clash.b.get_argument(0)),
155
+ ]
156
+ for element in elements:
157
+ if element.GlobalId == space.GlobalId:
158
+ continue
159
+ space_boundary = SpaceBoundary.from_space_and_element(
160
+ bounding_box, element
161
+ )
162
+ if space_boundary:
163
+ space_boundaries.append(space_boundary)
164
+ return cls(space=space_, boundaries=space_boundaries)