mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-12 02:23:34 +00:00
Clean branch
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
# This can be packaged with `pyinstaller --onefile --clean --icon=icon.ico ifcpatch.py`
|
||||
|
||||
import ifcopenshell
|
||||
import logging
|
||||
import argparse
|
||||
|
||||
def execute(args, is_library=None):
|
||||
logging.basicConfig(filename=args['log'], filemode='a', level=logging.DEBUG)
|
||||
logger = logging.getLogger('IFCPatch')
|
||||
print('# Loading IFC file ...')
|
||||
ifc_file = ifcopenshell.open(args['input'])
|
||||
print('# Loading patch recipe ...')
|
||||
patcher = getattr(__import__('recipes.{}'.format(args['recipe'])), args['recipe']).Patcher(
|
||||
args['input'], ifc_file, logger, args['arguments'])
|
||||
print('# Patching ...')
|
||||
patcher.patch()
|
||||
ifc_file = patcher.file
|
||||
if is_library is True:
|
||||
return ifc_file
|
||||
print('# Writing patched file ...')
|
||||
if not args['output']:
|
||||
args['output'] = args['input']
|
||||
if isinstance(ifc_file, str):
|
||||
with open(args['output'], 'w') as text_file:
|
||||
text_file.write(ifc_file)
|
||||
else:
|
||||
ifc_file.write(args['output'])
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Patches IFC files to fix badly formatted data')
|
||||
parser.add_argument(
|
||||
'-i',
|
||||
'--input',
|
||||
type=str,
|
||||
required=True,
|
||||
help='The IFC file to patch')
|
||||
parser.add_argument(
|
||||
'-o',
|
||||
'--output',
|
||||
type=str,
|
||||
help='The output file to save the patched IFC')
|
||||
parser.add_argument(
|
||||
'-r',
|
||||
'--recipe',
|
||||
type=str,
|
||||
required=True,
|
||||
help='Name of the recipe to use when patching')
|
||||
parser.add_argument(
|
||||
'-l',
|
||||
'--log',
|
||||
type=str,
|
||||
help='Specify a log file',
|
||||
default='ifcpatch.log')
|
||||
parser.add_argument(
|
||||
'-a',
|
||||
'--arguments',
|
||||
nargs='+',
|
||||
help='Specify custom arguments to the patch recipe')
|
||||
args = vars(parser.parse_args())
|
||||
|
||||
execute(args)
|
||||
|
||||
print('# All tasks are complete :-)')
|
||||
@@ -0,0 +1,58 @@
|
||||
import ifcopenshell
|
||||
import ifcopenshell.util.selector
|
||||
|
||||
|
||||
class Patcher:
|
||||
def __init__(self, src, file, logger, args=None):
|
||||
self.src = src
|
||||
self.file = file
|
||||
self.logger = logger
|
||||
self.args = args
|
||||
|
||||
def patch(self):
|
||||
self.contained_ins = {}
|
||||
self.aggregates = {}
|
||||
self.new = ifcopenshell.file(schema=self.file.wrapped_data.schema)
|
||||
self.owner_history = None
|
||||
for owner_history in self.file.by_type("IfcOwnerHistory"):
|
||||
self.owner_history = self.new.add(owner_history)
|
||||
break
|
||||
selector = ifcopenshell.util.selector.Selector()
|
||||
for element in selector.parse(self.file, self.args[0]):
|
||||
self.add_element(element)
|
||||
self.create_spatial_tree()
|
||||
self.file = self.new
|
||||
|
||||
def add_element(self, element):
|
||||
new_element = self.new.add(element)
|
||||
for rel in element.ContainedInStructure:
|
||||
spatial_element = rel.RelatingStructure
|
||||
new_spatial_element = self.new.add(spatial_element)
|
||||
self.contained_ins.setdefault(spatial_element.GlobalId, set()).add(new_element)
|
||||
self.add_spatial_tree(spatial_element, new_spatial_element)
|
||||
|
||||
def add_spatial_tree(self, spatial_element, new_spatial_element):
|
||||
for rel in spatial_element.Decomposes:
|
||||
new = self.new.add(rel.RelatingObject)
|
||||
self.aggregates.setdefault(rel.RelatingObject.GlobalId, set()).add(new_spatial_element)
|
||||
self.add_spatial_tree(rel.RelatingObject, new)
|
||||
|
||||
def create_spatial_tree(self):
|
||||
for relating_structure, related_elements in self.contained_ins.items():
|
||||
self.new.createIfcRelContainedInSpatialStructure(
|
||||
ifcopenshell.guid.new(),
|
||||
self.owner_history,
|
||||
None,
|
||||
None,
|
||||
list(related_elements),
|
||||
self.new.by_guid(relating_structure),
|
||||
)
|
||||
for relating_object, related_objects in self.aggregates.items():
|
||||
self.new.createIfcRelAggregates(
|
||||
ifcopenshell.guid.new(),
|
||||
self.owner_history,
|
||||
None,
|
||||
None,
|
||||
self.new.by_guid(relating_object),
|
||||
list(related_objects),
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
import ifcopenshell
|
||||
import ifcopenshell.util.element
|
||||
|
||||
class Patcher:
|
||||
def __init__(self, src, file, logger, args=None):
|
||||
self.src = src
|
||||
self.file = file
|
||||
self.logger = logger
|
||||
self.args = args
|
||||
|
||||
def patch(self):
|
||||
source = ifcopenshell.open(self.args[0])
|
||||
original_project = self.file.by_type('IfcProject')[0]
|
||||
merged_project = self.file.add(source.by_type('IfcProject')[0])
|
||||
for element in source.by_type('IfcRoot'):
|
||||
self.file.add(element)
|
||||
for inverse in self.file.get_inverse(merged_project):
|
||||
ifcopenshell.util.element.replace_attribute(inverse, merged_project, original_project)
|
||||
self.file.remove(merged_project)
|
||||
@@ -0,0 +1,18 @@
|
||||
import ifcopenshell
|
||||
import ifcopenshell.util.schema
|
||||
|
||||
|
||||
class Patcher:
|
||||
def __init__(self, src, file, logger, args=None):
|
||||
self.src = src
|
||||
self.file = file
|
||||
self.logger = logger
|
||||
self.args = args
|
||||
|
||||
def patch(self):
|
||||
self.new = ifcopenshell.file(schema=self.args[0])
|
||||
migrator = ifcopenshell.util.schema.Migrator()
|
||||
for element in self.file:
|
||||
print("Migrating", element)
|
||||
print("Successfully converted to", migrator.migrate(element, self.new))
|
||||
self.file = self.new
|
||||
@@ -0,0 +1,75 @@
|
||||
import math
|
||||
|
||||
class Patcher:
|
||||
def __init__(self, src, file, logger, args=None):
|
||||
self.src = src
|
||||
self.file = file
|
||||
self.logger = logger
|
||||
self.args = args
|
||||
|
||||
def patch(self):
|
||||
absolute_placements = []
|
||||
|
||||
for product in self.file.by_type('IfcProduct'):
|
||||
if not product.ObjectPlacement:
|
||||
continue
|
||||
absolute_placement = self.get_absolute_placement(product.ObjectPlacement)
|
||||
if absolute_placement.is_a('IfcLocalPlacement'):
|
||||
absolute_placements.append(absolute_placement)
|
||||
absolute_placements = set(absolute_placements)
|
||||
|
||||
for placement in absolute_placements:
|
||||
offset_location = (
|
||||
placement.RelativePlacement.Location.Coordinates[0] + float(self.args[0]),
|
||||
placement.RelativePlacement.Location.Coordinates[1] + float(self.args[1]),
|
||||
placement.RelativePlacement.Location.Coordinates[2] + float(self.args[2])
|
||||
)
|
||||
|
||||
relative_placement = self.file.createIfcAxis2Placement3D(
|
||||
self.file.createIfcCartesianPoint(offset_location))
|
||||
|
||||
if placement.RelativePlacement.Axis:
|
||||
relative_placement.Axis = placement.RelativePlacement.Axis
|
||||
if placement.RelativePlacement.RefDirection:
|
||||
relative_placement.RefDirection = placement.RelativePlacement.RefDirection
|
||||
|
||||
angle = float(self.args[3])
|
||||
if not angle:
|
||||
placement.RelativePlacement = relative_placement
|
||||
continue
|
||||
|
||||
rotation_matrix = self.z_rotation_matrix(math.radians(angle))
|
||||
relative_placement.Location.Coordinates = self.multiply_by_matrix(offset_location, rotation_matrix)
|
||||
|
||||
if placement.RelativePlacement.Axis:
|
||||
z_axis = placement.RelativePlacement.Axis.DirectionRatios
|
||||
relative_placement.Axis = self.file.createIfcDirection(
|
||||
self.multiply_by_matrix(z_axis, rotation_matrix))
|
||||
|
||||
if placement.RelativePlacement.RefDirection:
|
||||
x_axis = placement.RelativePlacement.RefDirection.DirectionRatios
|
||||
else:
|
||||
x_axis = (1., 0., 0.)
|
||||
relative_placement.RefDirection = self.file.createIfcDirection(
|
||||
self.multiply_by_matrix(x_axis, rotation_matrix))
|
||||
|
||||
placement.RelativePlacement = relative_placement
|
||||
|
||||
def get_absolute_placement(self, object_placement):
|
||||
if object_placement.PlacementRelTo:
|
||||
return self.get_absolute_placement(object_placement.PlacementRelTo)
|
||||
return object_placement
|
||||
|
||||
def z_rotation_matrix(self, angle):
|
||||
return [
|
||||
[math.cos(angle), -math.sin(angle), 0.],
|
||||
[math.sin(angle), math.cos(angle), 0.],
|
||||
[0., 0., 1.]
|
||||
]
|
||||
|
||||
def multiply_by_matrix(self, v, m):
|
||||
return [
|
||||
v[0]*m[0][0] + v[1]*m[0][1] + v[2]*m[0][2],
|
||||
v[0]*m[1][0] + v[1]*m[1][1] + v[2]*m[1][2],
|
||||
v[0]*m[2][0] + v[1]*m[2][1] + v[2]*m[2][2]
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
class Patcher:
|
||||
def __init__(self, src, file, logger, args=None):
|
||||
self.src = src
|
||||
self.file = file
|
||||
self.logger = logger
|
||||
self.args = args
|
||||
|
||||
def patch(self):
|
||||
project = self.file.by_type('IfcProject')[0]
|
||||
storeys = self.find_decomposed_ifc_class(project, 'IfcBuildingStorey')
|
||||
for storey in storeys:
|
||||
co = storey.ObjectPlacement.RelativePlacement.Location.Coordinates
|
||||
storey.ObjectPlacement.RelativePlacement.Location.Coordinates = (co[0], co[1], co[2]+float(self.args[0]))
|
||||
co = storey.ObjectPlacement.RelativePlacement.Location.Coordinates
|
||||
# NOTE If the geometric data is provided (ObjectPlacement is
|
||||
# specified), the Elevation value shall either not be included, or
|
||||
# be equal to the local placement Z value.
|
||||
storey.Elevation = co[2]
|
||||
|
||||
def find_decomposed_ifc_class(self, element, ifc_class):
|
||||
results = []
|
||||
rel_aggregates = element.IsDecomposedBy
|
||||
if not rel_aggregates:
|
||||
return results
|
||||
for rel_aggregate in rel_aggregates:
|
||||
for part in rel_aggregate.RelatedObjects:
|
||||
if part.is_a(ifc_class):
|
||||
results.append(part)
|
||||
results.extend(self.find_decomposed_ifc_class(part, ifc_class))
|
||||
return results
|
||||
@@ -0,0 +1,66 @@
|
||||
import ifcopenshell
|
||||
import ifcopenshell.util.element
|
||||
from toposort import toposort_flatten as toposort
|
||||
|
||||
|
||||
|
||||
class Patcher:
|
||||
def __init__(self, src, file, logger, args=None):
|
||||
self.src = src
|
||||
self.file = file
|
||||
self.logger = logger
|
||||
self.args = args
|
||||
self.optimized_file = ifcopenshell.file(schema=self.file.schema)
|
||||
|
||||
|
||||
def patch(self):
|
||||
def generate_instances_and_references():
|
||||
"""
|
||||
Generator which yields an entity id and
|
||||
the set of all of its references contained in its attributes.
|
||||
"""
|
||||
for inst in self.file:
|
||||
yield inst.id(), set(i.id() for i in self.file.traverse(inst)[1:] if i.id())
|
||||
|
||||
instance_mapping = {}
|
||||
|
||||
|
||||
def map_value(v):
|
||||
"""
|
||||
Recursive function which replicates an entity instance, with
|
||||
its attributes, mapping references to already registered
|
||||
instances. Indeed, because of the toposort we know that
|
||||
forward attribute value instances are mapped before the instances
|
||||
that reference them.
|
||||
"""
|
||||
if isinstance(v, (list, tuple)):
|
||||
# lists are recursively traversed
|
||||
return type(v)(map(map_value, v))
|
||||
elif isinstance(v, ifcopenshell.entity_instance):
|
||||
if v.id() == 0:
|
||||
# express simple types are not part of the toposort and just copied
|
||||
return self.optimized_file.create_entity(v.is_a(), v[0])
|
||||
|
||||
|
||||
return instance_mapping[v]
|
||||
else:
|
||||
# a plain python value can just be returned
|
||||
return v
|
||||
|
||||
info_to_id = {}
|
||||
|
||||
for id in toposort(dict(generate_instances_and_references())):
|
||||
inst = self.file[id]
|
||||
info = inst.get_info(include_identifier=False, recursive=True, return_type=frozenset)
|
||||
if info in info_to_id:
|
||||
mapped = instance_mapping[inst] = instance_mapping[self.file[info_to_id[info]]]
|
||||
|
||||
else:
|
||||
info_to_id[info] = id
|
||||
instance_mapping[inst] = self.optimized_file.create_entity(
|
||||
inst.is_a(),
|
||||
*map(map_value, inst)
|
||||
)
|
||||
|
||||
self.file = self.optimized_file
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
from collections import deque
|
||||
import ifcopenshell.util.element
|
||||
|
||||
class Patcher:
|
||||
def __init__(self, src, file, logger, args=None):
|
||||
self.src = src
|
||||
self.file = file
|
||||
self.logger = logger
|
||||
self.args = args
|
||||
|
||||
def patch(self):
|
||||
deleted = []
|
||||
hashes = {}
|
||||
for element in self.file:
|
||||
if element.is_a('IfcRoot'):
|
||||
continue
|
||||
h = hash(tuple(element))
|
||||
if h in hashes:
|
||||
for inverse in self.file.get_inverse(element):
|
||||
ifcopenshell.util.element.replace_attribute(inverse, element, hashes[h])
|
||||
deleted.append(element.id())
|
||||
else:
|
||||
hashes[h] = element
|
||||
deleted.sort()
|
||||
deleted_q = deque(deleted)
|
||||
new = ''
|
||||
for line in self.file.wrapped_data.to_string().split('\n'):
|
||||
try:
|
||||
if int(line.split('=')[0][1:]) != deleted_q[0]:
|
||||
new += (line + '\n')
|
||||
else:
|
||||
deleted_q.popleft()
|
||||
except:
|
||||
new += (line + '\n')
|
||||
self.file = new
|
||||
@@ -0,0 +1,24 @@
|
||||
class Patcher:
|
||||
def __init__(self, src, file, logger, args=None):
|
||||
self.src = src
|
||||
self.file = file
|
||||
self.logger = logger
|
||||
self.args = args
|
||||
|
||||
def patch(self):
|
||||
project = self.file.by_type('IfcProject')[0]
|
||||
sites = self.find_decomposed_ifc_class(project, 'IfcSite')
|
||||
for site in sites:
|
||||
site.Representation = None
|
||||
|
||||
def find_decomposed_ifc_class(self, element, ifc_class):
|
||||
results = []
|
||||
rel_aggregates = element.IsDecomposedBy
|
||||
if not rel_aggregates:
|
||||
return results
|
||||
for rel_aggregate in rel_aggregates:
|
||||
for part in rel_aggregate.RelatedObjects:
|
||||
if part.is_a(ifc_class):
|
||||
results.append(part)
|
||||
results.extend(self.find_decomposed_ifc_class(part, ifc_class))
|
||||
return results
|
||||
@@ -0,0 +1,69 @@
|
||||
class Patcher:
|
||||
def __init__(self, src, file, logger, args=None):
|
||||
self.src = src
|
||||
self.file = file
|
||||
self.logger = logger
|
||||
self.args = args
|
||||
|
||||
def patch(self):
|
||||
placement_coord_ids = set()
|
||||
for placement in self.file.by_type('IfcObjectPlacement'):
|
||||
[placement_coord_ids.add(e.id()) for e in self.file.traverse(placement) if e.is_a('IfcCartesianPoint')]
|
||||
|
||||
# Arbitrary threshold based on experience
|
||||
self.threshold = 1000000
|
||||
if self.args and len(self.args) == 1:
|
||||
self.threshold = float(self.args[0])
|
||||
elif self.args and len(self.args) == 4:
|
||||
self.threshold = float(self.args[3])
|
||||
|
||||
# This method will not work all the time, but will catch most issues. It
|
||||
# assumes that absolute coordinates are easily recognisable based on
|
||||
# having a large absolute value above a threshold. This is not always
|
||||
# the case, but is very fast to run, and works for most cases.
|
||||
offset_point = None
|
||||
if self.args and len(self.args) >= 3:
|
||||
offset_point = (float(self.args[0]),float(self.args[1]),float(self.args[2]))
|
||||
try:
|
||||
point_lists = self.file.by_type('IfcCartesianPointList3D')
|
||||
except:
|
||||
# IFC2X3 does not have IfcCartesianPointList3D
|
||||
point_lists = []
|
||||
for point_list in point_lists:
|
||||
coord_list = [None] * len(point_list.CoordList)
|
||||
for i, point in enumerate(point_list.CoordList):
|
||||
if len(point) == 2 or not self.is_point_far_away(point):
|
||||
coord_list[i] = point
|
||||
continue
|
||||
if not offset_point:
|
||||
offset_point = (-point[0], -point[1], -point[2])
|
||||
self.logger.info(f'Resetting absolute coordinates by {point}')
|
||||
point = (
|
||||
point[0] + offset_point[0],
|
||||
point[1] + offset_point[1],
|
||||
point[2] + offset_point[2]
|
||||
)
|
||||
coord_list[i] = point
|
||||
point_list.CoordList = coord_list
|
||||
for point in self.file.by_type('IfcCartesianPoint'):
|
||||
if len(point.Coordinates) == 2 or not self.is_point_far_away(point):
|
||||
continue
|
||||
if point.id() in placement_coord_ids:
|
||||
continue
|
||||
if not offset_point:
|
||||
offset_point = (-point.Coordinates[0], -point.Coordinates[1], -point.Coordinates[2])
|
||||
self.logger.info(f'Resetting absolute coordinates by {point}')
|
||||
point.Coordinates = (
|
||||
point.Coordinates[0] + offset_point[0],
|
||||
point.Coordinates[1] + offset_point[1],
|
||||
point.Coordinates[2] + offset_point[2]
|
||||
)
|
||||
|
||||
def is_point_far_away(self, point):
|
||||
if hasattr(point, 'Coordinates'):
|
||||
return abs(point.Coordinates[0]) > self.threshold \
|
||||
or abs(point.Coordinates[1]) > self.threshold \
|
||||
or abs(point.Coordinates[2]) > self.threshold
|
||||
return abs(point[0]) > self.threshold \
|
||||
or abs(point[1]) > self.threshold \
|
||||
or abs(point[2]) > self.threshold
|
||||
@@ -0,0 +1,31 @@
|
||||
class Patcher:
|
||||
def __init__(self, src, file, logger, args=None):
|
||||
self.src = src
|
||||
self.file = file
|
||||
self.logger = logger
|
||||
self.args = args
|
||||
|
||||
def patch(self):
|
||||
project = self.file.by_type('IfcProject')[0]
|
||||
spatial_elements = self.find_decomposed_ifc_class(project, self.args[0])
|
||||
for spatial_element in spatial_elements:
|
||||
self.patch_placement_to_origin(spatial_element)
|
||||
|
||||
def find_decomposed_ifc_class(self, element, ifc_class):
|
||||
results = []
|
||||
rel_aggregates = element.IsDecomposedBy
|
||||
if not rel_aggregates:
|
||||
return results
|
||||
for rel_aggregate in rel_aggregates:
|
||||
for part in rel_aggregate.RelatedObjects:
|
||||
if part.is_a(ifc_class):
|
||||
results.append(part)
|
||||
results.extend(self.find_decomposed_ifc_class(part, ifc_class))
|
||||
return results
|
||||
|
||||
def patch_placement_to_origin(self, element):
|
||||
element.ObjectPlacement.RelativePlacement.Location.Coordinates = (0., 0., 0.)
|
||||
if element.ObjectPlacement.RelativePlacement.Axis:
|
||||
element.ObjectPlacement.RelativePlacement.Axis.DirectionRatios = (0., 0., 1.)
|
||||
if element.ObjectPlacement.RelativePlacement.RefDirection:
|
||||
element.ObjectPlacement.RelativePlacement.RefDirection.DirectionRatios = (1., 0., 0.)
|
||||
@@ -0,0 +1,24 @@
|
||||
class Patcher:
|
||||
def __init__(self, src, file, logger, args=None):
|
||||
self.src = src
|
||||
self.file = file
|
||||
self.logger = logger
|
||||
self.args = args
|
||||
|
||||
def patch(self):
|
||||
project = self.file.by_type('IfcProject')[0]
|
||||
sites = self.find_decomposed_ifc_class(project, 'IfcSite')
|
||||
for site in sites:
|
||||
site.RefElevation = float(self.args[0])
|
||||
|
||||
def find_decomposed_ifc_class(self, element, ifc_class):
|
||||
results = []
|
||||
rel_aggregates = element.IsDecomposedBy
|
||||
if not rel_aggregates:
|
||||
return results
|
||||
for rel_aggregate in rel_aggregates:
|
||||
for part in rel_aggregate.RelatedObjects:
|
||||
if part.is_a(ifc_class):
|
||||
results.append(part)
|
||||
results.extend(self.find_decomposed_ifc_class(part, ifc_class))
|
||||
return results
|
||||
@@ -0,0 +1,42 @@
|
||||
class Patcher:
|
||||
def __init__(self, src, file, logger, args=None):
|
||||
self.src = src
|
||||
self.file = file
|
||||
self.logger = logger
|
||||
self.args = args
|
||||
|
||||
def patch(self):
|
||||
import ifcopenshell
|
||||
from shutil import copyfile
|
||||
storeys = self.file.by_type('IfcBuildingStorey')
|
||||
for i, storey in enumerate(storeys):
|
||||
dest = '{}-{}.ifc'.format(i, storey.Name)
|
||||
copyfile(self.src, dest)
|
||||
old_ifc = ifcopenshell.open(dest)
|
||||
new_ifc = ifcopenshell.file(schema=self.file.schema)
|
||||
if self.file.schema == 'IFC2X3':
|
||||
elements = old_ifc.by_type('IfcProject') + old_ifc.by_type('IfcProduct')
|
||||
else:
|
||||
elements = old_ifc.by_type('IfcContext') + old_ifc.by_type('IfcProduct')
|
||||
inverse_elements = []
|
||||
for element in elements:
|
||||
if element.is_a('IfcElement') \
|
||||
and not self.is_in_storey(element, storey):
|
||||
element.Representation = None
|
||||
continue
|
||||
if element.is_a('IfcElement'):
|
||||
styled_rep_items = [i for i in old_ifc.traverse(element) if i.is_a('IfcRepresentationItem') and i.StyledByItem]
|
||||
[new_ifc.add(i.StyledByItem[0]) for i in styled_rep_items]
|
||||
new_ifc.add(element)
|
||||
inverse_elements.extend(old_ifc.get_inverse(element))
|
||||
for inverse_element in inverse_elements:
|
||||
new_ifc.add(inverse_element)
|
||||
for element in new_ifc.by_type('IfcElement'):
|
||||
if not self.is_in_storey(element, storey):
|
||||
new_ifc.remove(element)
|
||||
new_ifc.write(dest)
|
||||
|
||||
def is_in_storey(self, element, storey):
|
||||
return element.ContainedInStructure \
|
||||
and element.ContainedInStructure[0].RelatingStructure.is_a('IfcBuildingStorey') \
|
||||
and element.ContainedInStructure[0].RelatingStructure.GlobalId == storey.GlobalId
|
||||
Reference in New Issue
Block a user