ifctrano 0.1.11__tar.gz → 0.2.0__tar.gz
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-0.1.11 → ifctrano-0.2.0}/PKG-INFO +20 -5
- {ifctrano-0.1.11 → ifctrano-0.2.0}/README.md +14 -0
- ifctrano-0.2.0/ifctrano/__init__.py +3 -0
- ifctrano-0.2.0/ifctrano/base.py +430 -0
- ifctrano-0.2.0/ifctrano/bounding_box.py +196 -0
- {ifctrano-0.1.11 → ifctrano-0.2.0}/ifctrano/building.py +104 -52
- ifctrano-0.2.0/ifctrano/construction.py +227 -0
- ifctrano-0.2.0/ifctrano/example/verification.ifc +3 -0
- {ifctrano-0.1.11 → ifctrano-0.2.0}/ifctrano/main.py +55 -2
- {ifctrano-0.1.11 → ifctrano-0.2.0}/ifctrano/space_boundary.py +100 -98
- ifctrano-0.2.0/ifctrano/types.py +5 -0
- ifctrano-0.2.0/ifctrano/utils.py +29 -0
- {ifctrano-0.1.11 → ifctrano-0.2.0}/pyproject.toml +4 -3
- ifctrano-0.1.11/ifctrano/__init__.py +0 -0
- ifctrano-0.1.11/ifctrano/base.py +0 -186
- ifctrano-0.1.11/ifctrano/bounding_box.py +0 -267
- ifctrano-0.1.11/ifctrano/example/verification.ifc +0 -3043
- {ifctrano-0.1.11 → ifctrano-0.2.0}/LICENSE +0 -0
- {ifctrano-0.1.11 → ifctrano-0.2.0}/ifctrano/exceptions.py +0 -0
@@ -1,8 +1,7 @@
|
|
1
|
-
Metadata-Version: 2.
|
1
|
+
Metadata-Version: 2.3
|
2
2
|
Name: ifctrano
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.2.0
|
4
4
|
Summary: Package for generating building energy simulation model from IFC
|
5
|
-
Home-page: https://github.com/andoludo/ifctrano
|
6
5
|
License: GPL V3
|
7
6
|
Keywords: BIM,IFC,energy simulation,modelica,building energy simulation,buildings,ideas
|
8
7
|
Author: Ando Andriamamonjy
|
@@ -12,11 +11,13 @@ Classifier: License :: Other/Proprietary License
|
|
12
11
|
Classifier: Programming Language :: Python :: 3
|
13
12
|
Classifier: Programming Language :: Python :: 3.10
|
14
13
|
Classifier: Programming Language :: Python :: 3.11
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
15
15
|
Requires-Dist: ifcopenshell (>=0.8.1.post1,<0.9.0)
|
16
|
-
Requires-Dist:
|
16
|
+
Requires-Dist: open3d (>=0.19.0,<0.20.0)
|
17
17
|
Requires-Dist: shapely (>=2.0.7,<3.0.0)
|
18
|
-
Requires-Dist: trano (>=0.
|
18
|
+
Requires-Dist: trano (>=0.2.0,<0.3.0)
|
19
19
|
Requires-Dist: typer (>=0.12.5,<0.13.0)
|
20
|
+
Requires-Dist: vedo (>=2025.5.3,<2026.0.0)
|
20
21
|
Project-URL: Repository, https://github.com/andoludo/ifctrano
|
21
22
|
Description-Content-Type: text/markdown
|
22
23
|
|
@@ -36,6 +37,20 @@ ifctrano --help
|
|
36
37
|
ifctrano verify
|
37
38
|
```
|
38
39
|
|
40
|
+
# ⚠️ WARNING ⚠️
|
41
|
+
|
42
|
+
**This package is still under construction and is largely a work in progress.**
|
43
|
+
There are still several aspects that need further development, including:
|
44
|
+
|
45
|
+
- Material and construction extraction
|
46
|
+
- Slab and roof boundaries
|
47
|
+
- Systems integration
|
48
|
+
- Additional validation
|
49
|
+
- Bug fixes
|
50
|
+
- ...
|
51
|
+
-
|
52
|
+
Help and contribution are more than appreciated! 🚧
|
53
|
+
|
39
54
|
## Overview
|
40
55
|
ifctrano is yet another **IFC to energy simulation** tool designed to translate **Industry Foundation Classes (IFC)** models into energy simulation models in **Modelica**.
|
41
56
|
|
@@ -14,6 +14,20 @@ ifctrano --help
|
|
14
14
|
ifctrano verify
|
15
15
|
```
|
16
16
|
|
17
|
+
# ⚠️ WARNING ⚠️
|
18
|
+
|
19
|
+
**This package is still under construction and is largely a work in progress.**
|
20
|
+
There are still several aspects that need further development, including:
|
21
|
+
|
22
|
+
- Material and construction extraction
|
23
|
+
- Slab and roof boundaries
|
24
|
+
- Systems integration
|
25
|
+
- Additional validation
|
26
|
+
- Bug fixes
|
27
|
+
- ...
|
28
|
+
-
|
29
|
+
Help and contribution are more than appreciated! 🚧
|
30
|
+
|
17
31
|
## Overview
|
18
32
|
ifctrano is yet another **IFC to energy simulation** tool designed to translate **Industry Foundation Classes (IFC)** models into energy simulation models in **Modelica**.
|
19
33
|
|
@@ -0,0 +1,430 @@
|
|
1
|
+
import json
|
2
|
+
import math
|
3
|
+
from pathlib import Path
|
4
|
+
from typing import Tuple, Literal, List, Annotated, Any, Dict, cast
|
5
|
+
|
6
|
+
import ifcopenshell.geom
|
7
|
+
import numpy as np
|
8
|
+
import open3d # type: ignore
|
9
|
+
from numpy import ndarray
|
10
|
+
from pydantic import (
|
11
|
+
BaseModel,
|
12
|
+
BeforeValidator,
|
13
|
+
ConfigDict,
|
14
|
+
model_validator,
|
15
|
+
computed_field,
|
16
|
+
)
|
17
|
+
from shapely.geometry.polygon import Polygon # type: ignore
|
18
|
+
from vedo import Line, Arrow, Mesh, show, write # type: ignore
|
19
|
+
|
20
|
+
from ifctrano.exceptions import VectorWithNansError
|
21
|
+
from multiprocessing import Process
|
22
|
+
|
23
|
+
settings = ifcopenshell.geom.settings() # type: ignore
|
24
|
+
Coordinate = Literal["x", "y", "z"]
|
25
|
+
AREA_TOLERANCE = 0.5
|
26
|
+
ROUNDING_FACTOR = 5
|
27
|
+
CLASH_CLEARANCE = 0.5
|
28
|
+
|
29
|
+
|
30
|
+
class BaseModelConfig(BaseModel):
|
31
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
32
|
+
|
33
|
+
|
34
|
+
def round_two_decimals(value: float) -> float:
|
35
|
+
return round(value, 10)
|
36
|
+
|
37
|
+
|
38
|
+
def _show(lines: List[Line], interactive: bool = True) -> None:
|
39
|
+
show(
|
40
|
+
*lines,
|
41
|
+
axes=1,
|
42
|
+
viewup="z",
|
43
|
+
bg="white",
|
44
|
+
interactive=interactive,
|
45
|
+
)
|
46
|
+
|
47
|
+
|
48
|
+
class BaseShow(BaseModel):
|
49
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
50
|
+
|
51
|
+
def lines(self) -> List[Line]: ... # type: ignore
|
52
|
+
|
53
|
+
def description(self) -> Any: ... # noqa: ANN401
|
54
|
+
|
55
|
+
def show(self, interactive: bool = True) -> None:
|
56
|
+
p = Process(target=_show, args=(self.lines(), interactive))
|
57
|
+
p.start()
|
58
|
+
|
59
|
+
def write(self) -> None:
|
60
|
+
|
61
|
+
write(
|
62
|
+
*self.lines(),
|
63
|
+
axes=1,
|
64
|
+
viewup="z",
|
65
|
+
bg="white",
|
66
|
+
interactive=True,
|
67
|
+
)
|
68
|
+
|
69
|
+
@classmethod
|
70
|
+
def load_description(cls, file_path: Path) -> Dict[str, Any]:
|
71
|
+
return cast(Dict[str, Any], json.loads(file_path.read_text()))
|
72
|
+
|
73
|
+
def save_description(self, file_path: Path) -> None:
|
74
|
+
file_path.write_text(json.dumps(sorted(self.description()), indent=4))
|
75
|
+
|
76
|
+
def description_loaded(self) -> Dict[str, Any]:
|
77
|
+
return cast(Dict[str, Any], json.loads(json.dumps(sorted(self.description()))))
|
78
|
+
|
79
|
+
|
80
|
+
class BasePoint(BaseModel):
|
81
|
+
x: Annotated[float, BeforeValidator(round_two_decimals)]
|
82
|
+
y: Annotated[float, BeforeValidator(round_two_decimals)]
|
83
|
+
z: Annotated[float, BeforeValidator(round_two_decimals)]
|
84
|
+
|
85
|
+
@classmethod
|
86
|
+
def from_coordinate(cls, point: Tuple[float, float, float]) -> "BasePoint":
|
87
|
+
return cls(x=point[0], y=point[1], z=point[2])
|
88
|
+
|
89
|
+
def to_array(self) -> np.ndarray: # type: ignore
|
90
|
+
return np.array([self.x, self.y, self.z])
|
91
|
+
|
92
|
+
def to_list(self) -> List[float]:
|
93
|
+
return [self.x, self.y, self.z]
|
94
|
+
|
95
|
+
def to_tuple(self) -> Tuple[float, float, float]:
|
96
|
+
return (self.x, self.y, self.z)
|
97
|
+
|
98
|
+
@classmethod
|
99
|
+
def from_array(cls, array: np.ndarray) -> "BasePoint": # type: ignore
|
100
|
+
try:
|
101
|
+
return cls(x=array[0], y=array[1], z=array[2])
|
102
|
+
except IndexError as e:
|
103
|
+
raise ValueError("Array must have three components") from e
|
104
|
+
|
105
|
+
def __eq__(self, other: "BasePoint") -> bool: # type: ignore
|
106
|
+
return all([self.x == other.x, self.y == other.y, self.z == other.z])
|
107
|
+
|
108
|
+
|
109
|
+
Signs = Literal[-1, 1]
|
110
|
+
|
111
|
+
|
112
|
+
class Sign(BaseModel):
|
113
|
+
x: Signs = 1
|
114
|
+
y: Signs = 1
|
115
|
+
z: Signs = 1
|
116
|
+
|
117
|
+
def __hash__(self) -> int:
|
118
|
+
return hash((self.x, self.y, self.z))
|
119
|
+
|
120
|
+
|
121
|
+
class Vector(BasePoint):
|
122
|
+
|
123
|
+
@model_validator(mode="after")
|
124
|
+
def _validator(self) -> "Vector":
|
125
|
+
if any(np.isnan(v) for v in self.to_list()):
|
126
|
+
raise VectorWithNansError("Vector cannot have NaN values")
|
127
|
+
return self
|
128
|
+
|
129
|
+
def __mul__(self, other: "Vector") -> "Vector":
|
130
|
+
|
131
|
+
array = np.cross(self.to_array(), other.to_array())
|
132
|
+
return Vector(x=array[0], y=array[1], z=array[2])
|
133
|
+
|
134
|
+
def dot(self, other: "Vector") -> float:
|
135
|
+
return np.dot(self.to_array(), other.to_array()) # type: ignore
|
136
|
+
|
137
|
+
def angle(self, other: "Vector") -> int:
|
138
|
+
dot_product = np.dot(self.to_xy(), other.to_xy())
|
139
|
+
cross_product = np.cross(self.to_xy(), other.to_xy())
|
140
|
+
angle_rad = np.arctan2(cross_product, dot_product)
|
141
|
+
angle_deg = np.degrees(angle_rad)
|
142
|
+
if angle_deg < 0:
|
143
|
+
angle_deg += 360
|
144
|
+
return int(angle_deg)
|
145
|
+
|
146
|
+
def project(self, other: "Vector") -> "Vector":
|
147
|
+
a = self.dot(other) / other.dot(other)
|
148
|
+
return Vector(x=a * other.x, y=a * other.y, z=a * other.z)
|
149
|
+
|
150
|
+
def norm(self) -> "Vector":
|
151
|
+
normalized_vector = self.to_array() / np.linalg.norm(self.to_array())
|
152
|
+
return Vector(
|
153
|
+
x=normalized_vector[0], y=normalized_vector[1], z=normalized_vector[2]
|
154
|
+
)
|
155
|
+
|
156
|
+
def to_array(self) -> np.ndarray: # type: ignore
|
157
|
+
return np.array([self.x, self.y, self.z])
|
158
|
+
|
159
|
+
def to_xy(self) -> np.ndarray: # type: ignore
|
160
|
+
return np.array([self.x, self.y])
|
161
|
+
|
162
|
+
def get_normal_index(self) -> int:
|
163
|
+
normal_index_list = [abs(v) for v in self.to_list()]
|
164
|
+
return normal_index_list.index(max(normal_index_list))
|
165
|
+
|
166
|
+
def is_a_zero(self, tolerance: float = 0.1) -> bool:
|
167
|
+
return all(abs(value) < tolerance for value in self.to_list())
|
168
|
+
|
169
|
+
@classmethod
|
170
|
+
def from_array(cls, array: np.ndarray) -> "Vector": # type: ignore
|
171
|
+
return cls.model_validate(super().from_array(array).model_dump())
|
172
|
+
|
173
|
+
|
174
|
+
class Point(BasePoint):
|
175
|
+
def __sub__(self, other: "Point") -> Vector:
|
176
|
+
|
177
|
+
return Vector(x=self.x - other.x, y=self.y - other.y, z=self.z - other.z)
|
178
|
+
|
179
|
+
def __add__(self, other: "Point") -> "Point":
|
180
|
+
return Point(x=self.x + other.x, y=self.y + other.y, z=self.z + other.z)
|
181
|
+
|
182
|
+
def s(self, signs: Sign) -> "Point":
|
183
|
+
return Point(x=self.x * signs.x, y=self.y * signs.y, z=self.z * signs.z)
|
184
|
+
|
185
|
+
|
186
|
+
class P(Point):
|
187
|
+
pass
|
188
|
+
|
189
|
+
|
190
|
+
class GlobalId(BaseModelConfig):
|
191
|
+
global_id: str
|
192
|
+
|
193
|
+
|
194
|
+
class CoordinateSystem(BaseModel):
|
195
|
+
x: Vector
|
196
|
+
y: Vector
|
197
|
+
z: Vector
|
198
|
+
|
199
|
+
def __eq__(self, other: "CoordinateSystem") -> bool: # type: ignore
|
200
|
+
return all(
|
201
|
+
[
|
202
|
+
self.x == other.x,
|
203
|
+
self.y == other.y,
|
204
|
+
self.z == other.z,
|
205
|
+
]
|
206
|
+
)
|
207
|
+
|
208
|
+
@classmethod
|
209
|
+
def from_array(cls, array: np.ndarray) -> "CoordinateSystem": # type: ignore
|
210
|
+
return cls(
|
211
|
+
x=Vector.from_array(array[0]),
|
212
|
+
y=Vector.from_array(array[1]),
|
213
|
+
z=Vector.from_array(array[2]),
|
214
|
+
)
|
215
|
+
|
216
|
+
def to_array(self) -> np.ndarray: # type: ignore
|
217
|
+
return np.array([self.x.to_array(), self.y.to_array(), self.z.to_array()])
|
218
|
+
|
219
|
+
def inverse(self, array: np.array) -> np.array: # type: ignore
|
220
|
+
return np.round(np.dot(array, self.to_array()), ROUNDING_FACTOR) # type: ignore
|
221
|
+
|
222
|
+
def project(self, array: np.array) -> np.ndarray: # type: ignore
|
223
|
+
return np.round(np.dot(array, np.linalg.inv(self.to_array())), ROUNDING_FACTOR) # type: ignore
|
224
|
+
|
225
|
+
|
226
|
+
class Vertices(BaseModel):
|
227
|
+
points: List[Point]
|
228
|
+
|
229
|
+
@classmethod
|
230
|
+
def from_arrays(
|
231
|
+
cls, arrays: np.ndarray[tuple[int, ...], np.dtype[np.float64]]
|
232
|
+
) -> "Vertices":
|
233
|
+
return cls(
|
234
|
+
points=[Point(x=array[0], y=array[1], z=array[2]) for array in arrays]
|
235
|
+
)
|
236
|
+
|
237
|
+
def to_array(self) -> ndarray: # type: ignore
|
238
|
+
return np.array([point.to_array() for point in self.points])
|
239
|
+
|
240
|
+
def to_list(self) -> List[List[float]]:
|
241
|
+
return self.to_array().tolist() # type: ignore
|
242
|
+
|
243
|
+
def to_tuple(self) -> List[List[float]]:
|
244
|
+
return tuple(tuple(t) for t in self.to_array().tolist()) # type: ignore
|
245
|
+
|
246
|
+
def to_face_vertices(self) -> "FaceVertices":
|
247
|
+
return FaceVertices(points=self.points)
|
248
|
+
|
249
|
+
def get_local_coordinate_system(self) -> CoordinateSystem:
|
250
|
+
origin = self.points[0]
|
251
|
+
x = self.points[1] - origin
|
252
|
+
found = False
|
253
|
+
for point in self.points[2:]:
|
254
|
+
y = point - origin
|
255
|
+
if abs(x.dot(y)) < 0.00001:
|
256
|
+
found = True
|
257
|
+
break
|
258
|
+
if not found:
|
259
|
+
raise ValueError("No orthogonal vectors found.")
|
260
|
+
|
261
|
+
z = x * y
|
262
|
+
return CoordinateSystem(x=x, y=y, z=z)
|
263
|
+
|
264
|
+
def get_bounding_box(self) -> "Vertices":
|
265
|
+
coordinates = self.get_local_coordinate_system()
|
266
|
+
projected = coordinates.project(self.to_array())
|
267
|
+
points_ = open3d.utility.Vector3dVector(projected)
|
268
|
+
aab = open3d.geometry.AxisAlignedBoundingBox.create_from_points(points_)
|
269
|
+
reversed = coordinates.inverse(np.array(aab.get_box_points()))
|
270
|
+
return Vertices.from_arrays(reversed)
|
271
|
+
|
272
|
+
def is_box_shaped(self) -> bool:
|
273
|
+
return len(self.points) == 8
|
274
|
+
|
275
|
+
|
276
|
+
class FaceVertices(Vertices):
|
277
|
+
|
278
|
+
@model_validator(mode="after")
|
279
|
+
def _model_validator(self) -> "FaceVertices":
|
280
|
+
if len(self.points) < 3:
|
281
|
+
raise ValueError("Face must have more than 3 vertices.")
|
282
|
+
return self
|
283
|
+
|
284
|
+
@computed_field
|
285
|
+
def _vector_1(self) -> Vector:
|
286
|
+
point_0 = self.points[0]
|
287
|
+
point_1 = self.points[1]
|
288
|
+
vector_0 = point_1 - point_0
|
289
|
+
return Vector.from_array(
|
290
|
+
vector_0.to_array() / np.linalg.norm(vector_0.to_array())
|
291
|
+
)
|
292
|
+
|
293
|
+
@computed_field
|
294
|
+
def _vector_2(self) -> Vector:
|
295
|
+
point_0 = self.points[0]
|
296
|
+
point_2 = self.points[2]
|
297
|
+
vector_0 = point_2 - point_0
|
298
|
+
return Vector.from_array(
|
299
|
+
vector_0.to_array() / np.linalg.norm(vector_0.to_array())
|
300
|
+
)
|
301
|
+
|
302
|
+
def get_normal(self) -> Vector:
|
303
|
+
normal_vector = self._vector_1 * self._vector_2 # type: ignore
|
304
|
+
normal_normalized = normal_vector.to_array() / np.linalg.norm(
|
305
|
+
normal_vector.to_array()
|
306
|
+
)
|
307
|
+
return Vector.from_array(normal_normalized)
|
308
|
+
|
309
|
+
def get_coordinates(self) -> CoordinateSystem:
|
310
|
+
z_axis = self.get_normal()
|
311
|
+
x_axis = self._vector_1
|
312
|
+
y_axis = z_axis * x_axis # type: ignore
|
313
|
+
return CoordinateSystem(x=x_axis, y=y_axis, z=z_axis)
|
314
|
+
|
315
|
+
def project(self, vertices: "FaceVertices") -> "ProjectedFaceVertices":
|
316
|
+
coordinates = self.get_coordinates()
|
317
|
+
projected = coordinates.project(vertices.to_array())
|
318
|
+
return ProjectedFaceVertices.from_arrays_(projected, coordinates)
|
319
|
+
|
320
|
+
def get_face_area(self) -> float:
|
321
|
+
projected = self.project(self)
|
322
|
+
return float(round(projected.to_polygon().area, ROUNDING_FACTOR))
|
323
|
+
|
324
|
+
def get_center(self) -> Point:
|
325
|
+
x = np.mean([point.x for point in self.points])
|
326
|
+
y = np.mean([point.y for point in self.points])
|
327
|
+
z = np.mean([point.z for point in self.points])
|
328
|
+
return Point(x=x, y=y, z=z)
|
329
|
+
|
330
|
+
def get_distance(self, other: "FaceVertices") -> float:
|
331
|
+
return math.dist(self.get_center().to_list(), other.get_center().to_list())
|
332
|
+
|
333
|
+
|
334
|
+
class FixedIndex(BaseModel):
|
335
|
+
index: int
|
336
|
+
value: float
|
337
|
+
|
338
|
+
|
339
|
+
class ProjectedFaceVertices(FaceVertices):
|
340
|
+
coordinate_system: CoordinateSystem
|
341
|
+
|
342
|
+
def get_fixed_index(self) -> FixedIndex:
|
343
|
+
fixed_indexes = [
|
344
|
+
FixedIndex(index=i, value=x[0])
|
345
|
+
for i, x in enumerate(self.to_array().T)
|
346
|
+
if len(set(x)) == 1
|
347
|
+
]
|
348
|
+
if len(fixed_indexes) != 1:
|
349
|
+
raise ValueError("No or wrong fixed index found")
|
350
|
+
return fixed_indexes[0]
|
351
|
+
|
352
|
+
def to_polygon(self) -> Polygon:
|
353
|
+
vertices_ = self.to_list()
|
354
|
+
try:
|
355
|
+
fixed_index = self.get_fixed_index()
|
356
|
+
except ValueError:
|
357
|
+
return Polygon()
|
358
|
+
indexes = [0, 1, 2]
|
359
|
+
indexes.remove(fixed_index.index)
|
360
|
+
vertices_ = [*vertices_, vertices_[0]]
|
361
|
+
points = [np.array(v)[indexes] for v in vertices_]
|
362
|
+
return Polygon(points)
|
363
|
+
|
364
|
+
def common_vertices(self, polygon: Polygon) -> FaceVertices:
|
365
|
+
fixed_index = self.get_fixed_index()
|
366
|
+
coords = [list(coord) for coord in list(polygon.exterior.coords)]
|
367
|
+
[coord.insert(fixed_index.index, fixed_index.value) for coord in coords] # type: ignore
|
368
|
+
vertices = FaceVertices.from_arrays(np.array(coords))
|
369
|
+
original = self.coordinate_system.inverse(vertices.to_array())
|
370
|
+
return FaceVertices.from_arrays(original) # type: ignore
|
371
|
+
|
372
|
+
@classmethod
|
373
|
+
def from_arrays_(
|
374
|
+
cls, arrays: ndarray[Any, Any], coordinate_system: CoordinateSystem
|
375
|
+
) -> "ProjectedFaceVertices":
|
376
|
+
return cls(
|
377
|
+
points=[Point(x=array[0], y=array[1], z=array[2]) for array in arrays],
|
378
|
+
coordinate_system=coordinate_system,
|
379
|
+
)
|
380
|
+
|
381
|
+
|
382
|
+
class CommonSurface(BaseShow):
|
383
|
+
area: float
|
384
|
+
orientation: Vector
|
385
|
+
main_vertices: FaceVertices
|
386
|
+
common_vertices: FaceVertices
|
387
|
+
exterior: bool = True
|
388
|
+
|
389
|
+
def __hash__(self) -> int:
|
390
|
+
return hash(
|
391
|
+
(
|
392
|
+
self.area,
|
393
|
+
tuple(self.orientation.to_list()),
|
394
|
+
self.main_vertices.to_tuple(),
|
395
|
+
self.common_vertices.to_tuple(),
|
396
|
+
)
|
397
|
+
)
|
398
|
+
|
399
|
+
@model_validator(mode="after")
|
400
|
+
def _model_validator(self) -> "CommonSurface":
|
401
|
+
self.area = round(self.area, ROUNDING_FACTOR)
|
402
|
+
return self
|
403
|
+
|
404
|
+
def description(self) -> tuple[list[float], list[float]]:
|
405
|
+
return ([self.area], self.orientation.to_list())
|
406
|
+
|
407
|
+
def lines(self) -> List[Line]:
|
408
|
+
lines = []
|
409
|
+
lst = self.common_vertices.to_list()[:4]
|
410
|
+
|
411
|
+
# for a, b in [[lst[i], lst[(i + 1) % len(lst)]] for i in range(len(lst))]:
|
412
|
+
color = "red" if self.exterior else "blue"
|
413
|
+
alpha = 0.1 if self.exterior else 0.9
|
414
|
+
lines.append(Mesh([lst, [(0, 1, 2, 3)]], c=color, alpha=alpha))
|
415
|
+
arrow = Arrow(
|
416
|
+
self.main_vertices.get_center().to_list(),
|
417
|
+
(
|
418
|
+
self.main_vertices.get_center().to_array() + self.orientation.to_array()
|
419
|
+
).tolist(),
|
420
|
+
c="deepskyblue",
|
421
|
+
s=0.001, # thinner shaft
|
422
|
+
head_length=0.05, # smaller tip
|
423
|
+
head_radius=0.05, # sharper tip
|
424
|
+
res=16, # shaft resolution
|
425
|
+
)
|
426
|
+
lines.append(arrow)
|
427
|
+
return lines
|
428
|
+
|
429
|
+
|
430
|
+
Libraries = Literal["IDEAS", "Buildings", "reduced_order", "iso_13790"]
|
@@ -0,0 +1,196 @@
|
|
1
|
+
from itertools import combinations
|
2
|
+
from logging import getLogger
|
3
|
+
from typing import List, Optional, Any, Tuple
|
4
|
+
|
5
|
+
import ifcopenshell
|
6
|
+
import ifcopenshell.geom
|
7
|
+
import ifcopenshell.util.placement
|
8
|
+
import ifcopenshell.util.shape
|
9
|
+
import numpy as np
|
10
|
+
import open3d # type: ignore
|
11
|
+
from ifcopenshell import entity_instance
|
12
|
+
from pydantic import (
|
13
|
+
BaseModel,
|
14
|
+
Field,
|
15
|
+
ConfigDict,
|
16
|
+
)
|
17
|
+
from shapely import Polygon # type: ignore
|
18
|
+
from vedo import Line # type: ignore
|
19
|
+
|
20
|
+
from ifctrano.base import (
|
21
|
+
Point,
|
22
|
+
Vector,
|
23
|
+
Vertices,
|
24
|
+
BaseModelConfig,
|
25
|
+
settings,
|
26
|
+
CommonSurface,
|
27
|
+
AREA_TOLERANCE,
|
28
|
+
FaceVertices,
|
29
|
+
BaseShow,
|
30
|
+
)
|
31
|
+
from ifctrano.exceptions import VectorWithNansError
|
32
|
+
|
33
|
+
logger = getLogger(__name__)
|
34
|
+
|
35
|
+
|
36
|
+
class BoundingBoxFace(BaseModelConfig):
|
37
|
+
vertices: FaceVertices
|
38
|
+
normal: Vector
|
39
|
+
|
40
|
+
@classmethod
|
41
|
+
def build(cls, vertices: Vertices) -> "BoundingBoxFace":
|
42
|
+
face_vertices = vertices.to_face_vertices()
|
43
|
+
|
44
|
+
return cls(vertices=face_vertices, normal=face_vertices.get_normal())
|
45
|
+
|
46
|
+
|
47
|
+
class BoundingBoxFaces(BaseModel):
|
48
|
+
faces: List[BoundingBoxFace]
|
49
|
+
|
50
|
+
def description(self) -> List[tuple[Any, Tuple[float, float, float]]]:
|
51
|
+
return sorted([(f.vertices.to_list(), f.normal.to_tuple()) for f in self.faces])
|
52
|
+
|
53
|
+
@classmethod
|
54
|
+
def build(
|
55
|
+
cls, box_points: np.ndarray[tuple[int, ...], np.dtype[np.float64]]
|
56
|
+
) -> "BoundingBoxFaces":
|
57
|
+
faces = [
|
58
|
+
[0, 1, 6, 3],
|
59
|
+
[2, 5, 4, 7],
|
60
|
+
[0, 3, 5, 2],
|
61
|
+
[1, 7, 4, 6],
|
62
|
+
[0, 2, 7, 1],
|
63
|
+
[3, 6, 4, 5],
|
64
|
+
]
|
65
|
+
faces_ = [
|
66
|
+
BoundingBoxFace.build(Vertices.from_arrays(np.array(box_points)[face]))
|
67
|
+
for face in faces
|
68
|
+
]
|
69
|
+
return cls(faces=faces_)
|
70
|
+
|
71
|
+
|
72
|
+
class ExtendCommonSurface(CommonSurface):
|
73
|
+
distance: float
|
74
|
+
|
75
|
+
def to_common_surface(self) -> CommonSurface:
|
76
|
+
return CommonSurface(
|
77
|
+
area=self.area,
|
78
|
+
orientation=self.orientation,
|
79
|
+
main_vertices=self.main_vertices,
|
80
|
+
common_vertices=self.common_vertices,
|
81
|
+
)
|
82
|
+
|
83
|
+
|
84
|
+
class OrientedBoundingBox(BaseShow):
|
85
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
86
|
+
faces: BoundingBoxFaces
|
87
|
+
centroid: Point
|
88
|
+
area_tolerance: float = Field(default=AREA_TOLERANCE)
|
89
|
+
volume: float
|
90
|
+
height: float
|
91
|
+
entity: Optional[entity_instance] = None
|
92
|
+
|
93
|
+
def lines(self) -> List[Line]:
|
94
|
+
lines = []
|
95
|
+
for f in self.faces.faces:
|
96
|
+
face = f.vertices.to_list()
|
97
|
+
for a, b in combinations(face, 2):
|
98
|
+
lines.append(Line(a, b))
|
99
|
+
return lines
|
100
|
+
|
101
|
+
def contained(self, poly_1: Polygon, poly_2: Polygon) -> bool:
|
102
|
+
include_1_in_2 = poly_1.contains(poly_2)
|
103
|
+
include_2_in_1 = poly_2.contains(poly_1)
|
104
|
+
return bool(include_2_in_1 or include_1_in_2)
|
105
|
+
|
106
|
+
def intersect_faces(self, other: "OrientedBoundingBox") -> Optional[CommonSurface]:
|
107
|
+
extend_surfaces = []
|
108
|
+
for face in self.faces.faces:
|
109
|
+
|
110
|
+
for other_face in other.faces.faces:
|
111
|
+
vector = face.normal * other_face.normal
|
112
|
+
if vector.is_a_zero():
|
113
|
+
projected_face_1 = face.vertices.project(face.vertices)
|
114
|
+
projected_face_2 = face.vertices.project(other_face.vertices)
|
115
|
+
polygon_1 = projected_face_1.to_polygon()
|
116
|
+
polygon_2 = projected_face_2.to_polygon()
|
117
|
+
intersection = polygon_2.intersection(polygon_1)
|
118
|
+
if intersection.area > self.area_tolerance or self.contained(
|
119
|
+
polygon_1, polygon_2
|
120
|
+
):
|
121
|
+
distance = projected_face_1.get_distance(projected_face_2)
|
122
|
+
area = intersection.area
|
123
|
+
try:
|
124
|
+
direction_vector = (other.centroid - self.centroid).norm()
|
125
|
+
orientation = direction_vector.project(face.normal).norm()
|
126
|
+
except VectorWithNansError as e:
|
127
|
+
logger.warning(
|
128
|
+
"Orientation vector was not properly computed when computing the intersection between "
|
129
|
+
f"two elements "
|
130
|
+
f"({(self.entity.GlobalId, self.entity.is_a(), self.entity.Name) if self.entity else None}" # noqa: E501
|
131
|
+
f", {(other.entity.GlobalId, other.entity.is_a(), other.entity.Name)if other.entity else None}). Error: {e}" # noqa: E501
|
132
|
+
)
|
133
|
+
continue
|
134
|
+
extend_surfaces.append(
|
135
|
+
ExtendCommonSurface(
|
136
|
+
distance=distance,
|
137
|
+
area=area,
|
138
|
+
orientation=orientation,
|
139
|
+
main_vertices=face.vertices,
|
140
|
+
common_vertices=projected_face_1.common_vertices(
|
141
|
+
intersection
|
142
|
+
),
|
143
|
+
)
|
144
|
+
)
|
145
|
+
|
146
|
+
if extend_surfaces:
|
147
|
+
if not all(
|
148
|
+
e.orientation == extend_surfaces[0].orientation for e in extend_surfaces
|
149
|
+
):
|
150
|
+
logger.warning("Different orientations found. taking the max area")
|
151
|
+
max_area = max([e.area for e in extend_surfaces])
|
152
|
+
extend_surfaces = [e for e in extend_surfaces if e.area == max_area]
|
153
|
+
extend_surface = sorted(
|
154
|
+
extend_surfaces, key=lambda x: x.distance, reverse=True
|
155
|
+
)[-1]
|
156
|
+
return extend_surface.to_common_surface()
|
157
|
+
else:
|
158
|
+
logger.warning(
|
159
|
+
"No common surfaces found between between "
|
160
|
+
f"two elements "
|
161
|
+
f"({(self.entity.GlobalId, self.entity.is_a(), self.entity.Name) if self.entity else None}, "
|
162
|
+
f"{(other.entity.GlobalId, other.entity.is_a(), other.entity.Name) if other.entity else None})."
|
163
|
+
)
|
164
|
+
return None
|
165
|
+
|
166
|
+
@classmethod
|
167
|
+
def from_vertices(
|
168
|
+
cls,
|
169
|
+
vertices: np.ndarray[tuple[int, ...], np.dtype[np.float64]],
|
170
|
+
entity: Optional[entity_instance] = None,
|
171
|
+
) -> "OrientedBoundingBox":
|
172
|
+
points_ = open3d.utility.Vector3dVector(vertices)
|
173
|
+
mobb = open3d.geometry.OrientedBoundingBox.create_from_points_minimal(points_)
|
174
|
+
height = (mobb.get_max_bound() - mobb.get_min_bound())[
|
175
|
+
2
|
176
|
+
] # assuming that height is the z axis
|
177
|
+
centroid = Point.from_array(mobb.get_center())
|
178
|
+
faces = BoundingBoxFaces.build(np.array(mobb.get_box_points()))
|
179
|
+
return cls(
|
180
|
+
faces=faces,
|
181
|
+
centroid=centroid,
|
182
|
+
volume=mobb.volume(),
|
183
|
+
height=height,
|
184
|
+
entity=entity,
|
185
|
+
)
|
186
|
+
|
187
|
+
@classmethod
|
188
|
+
def from_entity(cls, entity: entity_instance) -> "OrientedBoundingBox":
|
189
|
+
entity_shape = ifcopenshell.geom.create_shape(settings, entity)
|
190
|
+
vertices = ifcopenshell.util.shape.get_shape_vertices(
|
191
|
+
entity_shape, entity_shape.geometry # type: ignore
|
192
|
+
)
|
193
|
+
vertices_ = Vertices.from_arrays(np.asarray(vertices))
|
194
|
+
|
195
|
+
vertices_ = vertices_.get_bounding_box()
|
196
|
+
return cls.from_vertices(vertices_.to_array(), entity)
|