ifctrano 0.4.0__tar.gz → 0.8.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.
@@ -1,8 +1,9 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: ifctrano
3
- Version: 0.4.0
3
+ Version: 0.8.0
4
4
  Summary: Package for generating building energy simulation model from IFC
5
5
  License: GPL V3
6
+ License-File: LICENSE
6
7
  Keywords: BIM,IFC,energy simulation,modelica,building energy simulation,buildings,ideas
7
8
  Author: Ando Andriamamonjy
8
9
  Author-email: andoludovic.andriamamonjy@gmail.com
@@ -15,7 +16,7 @@ Classifier: Programming Language :: Python :: 3.12
15
16
  Requires-Dist: ifcopenshell (>=0.8.1.post1,<0.9.0)
16
17
  Requires-Dist: open3d (>=0.19.0,<0.20.0)
17
18
  Requires-Dist: shapely (>=2.0.7,<3.0.0)
18
- Requires-Dist: trano (>=0.6.0,<0.7.0)
19
+ Requires-Dist: trano (>=0.10.0,<0.11.0)
19
20
  Requires-Dist: typer (>=0.12.5,<0.13.0)
20
21
  Requires-Dist: vedo (>=2025.5.3,<2026.0.0)
21
22
  Project-URL: Repository, https://github.com/andoludo/ifctrano
@@ -398,6 +398,7 @@ class CommonSurface(BaseShow):
398
398
  main_vertices: FaceVertices
399
399
  common_vertices: FaceVertices
400
400
  exterior: bool = True
401
+ polygon: str
401
402
 
402
403
  def __hash__(self) -> int:
403
404
  return hash(
@@ -78,6 +78,7 @@ class ExtendCommonSurface(CommonSurface):
78
78
  orientation=self.orientation,
79
79
  main_vertices=self.main_vertices,
80
80
  common_vertices=self.common_vertices,
81
+ polygon=self.polygon,
81
82
  )
82
83
 
83
84
 
@@ -137,6 +138,7 @@ class OrientedBoundingBox(BaseShow):
137
138
  common_vertices=projected_face_1.common_vertices(
138
139
  intersection
139
140
  ),
141
+ polygon=intersection.wkt,
140
142
  )
141
143
  )
142
144
 
@@ -1,8 +1,10 @@
1
+ import logging
1
2
  import re
2
3
  from pathlib import Path
3
- from typing import List, Tuple, Any, Optional, Set
4
+ from typing import List, Tuple, Any, Optional, Set, Dict
4
5
 
5
6
  import ifcopenshell
7
+ import yaml
6
8
  from ifcopenshell import file, entity_instance
7
9
  from pydantic import validate_call, Field, model_validator, field_validator
8
10
  from trano.elements import InternalElement # type: ignore
@@ -12,13 +14,23 @@ from trano.topology import Network # type: ignore
12
14
  from vedo import Line # type: ignore
13
15
 
14
16
  from ifctrano.base import BaseModelConfig, Libraries, Vector, BaseShow, CommonSurface
15
- from ifctrano.exceptions import IfcFileNotFoundError, NoIfcSpaceFoundError
17
+ from ifctrano.exceptions import (
18
+ IfcFileNotFoundError,
19
+ NoIfcSpaceFoundError,
20
+ NoSpaceBoundariesError,
21
+ )
16
22
  from ifctrano.space_boundary import (
17
23
  SpaceBoundaries,
18
24
  initialize_tree,
19
25
  Space,
20
26
  )
21
- from ifctrano.construction import Constructions, default_construction
27
+ from ifctrano.construction import (
28
+ Constructions,
29
+ default_construction,
30
+ default_internal_construction,
31
+ )
32
+
33
+ logger = logging.getLogger(__name__)
22
34
 
23
35
 
24
36
  def get_spaces(ifcopenshell_file: file) -> List[entity_instance]:
@@ -180,9 +192,18 @@ class Building(BaseShow):
180
192
  ]
181
193
  if not spaces:
182
194
  raise NoIfcSpaceFoundError("No IfcSpace found in the file.")
