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 +0 -0
- ifctrano/base.py +163 -0
- ifctrano/bounding_box.py +245 -0
- ifctrano/building.py +55 -0
- ifctrano/exceptions.py +10 -0
- ifctrano/main.py +44 -0
- ifctrano/space_boundary.py +164 -0
- ifctrano-0.1.1.dist-info/LICENSE +674 -0
- ifctrano-0.1.1.dist-info/METADATA +61 -0
- ifctrano-0.1.1.dist-info/RECORD +12 -0
- ifctrano-0.1.1.dist-info/WHEEL +4 -0
- ifctrano-0.1.1.dist-info/entry_points.txt +3 -0
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"]
|
ifctrano/bounding_box.py
ADDED
@@ -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
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)
|