ifctrano 0.1.7__py3-none-any.whl → 0.1.9__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/base.py +24 -1
- ifctrano/bounding_box.py +33 -11
- ifctrano/building.py +141 -11
- ifctrano/example/verification.ifc +3043 -0
- ifctrano/exceptions.py +16 -0
- ifctrano/main.py +35 -4
- ifctrano/space_boundary.py +133 -32
- {ifctrano-0.1.7.dist-info → ifctrano-0.1.9.dist-info}/METADATA +2 -2
- ifctrano-0.1.9.dist-info/RECORD +13 -0
- ifctrano-0.1.9.dist-info/entry_points.txt +3 -0
- ifctrano-0.1.7.dist-info/RECORD +0 -12
- ifctrano-0.1.7.dist-info/entry_points.txt +0 -3
- {ifctrano-0.1.7.dist-info → ifctrano-0.1.9.dist-info}/LICENSE +0 -0
- {ifctrano-0.1.7.dist-info → ifctrano-0.1.9.dist-info}/WHEEL +0 -0
ifctrano/base.py
CHANGED
@@ -3,11 +3,15 @@ from typing import Tuple, Literal, List, Annotated, Sequence
|
|
3
3
|
import ifcopenshell.geom
|
4
4
|
import numpy as np
|
5
5
|
from numpy import ndarray
|
6
|
-
from pydantic import BaseModel, BeforeValidator, ConfigDict
|
6
|
+
from pydantic import BaseModel, BeforeValidator, ConfigDict, model_validator
|
7
|
+
|
8
|
+
from ifctrano.exceptions import VectorWithNansError
|
7
9
|
|
8
10
|
settings = ifcopenshell.geom.settings() # type: ignore
|
9
11
|
Coordinate = Literal["x", "y", "z"]
|
10
12
|
AREA_TOLERANCE = 0.5
|
13
|
+
ROUNDING_FACTOR = 5
|
14
|
+
CLASH_CLEARANCE = 0.5
|
11
15
|
|
12
16
|
|
13
17
|
class BaseModelConfig(BaseModel):
|
@@ -60,6 +64,13 @@ class Sign(BaseModel):
|
|
60
64
|
|
61
65
|
|
62
66
|
class Vector(BasePoint):
|
67
|
+
|
68
|
+
@model_validator(mode="after")
|
69
|
+
def _validator(self) -> "Vector":
|
70
|
+
if any(np.isnan(v) for v in self.to_list()):
|
71
|
+
raise VectorWithNansError("Vector cannot have NaN values")
|
72
|
+
return self
|
73
|
+
|
63
74
|
def __mul__(self, other: "Vector") -> "Vector":
|
64
75
|
|
65
76
|
array = np.cross(self.to_array(), other.to_array())
|
@@ -68,6 +79,15 @@ class Vector(BasePoint):
|
|
68
79
|
def dot(self, other: "Vector") -> float:
|
69
80
|
return np.dot(self.to_array(), other.to_array()) # type: ignore
|
70
81
|
|
82
|
+
def angle(self, other: "Vector") -> int:
|
83
|
+
dot_product = np.dot(self.to_xy(), other.to_xy())
|
84
|
+
cross_product = np.cross(self.to_xy(), other.to_xy())
|
85
|
+
angle_rad = np.arctan2(cross_product, dot_product)
|
86
|
+
angle_deg = np.degrees(angle_rad)
|
87
|
+
if angle_deg < 0:
|
88
|
+
angle_deg += 360
|
89
|
+
return int(angle_deg)
|
90
|
+
|
71
91
|
def project(self, other: "Vector") -> "Vector":
|
72
92
|
a = self.dot(other) / other.dot(other)
|
73
93
|
return Vector(x=a * other.x, y=a * other.y, z=a * other.z)
|
@@ -81,6 +101,9 @@ class Vector(BasePoint):
|
|
81
101
|
def to_array(self) -> np.ndarray: # type: ignore
|
82
102
|
return np.array([self.x, self.y, self.z])
|
83
103
|
|
104
|
+
def to_xy(self) -> np.ndarray: # type: ignore
|
105
|
+
return np.array([self.x, self.y])
|
106
|
+
|
84
107
|
def get_normal_index(self) -> int:
|
85
108
|
normal_index_list = [abs(v) for v in self.to_list()]
|
86
109
|
return normal_index_list.index(max(normal_index_list))
|
ifctrano/bounding_box.py
CHANGED
@@ -1,5 +1,5 @@
|
|
1
1
|
from logging import getLogger
|
2
|
-
from typing import List, Optional, Any, Tuple
|
2
|
+
from typing import List, Optional, Any, Tuple, cast
|
3
3
|
|
4
4
|
import ifcopenshell
|
5
5
|
import numpy as np
|
@@ -23,8 +23,9 @@ from ifctrano.base import (
|
|
23
23
|
settings,
|
24
24
|
CommonSurface,
|
25
25
|
AREA_TOLERANCE,
|
26
|
+
ROUNDING_FACTOR,
|
26
27
|
)
|
27
|
-
from ifctrano.exceptions import BoundingBoxFaceError
|
28
|
+
from ifctrano.exceptions import BoundingBoxFaceError, VectorWithNansError
|
28
29
|
|
29
30
|
logger = getLogger(__name__)
|
30
31
|
|
@@ -77,7 +78,12 @@ class BoundingBoxFace(BaseModelConfig):
|
|
77
78
|
vertices=vertices, normal=normal, coordinate_system=coordinate_system
|
78
79
|
)
|
79
80
|
|
81
|
+
def get_face_area(self) -> float:
|
82
|
+
polygon_2d = self.get_2d_polygon(self.coordinate_system)
|
83
|
+
return cast(float, round(polygon_2d.polygon.area, ROUNDING_FACTOR))
|
84
|
+
|
80
85
|
def get_2d_polygon(self, coordinate_system: CoordinateSystem) -> Polygon2D:
|
86
|
+
|
81
87
|
projected_vertices = coordinate_system.inverse(self.vertices.to_array())
|
82
88
|
projected_normal_index = Vector.from_array(
|
83
89
|
coordinate_system.inverse(self.normal.to_array())
|
@@ -152,6 +158,8 @@ class OrientedBoundingBox(BaseModel):
|
|
152
158
|
faces: BoundingBoxFaces
|
153
159
|
centroid: Point
|
154
160
|
area_tolerance: float = Field(default=AREA_TOLERANCE)
|
161
|
+
volume: float
|
162
|
+
height: float
|
155
163
|
|
156
164
|
def intersect_faces(self, other: "OrientedBoundingBox") -> Optional[CommonSurface]:
|
157
165
|
extend_surfaces = []
|
@@ -160,16 +168,21 @@ class OrientedBoundingBox(BaseModel):
|
|
160
168
|
for other_face in other.faces.faces:
|
161
169
|
vector = face.normal * other_face.normal
|
162
170
|
if vector.is_a_zero():
|
163
|
-
|
164
171
|
polygon_1 = other_face.get_2d_polygon(face.coordinate_system)
|
165
172
|
polygon_2 = face.get_2d_polygon(face.coordinate_system)
|
166
173
|
intersection = polygon_2.polygon.intersection(polygon_1.polygon)
|
167
|
-
|
168
174
|
if intersection.area > self.area_tolerance:
|
169
175
|
distance = abs(polygon_1.length - polygon_2.length)
|
170
176
|
area = intersection.area
|
171
177
|
direction_vector = (other.centroid - self.centroid).norm()
|
172
|
-
|
178
|
+
try:
|
179
|
+
orientation = direction_vector.project(face.normal).norm()
|
180
|
+
except VectorWithNansError as e:
|
181
|
+
logger.error(
|
182
|
+
"Orientation vector was not properly computed when computing the intersection between"
|
183
|
+
f"two elements. Error: {e}"
|
184
|
+
)
|
185
|
+
continue
|
173
186
|
extend_surfaces.append(
|
174
187
|
ExtendCommonSurface(
|
175
188
|
distance=distance, area=area, orientation=orientation
|
@@ -194,8 +207,8 @@ class OrientedBoundingBox(BaseModel):
|
|
194
207
|
) -> "OrientedBoundingBox":
|
195
208
|
vertices_np = np.array(vertices)
|
196
209
|
points = np.asarray(vertices_np)
|
197
|
-
cov = np.cov(points, y=None, rowvar=0, bias=
|
198
|
-
v, vect = np.linalg.eig(cov)
|
210
|
+
cov = np.cov(points, y=None, rowvar=0, bias=0) # type: ignore
|
211
|
+
v, vect = np.linalg.eig(np.round(cov, ROUNDING_FACTOR))
|
199
212
|
tvect = np.transpose(vect)
|
200
213
|
points_r = np.dot(points, np.linalg.inv(tvect))
|
201
214
|
|
@@ -206,9 +219,12 @@ class OrientedBoundingBox(BaseModel):
|
|
206
219
|
ymin, ymax = co_min[1], co_max[1]
|
207
220
|
zmin, zmax = co_min[2], co_max[2]
|
208
221
|
|
209
|
-
|
210
|
-
|
211
|
-
|
222
|
+
x_len = xmax - xmin
|
223
|
+
y_len = ymax - ymin
|
224
|
+
z_len = zmax - zmin
|
225
|
+
xdif = x_len * 0.5
|
226
|
+
ydif = y_len * 0.5
|
227
|
+
zdif = z_len * 0.5
|
212
228
|
|
213
229
|
cx = xmin + xdif
|
214
230
|
cy = ymin + ydif
|
@@ -225,7 +241,11 @@ class OrientedBoundingBox(BaseModel):
|
|
225
241
|
[cx + xdif, cy - ydif, cz - zdif],
|
226
242
|
]
|
227
243
|
)
|
228
|
-
|
244
|
+
corners_ = np.dot(corners, tvect)
|
245
|
+
dims = np.transpose(corners_)
|
246
|
+
x_size = np.max(dims[0]) - np.min(dims[0])
|
247
|
+
y_size = np.max(dims[1]) - np.min(dims[1])
|
248
|
+
z_size = np.max(dims[2]) - np.min(dims[2])
|
229
249
|
coordinate_system = CoordinateSystem.from_array(tvect)
|
230
250
|
c = P(x=cx, y=cy, z=cz)
|
231
251
|
d = P(x=xdif, y=ydif, z=zdif)
|
@@ -233,6 +253,8 @@ class OrientedBoundingBox(BaseModel):
|
|
233
253
|
return cls(
|
234
254
|
faces=faces,
|
235
255
|
centroid=Point.from_array(coordinate_system.project(c.to_array())),
|
256
|
+
volume=x_size * y_size * z_size,
|
257
|
+
height=z_size,
|
236
258
|
)
|
237
259
|
|
238
260
|
@classmethod
|
ifctrano/building.py
CHANGED
@@ -1,30 +1,84 @@
|
|
1
|
+
import re
|
1
2
|
from pathlib import Path
|
2
|
-
from typing import List
|
3
|
+
from typing import List, Tuple, Any, Optional
|
3
4
|
|
4
5
|
import ifcopenshell
|
5
6
|
from ifcopenshell import file, entity_instance
|
6
|
-
from pydantic import validate_call
|
7
|
+
from pydantic import validate_call, Field, model_validator, field_validator
|
8
|
+
from trano.elements import InternalElement # type: ignore
|
7
9
|
from trano.elements.library.library import Library # type: ignore
|
8
|
-
|
10
|
+
from trano.elements.types import Tilt # type: ignore
|
9
11
|
from trano.topology import Network # type: ignore
|
10
12
|
|
11
|
-
from ifctrano.base import BaseModelConfig, Libraries
|
12
|
-
from ifctrano.exceptions import IfcFileNotFoundError
|
13
|
-
from ifctrano.space_boundary import
|
13
|
+
from ifctrano.base import BaseModelConfig, Libraries, Vector
|
14
|
+
from ifctrano.exceptions import IfcFileNotFoundError, NoIfcSpaceFoundError
|
15
|
+
from ifctrano.space_boundary import (
|
16
|
+
SpaceBoundaries,
|
17
|
+
initialize_tree,
|
18
|
+
Space,
|
19
|
+
construction,
|
20
|
+
)
|
14
21
|
|
15
22
|
|
16
23
|
def get_spaces(ifcopenshell_file: file) -> List[entity_instance]:
|
17
24
|
return ifcopenshell_file.by_type("IfcSpace")
|
18
25
|
|
19
26
|
|
27
|
+
class IfcInternalElement(BaseModelConfig):
|
28
|
+
spaces: List[Space]
|
29
|
+
element: entity_instance
|
30
|
+
area: float
|
31
|
+
|
32
|
+
def __hash__(self) -> int:
|
33
|
+
return hash(
|
34
|
+
(
|
35
|
+
*sorted([space.global_id for space in self.spaces]),
|
36
|
+
self.element.GlobalId,
|
37
|
+
self.area,
|
38
|
+
)
|
39
|
+
)
|
40
|
+
|
41
|
+
def __eq__(self, other: "IfcInternalElement") -> bool: # type: ignore
|
42
|
+
return hash(self) == hash(other)
|
43
|
+
|
44
|
+
def description(self) -> Tuple[Any, Any, str, float]:
|
45
|
+
return (
|
46
|
+
*sorted([space.global_id for space in self.spaces]),
|
47
|
+
self.element.GlobalId,
|
48
|
+
self.element.is_a(),
|
49
|
+
self.area,
|
50
|
+
)
|
51
|
+
|
52
|
+
|
53
|
+
class InternalElements(BaseModelConfig):
|
54
|
+
elements: List[IfcInternalElement] = Field(default_factory=list)
|
55
|
+
|
56
|
+
def internal_element_ids(self) -> List[str]:
|
57
|
+
return list({e.element.GlobalId for e in self.elements})
|
58
|
+
|
59
|
+
def description(self) -> List[Tuple[Any, Any, str, float]]:
|
60
|
+
return sorted([element.description() for element in self.elements])
|
61
|
+
|
62
|
+
|
20
63
|
class Building(BaseModelConfig):
|
21
64
|
name: str
|
22
65
|
space_boundaries: List[SpaceBoundaries]
|
23
66
|
ifc_file: file
|
24
67
|
parent_folder: Path
|
68
|
+
internal_elements: InternalElements = Field(default_factory=InternalElements)
|
69
|
+
|
70
|
+
@field_validator("name")
|
71
|
+
@classmethod
|
72
|
+
def _name_validator(cls, name: str) -> str:
|
73
|
+
name = name.replace(" ", "_")
|
74
|
+
name = re.sub(r"[^a-zA-Z0-9_]", "", name)
|
75
|
+
return name.lower()
|
25
76
|
|
26
77
|
@classmethod
|
27
|
-
def from_ifc(
|
78
|
+
def from_ifc(
|
79
|
+
cls, ifc_file_path: Path, selected_spaces_global_id: Optional[List[str]] = None
|
80
|
+
) -> "Building":
|
81
|
+
selected_spaces_global_id = selected_spaces_global_id or []
|
28
82
|
if not ifc_file_path.exists():
|
29
83
|
raise IfcFileNotFoundError(
|
30
84
|
f"File specified {ifc_file_path} does not exist."
|
@@ -32,6 +86,12 @@ class Building(BaseModelConfig):
|
|
32
86
|
ifc_file = ifcopenshell.open(str(ifc_file_path))
|
33
87
|
tree = initialize_tree(ifc_file)
|
34
88
|
spaces = get_spaces(ifc_file)
|
89
|
+
if selected_spaces_global_id:
|
90
|
+
spaces = [
|
91
|
+
space for space in spaces if space.GlobalId in selected_spaces_global_id
|
92
|
+
]
|
93
|
+
if not spaces:
|
94
|
+
raise NoIfcSpaceFoundError("No IfcSpace found in the file.")
|
35
95
|
space_boundaries = [
|
36
96
|
SpaceBoundaries.from_space_entity(ifc_file, tree, space) for space in spaces
|
37
97
|
]
|
@@ -42,12 +102,82 @@ class Building(BaseModelConfig):
|
|
42
102
|
name=ifc_file_path.stem,
|
43
103
|
)
|
44
104
|
|
105
|
+
@model_validator(mode="after")
|
106
|
+
def _validator(self) -> "Building":
|
107
|
+
self.internal_elements = self.get_adjacency()
|
108
|
+
return self
|
109
|
+
|
110
|
+
def get_adjacency(self) -> InternalElements:
|
111
|
+
elements = []
|
112
|
+
for space_boundaries_ in self.space_boundaries:
|
113
|
+
for space_boundaries__ in self.space_boundaries:
|
114
|
+
space_1 = space_boundaries_.space
|
115
|
+
space_2 = space_boundaries__.space
|
116
|
+
if space_1.global_id == space_2.global_id:
|
117
|
+
continue
|
118
|
+
common_surface = space_1.bounding_box.intersect_faces(
|
119
|
+
space_2.bounding_box
|
120
|
+
)
|
121
|
+
for boundary in space_boundaries_.boundaries:
|
122
|
+
for boundary_ in space_boundaries__.boundaries:
|
123
|
+
if (
|
124
|
+
boundary.entity.GlobalId == boundary_.entity.GlobalId
|
125
|
+
and boundary.common_surface
|
126
|
+
and boundary_.common_surface
|
127
|
+
and common_surface
|
128
|
+
and (
|
129
|
+
boundary.common_surface.orientation
|
130
|
+
* common_surface.orientation
|
131
|
+
).is_a_zero()
|
132
|
+
and (
|
133
|
+
boundary_.common_surface.orientation
|
134
|
+
* common_surface.orientation
|
135
|
+
).is_a_zero()
|
136
|
+
) and boundary.common_surface.orientation.dot(
|
137
|
+
boundary_.common_surface.orientation
|
138
|
+
) < 0:
|
139
|
+
elements.append( # noqa: PERF401
|
140
|
+
IfcInternalElement(
|
141
|
+
spaces=[space_1, space_2],
|
142
|
+
element=boundary_.entity,
|
143
|
+
area=min(
|
144
|
+
common_surface.area,
|
145
|
+
boundary.common_surface.area,
|
146
|
+
boundary_.common_surface.area,
|
147
|
+
),
|
148
|
+
)
|
149
|
+
)
|
150
|
+
return InternalElements(elements=list(set(elements)))
|
151
|
+
|
45
152
|
@validate_call
|
46
|
-
def create_model(
|
153
|
+
def create_model(
|
154
|
+
self,
|
155
|
+
library: Libraries = "Buildings",
|
156
|
+
north_axis: Optional[Vector] = None,
|
157
|
+
) -> str:
|
158
|
+
north_axis = north_axis or Vector(x=0, y=1, z=0)
|
47
159
|
network = Network(name=self.name, library=Library.from_configuration(library))
|
48
|
-
|
49
|
-
|
50
|
-
|
160
|
+
spaces = {
|
161
|
+
space_boundary.space.global_id: space_boundary.model(
|
162
|
+
self.internal_elements.internal_element_ids(), north_axis
|
163
|
+
)
|
164
|
+
for space_boundary in self.space_boundaries
|
165
|
+
}
|
166
|
+
spaces = {k: v for k, v in spaces.items() if v}
|
167
|
+
network.add_boiler_plate_spaces(list(spaces.values()), create_internal=False)
|
168
|
+
for internal_element in self.internal_elements.elements:
|
169
|
+
space_1 = internal_element.spaces[0]
|
170
|
+
space_2 = internal_element.spaces[1]
|
171
|
+
network.connect_spaces(
|
172
|
+
spaces[space_1.global_id],
|
173
|
+
spaces[space_2.global_id],
|
174
|
+
InternalElement(
|
175
|
+
azimuth=10,
|
176
|
+
construction=construction,
|
177
|
+
surface=internal_element.area,
|
178
|
+
tilt=Tilt.wall,
|
179
|
+
),
|
180
|
+
)
|
51
181
|
return network.model() # type: ignore
|
52
182
|
|
53
183
|
def save_model(self, library: Libraries = "Buildings") -> None:
|