183
- space_boundaries = [
184
- SpaceBoundaries.from_space_entity(ifc_file, tree, space) for space in spaces
185
- ]
195
+ space_boundaries = []
196
+ for space in spaces:
197
+ try:
198
+ space_boundaries.append(
199
+ SpaceBoundaries.from_space_entity(ifc_file, tree, space)
200
+ )
201
+ except Exception as e: # noqa: PERF203
202
+ logger.error(f"Cannot process space {space.id()}. Reason {e}")
203
+ continue
204
+ if not space_boundaries:
205
+ raise NoSpaceBoundariesError("No valid space boundaries found.")
206
+
186
207
  return cls(
187
208
  space_boundaries=space_boundaries,
188
209
  ifc_file=ifc_file,
@@ -199,6 +220,71 @@ class Building(BaseShow):
199
220
  def get_adjacency(self) -> InternalElements:
200
221
  return get_internal_elements(self.space_boundaries)
201
222
 
223
+ @validate_call
224
+ def to_config(
225
+ self,
226
+ north_axis: Optional[Vector] = None,
227
+ ) -> Dict[str, Any]:
228
+ north_axis = north_axis or Vector(x=0, y=1, z=0)
229
+ spaces = [
230
+ space_boundary.to_config(
231
+ self.internal_elements.internal_element_ids(),
232
+ north_axis,
233
+ self.constructions,
234
+ )
235
+ for space_boundary in self.space_boundaries
236
+ ]
237
+ internal_walls = []
238
+ for internal_element in self.internal_elements.elements:
239
+ space_1 = internal_element.spaces[0]
240
+ space_2 = internal_element.spaces[1]
241
+ construction = self.constructions.get_construction(
242
+ internal_element.element, default_internal_construction
243
+ )
244
+ if internal_element.element.is_a() in ["IfcSlab"]:
245
+ space_1_tilt = (
246
+ Tilt.floor.value
247
+ if space_1.bounding_box.centroid.z > space_2.bounding_box.centroid.z
248
+ else Tilt.ceiling.value
249
+ )
250
+ space_2_tilt = (
251
+ Tilt.floor.value
252
+ if space_2.bounding_box.centroid.z > space_1.bounding_box.centroid.z
253
+ else Tilt.ceiling.value
254
+ )
255
+ if space_1_tilt == space_2_tilt:
256
+ raise ValueError("Space tilts are not compatible.")
257
+ internal_walls.append(
258
+ {
259
+ "space_1": space_1.space_unique_name(),
260
+ "space_2": space_2.space_unique_name(),
261
+ "construction": construction.name,
262
+ "surface": internal_element.area,
263
+ "space_1_tilt": space_1_tilt,
264
+ "space_2_tilt": space_2_tilt,
265
+ }
266
+ )
267
+ else:
268
+ internal_walls.append(
269
+ {
270
+ "space_1": space_1.space_unique_name(),
271
+ "space_2": space_2.space_unique_name(),
272
+ "construction": construction.name,
273
+ "surface": internal_element.area,
274
+ }
275
+ )
276
+ construction_config = self.constructions.to_config()
277
+ return construction_config | {
278
+ "spaces": spaces,
279
+ "internal_walls": internal_walls,
280
+ }
281
+
282
+ @validate_call
283
+ def to_yaml(self, yaml_path: Path, north_axis: Optional[Vector] = None) -> None:
284
+ config = self.to_config(north_axis=north_axis)
285
+ yaml_data = yaml.dump(config)
286
+ yaml_path.write_text(yaml_data)
287
+
202
288
  @validate_call
203
289
  def create_network(
204
290
  self,
@@ -1,5 +1,5 @@
1
1
  import logging
2
- from typing import List, Optional
2
+ from typing import List, Optional, Dict, Any
3
3
 
4
4
  from ifcopenshell import file, entity_instance
5
5
 
@@ -38,6 +38,12 @@ default_construction = Construction(
38
38
  Layer(material=material_1, thickness=0.18),
39
39
  ],
40
40
  )
41
+ default_internal_construction = Construction(
42
+ name="default_internal_construction",
43
+ layers=[
44
+ Layer(material=material_1, thickness=0.18),
45
+ ],
46
+ )
41
47
  id_100 = GlassMaterial(
42
48
  name="id_100",
43
49
  thermal_conductivity=1,
@@ -186,14 +192,16 @@ class Constructions(BaseModel):
186
192
  )
187
193
  return cls(constructions=constructions)
188
194
 
189
- def get_construction(self, entity: entity_instance) -> Construction:
195
+ def get_construction(
196
+ self, entity: entity_instance, default: Optional[Construction] = None
197
+ ) -> Construction:
190
198
  construction_id = self._get_construction_id(entity)
191
199
  if construction_id is None:
192
200
  logger.warning(
193
201
  f"Construction ID not found for {entity.GlobalId} ({entity.is_a()}). "
194
202
  f"Using default construction."
195
203
  )
196
- return default_construction
204
+ return default or default_construction
197
205
  constructions = [
198
206
  construction.to_construction()
199
207
  for construction in self.constructions
@@ -225,3 +233,79 @@ class Constructions(BaseModel):
225
233
  else:
226
234
  logger.error("Unexpected material type found.")
227
235
  return None
236
+
237
+ def to_config(self) -> Dict[str, Any]:
238
+ constructions_all = [*self.constructions, default_construction, glass]
239
+ constructions = [
240
+ {
241
+ "id": construction.name,
242
+ "layers": [
243
+ {"material": layer.material.name, "thickness": layer.thickness}
244
+ for layer in construction.layers
245
+ ],
246
+ }
247
+ for construction in constructions_all
248
+ if isinstance(construction, Construction)
249
+ ]
250
+ glazings = [
251
+ {
252
+ "id": construction.name,
253
+ "layers": [
254
+ {
255
+ (
256
+ "glass" if isinstance(layer, GlassLayer) else "gas"
257
+ ): layer.material.name,
258
+ "thickness": layer.thickness,
259
+ }
260
+ for layer in construction.layers
261
+ ],
262
+ }
263
+ for construction in constructions_all
264
+ if isinstance(construction, Glass)
265
+ ]
266
+ materials = {
267
+ layer.material
268
+ for construction in constructions_all
269
+ for layer in construction.layers
270
+ if type(layer.material) is Material
271
+ }
272
+ gas = {
273
+ layer.material
274
+ for construction in constructions_all
275
+ for layer in construction.layers
276
+ if type(layer.material) is Gas
277
+ }
278
+ glass_ = {
279
+ layer.material
280
+ for construction in constructions_all
281
+ for layer in construction.layers
282
+ if type(layer.material) is GlassMaterial
283
+ }
284
+
285
+ materials_ = [
286
+ (material.model_dump(exclude={"name"}) | {"id": material.name})
287
+ for material in materials
288
+ ]
289
+ gas_ = [
290
+ (material.model_dump(exclude={"name"}) | {"id": material.name})
291
+ for material in gas
292
+ ]
293
+ glass_material = [
294
+ (_convert_glass(material) | {"id": material.name}) for material in glass_
295
+ ]
296
+ return {
297
+ "constructions": constructions,
298
+ "material": materials_,
299
+ "glazings": glazings,
300
+ "gas": gas_,
301
+ "glass_material": glass_material,
302
+ }
303
+
304
+
305
+ def _convert_glass(glass_: Material) -> Dict[str, Any]:
306
+ return {
307
+ key: (
308
+ value if not isinstance(value, list) else f"{{{','.join(map(str, value))}}}"
309
+ )
310
+ for key, value in glass_.model_dump().items()
311
+ }
@@ -18,6 +18,10 @@ class NoIfcSpaceFoundError(Exception):
18
18
  pass
19
19
 
20
20
 
21
+ class NoSpaceBoundariesError(Exception):
22
+ pass
23
+
24
+
21
25
  class InvalidLibraryError(Exception):
22
26
  pass
23
27
 
@@ -22,6 +22,38 @@ CHECKMARK = "[green]✔[/green]"
22
22
  CROSS_MARK = "[red]✘[/red]"
23
23
 
24
24
 
25
+ @app.command()
26
+ def config(
27
+ model: Annotated[
28
+ str,
29
+ typer.Argument(help="Local path to the ifc file."),
30
+ ],
31
+ show_space_boundaries: Annotated[
32
+ bool,
33
+ typer.Option(help="Show computed space boundaries."),
34
+ ] = False,
35
+ ) -> None:
36
+ working_directory = Path.cwd()
37
+ with Progress(
38
+ SpinnerColumn(),
39
+ TextColumn("[progress.description]{task.description}"),
40
+ transient=True,
41
+ ) as progress:
42
+ modelica_model_path = Path(model).resolve().with_suffix(".mo")
43
+ config_path = working_directory.joinpath(f"{modelica_model_path.stem}.yaml")
44
+ task = progress.add_task(
45
+ description=f"Generating {config_path} configuration file.",
46
+ total=None,
47
+ )
48
+ building = Building.from_ifc(Path(model))
49
+ if show_space_boundaries:
50
+ print(f"{CHECKMARK} Showing space boundaries.")
51
+ building.show()
52
+ building.to_yaml(config_path)
53
+ progress.remove_task(task)
54
+ print(f"{CHECKMARK} configuration file generated: {config_path}.")
55
+
56
+
25
57
  @app.command()
26
58
  def create(
27
59
  model: Annotated[
@@ -1,11 +1,14 @@
1
+ import logging
2
+ import math
1
3
  import multiprocessing
2
- from typing import Optional, List, Tuple, Any, Annotated
4
+ from typing import Optional, List, Tuple, Any, Annotated, Dict
3
5
 
4
6
  import ifcopenshell
5
7
  import ifcopenshell.geom
6
8
  import ifcopenshell.util.shape
7
9
  from ifcopenshell import entity_instance, file
8
- from pydantic import Field, BeforeValidator, BaseModel
10
+ from pydantic import Field, BeforeValidator, BaseModel, ConfigDict
11
+ from shapely import wkt # type: ignore
9
12
  from trano.data_models.conversion import SpaceParameter # type: ignore
10
13
  from trano.elements import Space as TranoSpace, ExternalWall, Window, BaseWall, ExternalDoor # type: ignore
11
14
  from trano.elements.system import Occupancy # type: ignore
@@ -33,6 +36,8 @@ from ifctrano.utils import (
33
36
 
34
37
  ROOF_VECTOR = Vector(x=0, y=0, z=1)
35
38
 
39
+ logger = logging.getLogger(__name__)
40
+
36
41
 
37
42
  def initialize_tree(ifc_file: file) -> ifcopenshell.geom.tree:
38
43
  tree = ifcopenshell.geom.tree()
@@ -88,6 +93,14 @@ class Space(GlobalId):
88
93
  main_name = f"{remove_non_alphanumeric(self.name)}_" if self.name else ""
89
94
  return f"space_{main_name}{remove_non_alphanumeric(self.entity.GlobalId)}"
90
95
 
96
+ def space_unique_name(self) -> str:
97
+ base_name = remove_non_alphanumeric(self.name) if self.name else ""
98
+ main_name = f"{base_name}_" if base_name else ""
99
+ space_name = f"{main_name}{remove_non_alphanumeric(self.entity.GlobalId)[-3:]}"
100
+ if "space" not in space_name:
101
+ return f"space_{space_name}"
102
+ return space_name
103
+
91
104
 
92
105
  class ExternalSpaceBoundaryGroup(BaseModelConfig):
93
106
  constructions: List[BaseWall]
@@ -146,6 +159,10 @@ class ExternalSpaceBoundaryGroups(BaseModelConfig):
146
159
  )
147
160
 
148
161
 
162
+ def deg_to_rad(deg: float) -> float:
163
+ return deg * math.pi / 180.0
164
+
165
+
149
166
  class Azimuths(BaseModel):
150
167
  north: List[float] = [0.0, 360]
151
168
  east: List[float] = [90.0]
@@ -277,6 +294,63 @@ class SpaceBoundaries(BaseShow):
277
294
  if space_boundary in self.boundaries:
278
295
  self.boundaries.remove(space_boundary)
279
296
 
297
+ def to_config(
298
+ self,
299
+ exclude_entities: List[str],
300
+ north_axis: Vector,
301
+ constructions: Constructions,
302
+ ) -> Dict[str, Any]:
303
+ external_boundaries: Dict[str, Any] = {
304
+ "external_walls": [],
305
+ "floor_on_grounds": [],
306
+ "windows": [],
307
+ }
308
+ external_boundaries_check = []
309
+ for boundary in self.boundaries:
310
+ boundary_model = boundary.model_element(
311
+ exclude_entities, north_axis, constructions
312
+ )
313
+ if boundary_model:
314
+ external_boundaries_check.append(boundary_model)
315
+ element = {
316
+ "surface": boundary_model.surface,
317
+ "azimuth": deg_to_rad(boundary_model.azimuth),
318
+ "tilt": boundary_model.tilt.value,
319
+ "construction": boundary_model.construction.name,
320
+ }
321
+ if isinstance(
322
+ boundary_model, (ExternalWall, ExternalDoor)
323
+ ) and boundary_model.tilt in [Tilt.wall, Tilt.ceiling]:
324
+ external_boundaries["external_walls"].append(element)
325
+ elif isinstance(boundary_model, (Window)):
326
+ external_boundaries["windows"].append(element)
327
+ elif isinstance(
328
+ boundary_model, (ExternalWall)
329
+ ) and boundary_model.tilt in [Tilt.floor]:
330
+ external_boundaries["floor_on_grounds"].append(element)
331
+ else:
332
+ raise ValueError("Unknown boundary type")
333
+ external_space_boundaries_group = (
334
+ ExternalSpaceBoundaryGroups.from_external_boundaries(
335
+ external_boundaries_check
336
+ )
337
+ )
338
+ if not external_space_boundaries_group.has_windows_without_wall():
339
+ logger.error(
340
+ f"Space {self.space.global_id} has a boundary that has a windows but without walls."
341
+ )
342
+ occupancy_parameters = Occupancy().parameters.model_dump(mode="json")
343
+ space_parameters = SpaceParameter(
344
+ floor_area=self.space.floor_area,
345
+ average_room_height=self.space.average_room_height,
346
+ ).model_dump(mode="json")
347
+ return {
348
+ "id": self.space.space_unique_name(),
349
+ "occupancy": {"parameters": occupancy_parameters},
350
+ "parameters": space_parameters,
351
+ "external_boundaries": external_boundaries,
352
+ }
353
+
280
354
  def model(
281
355
  self,
282
356
  exclude_entities: List[str],
@@ -340,4 +414,109 @@ class SpaceBoundaries(BaseShow):
340
414
  )
341
415
  if space_boundary:
342
416
  space_boundaries.append(space_boundary)
343
- return cls(space=space_, boundaries=space_boundaries)
417
+ merged_boundaries = MergedSpaceBoundaries.from_boundaries(space_boundaries)
418
+ space_boundaries_ = merged_boundaries.merge_boundaries_from_part()
419
+ space_boundaries__ = remove_duplicate_boundaries(space_boundaries_)
420
+ return cls(space=space_, boundaries=space_boundaries__)
421
+
422
+
423
+ def remove_duplicate_boundaries(
424
+ boundaries: List[SpaceBoundary],
425
+ ) -> List[SpaceBoundary]:
426
+ types = ["IfcRoof", "IfcSlab"]
427
+ boundaries = sorted(boundaries, key=lambda b: b.entity.GlobalId)
428
+ boundaries_without_types = [
429
+ sp for sp in boundaries if sp.entity.is_a() not in types
430
+ ]
431
+ new_boundaries = []
432
+ for type_ in types:
433
+ references = [sp for sp in boundaries if sp.entity.is_a() == type_]
434
+ while True:
435
+ reference = next(iter(references), None)
436
+ if not reference:
437
+ break
438
+ others = [p_ for p_ in references if p_ != reference]
439
+ intersecting = [
440
+ o
441
+ for o in others
442
+ if (
443
+ wkt.loads(o.common_surface.polygon).intersects(
444
+ wkt.loads(reference.common_surface.polygon)
445
+ )
446
+ and o.common_surface.orientation
447
+ == reference.common_surface.orientation
448
+ )
449
+ and (
450
+ wkt.loads(o.common_surface.polygon).intersection(
451
+ wkt.loads(reference.common_surface.polygon)
452
+ )
453
+ ).area
454
+ > 0
455
+ ]
456
+ current_group = sorted(
457
+ [*intersecting, reference], key=lambda p: p.entity.GlobalId
458
+ )
459
+ new_boundaries.append(next(iter(current_group)))
460
+ references = [p_ for p_ in references if p_ not in current_group]
461
+ return [*boundaries_without_types, *new_boundaries]
462
+
463
+
464
+ class MergedSpaceBoundary(BaseModel):
465
+ model_config = ConfigDict(arbitrary_types_allowed=True)
466
+ parent: entity_instance
467
+ related_boundaries: List[SpaceBoundary]
468
+
469
+ def get_new_boundary(self) -> Optional[SpaceBoundary]:
470
+ related_boundaries = sorted(
471
+ self.related_boundaries, key=lambda b: b.entity.GlobalId
472
+ )
473
+ boundary = next(iter(related_boundaries), None)
474
+ if boundary:
475
+ return SpaceBoundary.model_validate(
476
+ boundary.model_dump() | {"entity": self.parent}
477
+ )
478
+ return None
479
+
480
+
481
+ class MergedSpaceBoundaries(BaseModel):
482
+ part_boundaries: List[MergedSpaceBoundary]
483
+ original_boundaries: List[SpaceBoundary]
484
+
485
+ @classmethod
486
+ def from_boundaries(
487
+ cls, space_boundaries: List[SpaceBoundary]
488
+ ) -> "MergedSpaceBoundaries":
489
+ building_element_part_boundaries = [
490
+ boundary
491
+ for boundary in space_boundaries
492
+ if boundary.entity.is_a() in ["IfcBuildingElementPart"]
493
+ ]
494
+ existing_parent_entities = {
495
+ decompose.RelatingObject
496
+ for b in building_element_part_boundaries
497
+ for decompose in b.entity.Decomposes
498
+ }
499
+ part_boundaries = [
500
+ MergedSpaceBoundary(
501
+ parent=parent,
502
+ related_boundaries=[
503
+ b
504
+ for b in building_element_part_boundaries
505
+ for decompose in b.entity.Decomposes
506
+ if decompose.RelatingObject == parent
507
+ ],
508
+ )
509
+ for parent in existing_parent_entities
510
+ ]
511
+ return cls(
512
+ part_boundaries=part_boundaries, original_boundaries=space_boundaries
513
+ )
514
+
515
+ def merge_boundaries_from_part(self) -> List[SpaceBoundary]:
516
+ new_boundaries = [b.get_new_boundary() for b in self.part_boundaries]
517
+ new_boundaries_ = [nb for nb in new_boundaries if nb is not None]
518
+ return [
519
+ b
520
+ for b in self.original_boundaries
521
+ if b.entity.is_a() not in ["IfcBuildingElementPart"]
522
+ ] + new_boundaries_
@@ -0,0 +1,11 @@
1
+ from typing import Literal
2
+
3
+ BuildingElements = Literal[
4
+ "IfcWall",
5
+ "IfcSlab",
6
+ "IfcRoof",
7
+ "IfcDoor",
8
+ "IfcWindow",
9
+ "IfcPlate",
10
+ "IfcBuildingElementPart",
11
+ ]
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "ifctrano"
3
- version = "0.4.0"
3
+ version = "0.8.0"
4
4
  description = "Package for generating building energy simulation model from IFC"
5
5
  authors = ["Ando Andriamamonjy <andoludovic.andriamamonjy@gmail.com>"]
6
6
  license = "GPL V3"
@@ -11,7 +11,7 @@ keywords = ["BIM","IFC","energy simulation", "modelica", "building energy simula
11
11
  [tool.poetry.dependencies]
12
12
  python = ">=3.10,<3.13"
13
13
  ifcopenshell = "^0.8.1.post1"
14
- trano = "^0.6.0"
14
+ trano = "^0.10.0"
15
15
  shapely = "^2.0.7"
16
16
  typer = "^0.12.5"
17
17
  vedo = "^2025.5.3"
@@ -33,6 +33,7 @@ isort = "^5.13.2"
33
33
  jsf = "^0.11.2"
34
34
  black = "^24.10.0"
35
35
  pytest = "^7.4.3"
36
+ deepdiff = "^8.6.1"
36
37
 
37
38
  [tool.poetry.scripts]
38
39
  ifctrano = "ifctrano.main:app"
@@ -1,5 +0,0 @@
1
- from typing import Literal
2
-
3
- BuildingElements = Literal[
4
- "IfcWall", "IfcSlab", "IfcRoof", "IfcDoor", "IfcWindow", "IfcPlate"
5
- ]
File without changes
File without changes
File without changes
File without changes