Skip to content

Commit 86a44c2

Browse files
committed
WIP experimental hpp-fcl and aabbtree-based IfcClash
1 parent a427462 commit 86a44c2

File tree

2 files changed

+398
-0
lines changed

2 files changed

+398
-0
lines changed

src/ifcclash/ifcclash/collider.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import hppfcl
2+
import numpy as np
3+
from aabbtree import AABB
4+
from aabbtree import AABBTree
5+
6+
7+
class Collider:
8+
def __init__(self):
9+
self.groups = {}
10+
11+
def create_group(self, name):
12+
self.groups[name] = {"tree": AABBTree(), "objects": {}}
13+
14+
def create_object(self, group_name, id, shape):
15+
obj = hppfcl.CollisionObject(
16+
self.create_bvh(shape.geometry), self.create_transform(shape.transformation.matrix.data)
17+
)
18+
aabb = obj.getAABB()
19+
c = aabb.center()
20+
x = aabb.width()
21+
y = aabb.height()
22+
z = aabb.depth()
23+
aabb = AABB([(c[0] - x / 2, c[0] + x / 2), (c[1] - y / 2, c[1] + y / 2), (c[2] - z / 2, c[2] + z / 2)])
24+
self.groups[group_name]["tree"].add(aabb, id)
25+
self.groups[group_name]["objects"][id] = (aabb, obj)
26+
27+
def collide_internal(self, name):
28+
print('starting internal collision')
29+
return self.collide_narrowphase(self.collide_broadphase(name, name))
30+
31+
def collide_group(self, name1, name2):
32+
print('starting group collision')
33+
return self.collide_narrowphase(self.collide_broadphase(name1, name2))
34+
35+
def collide_broadphase(self, name1, name2):
36+
print('Begin broad phase')
37+
potential_collisions = []
38+
checked_collisions = set()
39+
i = 0
40+
for id, obj_data in self.groups[name1]["objects"].items():
41+
aabb, obj = obj_data
42+
collision_stack = [self.groups[name2]["tree"]]
43+
checked_collisions.add(id)
44+
i += 1
45+
while i % 1000 == 0:
46+
print(i, '...')
47+
while collision_stack:
48+
node = collision_stack.pop()
49+
if node.value == id or node.value in checked_collisions:
50+
continue
51+
if node.does_overlap(aabb):
52+
if node.is_leaf:
53+
potential_collisions.append(
54+
{
55+
"id1": id,
56+
"obj1": obj,
57+
"id2": node.value,
58+
"obj2": self.groups[name2]["objects"][node.value][1],
59+
}
60+
)
61+
else:
62+
collision_stack.append(node.left)
63+
collision_stack.append(node.right)
64+
return potential_collisions
65+
66+
def collide_narrowphase(self, potential_collisions):
67+
print('Begin narrow phase')
68+
collisions = []
69+
for data in potential_collisions:
70+
result = hppfcl.CollisionResult()
71+
hppfcl.collide(data["obj1"], data["obj2"], hppfcl.CollisionRequest(), result)
72+
if result.isCollision():
73+
collisions.append({"id1": data["id1"], "id2": data["id2"], "collision": result})
74+
print({"id1": data["id1"], "id2": data["id2"], "collision": result})
75+
return collisions
76+
77+
def create_transform(self, m):
78+
mat = np.array([[m[0], m[3], m[6], m[9]], [m[1], m[4], m[7], m[10]], [m[2], m[5], m[8], m[11]], [0, 0, 0, 1]])
79+
mat.transpose()
80+
return hppfcl.Transform3f(mat[:3, :3], mat[:3, 3])
81+
82+
def create_bvh(self, mesh):
83+
v = mesh.verts
84+
f = mesh.faces
85+
mesh_verts = np.array([[v[i], v[i + 1], v[i + 2]] for i in range(0, len(v), 3)])
86+
mesh_faces = [(int(f[i]), int(f[i + 1]), int(f[i + 2])) for i in range(0, len(f), 3)]
87+
88+
bvh = hppfcl.BVHModelOBB()
89+
bvh.beginModel(num_tris=len(mesh.faces), num_vertices=len(mesh_verts))
90+
vertices = hppfcl.StdVec_Vec3f()
91+
[vertices.append(v) for v in mesh_verts]
92+
triangles = hppfcl.StdVec_Triangle()
93+
[triangles.append(hppfcl.Triangle(f[0], f[1], f[2])) for f in mesh_faces]
94+
bvh.addSubModel(vertices, triangles)
95+
bvh.endModel()
96+
return bvh

src/ifcclash/ifcclash/ifcclash.py

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
#!/usr/bin/env python3
2+
3+
import ifcopenshell
4+
import ifcopenshell.geom
5+
import ifcopenshell.util.selector
6+
import multiprocessing
7+
import numpy as np
8+
import json
9+
import sys
10+
import argparse
11+
import logging
12+
from . import collider
13+
14+
15+
class Clasher:
16+
def __init__(self, settings):
17+
self.settings = settings
18+
self.geom_settings = ifcopenshell.geom.settings()
19+
self.clash_sets = []
20+
self.collider = collider.Collider()
21+
self.selector = ifcopenshell.util.selector.Selector()
22+
self.ifcs = {}
23+
24+
def clash(self):
25+
existing_limit = sys.getrecursionlimit()
26+
sys.setrecursionlimit(100000)
27+
for clash_set in self.clash_sets:
28+
self.process_clash_set(clash_set)
29+
sys.setrecursionlimit(existing_limit)
30+
31+
def process_clash_set(self, clash_set):
32+
print("proccessings", clash_set)
33+
self.collider.create_group("a")
34+
for source in clash_set["a"]:
35+
self.add_collision_objects(
36+
"a", self.load_ifc(source["file"]), source.get("mode", None), source.get("selector", None)
37+
)
38+
39+
if "b" in clash_set:
40+
self.collider.create_group("b")
41+
for source in clash_set["b"]:
42+
self.add_collision_objects(
43+
"b", self.load_ifc(source["file"]), source.get("mode", None), source.get("selector", None)
44+
)
45+
results = self.collider.collide_group("a", "b")
46+
else:
47+
results = self.collider.collide_internal("a")
48+
49+
for result in results:
50+
print("*" * 10)
51+
print("Is Collision:", result["collision"].isCollision())
52+
print(result["id1"], result["id2"])
53+
print("Number of contacts:", result["collision"].numContacts())
54+
for contact in result["collision"].getContacts():
55+
print(contact)
56+
57+
def load_ifc(self, path):
58+
ifc = self.ifcs.get(path, None)
59+
if not ifc:
60+
ifc = ifcopenshell.open(path)
61+
self.ifcs[path] = ifc
62+
return ifc
63+
64+
def add_collision_objects(self, name, ifc_file, mode=None, selector=None):
65+
print('adding collision objects', name)
66+
if not mode:
67+
iterator = ifcopenshell.geom.iterator(
68+
self.geom_settings,
69+
ifc_file,
70+
multiprocessing.cpu_count(),
71+
exclude=(ifc_file.by_type("IfcSpatialStructureElement")),
72+
)
73+
elif mode == "e":
74+
iterator = ifcopenshell.geom.iterator(
75+
self.geom_settings,
76+
ifc_file,
77+
multiprocessing.cpu_count(),
78+
exclude=selector.parse(ifc_file, selector),
79+
)
80+
elif mode == "i":
81+
iterator = ifcopenshell.geom.iterator(
82+
self.geom_settings,
83+
ifc_file,
84+
multiprocessing.cpu_count(),
85+
include=selector.parse(ifc_file, selector),
86+
)
87+
valid_file = iterator.initialize()
88+
if not valid_file:
89+
return False
90+
old_progress = -1
91+
while True:
92+
shape = iterator.get()
93+
self.collider.create_object(name, shape.guid, shape)
94+
if not iterator.next():
95+
break
96+
97+
def export(self):
98+
if len(self.settings.output) > 4 and self.settings.output[-4:] == ".bcf":
99+
return self.export_bcfxml()
100+
self.export_json()
101+
102+
def export_bcfxml(self):
103+
import bcf
104+
import bcf.bcfxml
105+
106+
for i, clash_set in enumerate(self.clash_sets):
107+
bcfxml = bcf.bcfxml.BcfXml()
108+
bcfxml.new_project()
109+
bcfxml.project.name = clash_set["name"]
110+
bcfxml.edit_project()
111+
for key, clash in clash_set["clashes"].items():
112+
topic = bcf.data.Topic()
113+
topic.title = "{}/{} and {}/{}".format(
114+
clash["a_ifc_class"], clash["a_name"], clash["b_ifc_class"], clash["b_name"]
115+
)
116+
topic = bcfxml.add_topic(topic)
117+
viewpoint = bcf.data.Viewpoint()
118+
viewpoint.perspective_camera = bcf.data.PerspectiveCamera()
119+
position = np.array(clash["position"])
120+
point = position + np.array((5, 5, 5)) # Dumb, but works!
121+
viewpoint.perspective_camera.camera_view_point.x = point[0]
122+
viewpoint.perspective_camera.camera_view_point.y = point[1]
123+
viewpoint.perspective_camera.camera_view_point.z = point[2]
124+
mat = self.get_track_to_matrix(point, position)
125+
viewpoint.perspective_camera.camera_direction.x = mat[0][2] * -1
126+
viewpoint.perspective_camera.camera_direction.y = mat[1][2] * -1
127+
viewpoint.perspective_camera.camera_direction.z = mat[2][2] * -1
128+
viewpoint.perspective_camera.camera_up_vector.x = mat[0][1]
129+
viewpoint.perspective_camera.camera_up_vector.y = mat[1][1]
130+
viewpoint.perspective_camera.camera_up_vector.z = mat[2][1]
131+
viewpoint.components = bcf.data.Components()
132+
c1 = bcf.data.Component()
133+
c1.ifc_guid = clash["a_global_id"]
134+
c2 = bcf.data.Component()
135+
c2.ifc_guid = clash["b_global_id"]
136+
viewpoint.components.selection.append(c1)
137+
viewpoint.components.selection.append(c2)
138+
viewpoint.components.visibility = bcf.data.ComponentVisibility()
139+
viewpoint.components.visibility.default_visibility = True
140+
viewpoint.snapshot = self.get_viewpoint_snapshot(viewpoint, mat)
141+
bcfxml.add_viewpoint(topic, viewpoint)
142+
if i == 0:
143+
bcfxml.save_project(self.settings.output)
144+
else:
145+
bcfxml.save_project(self.settings.output + f".{i}")
146+
147+
def get_viewpoint_snapshot(self, viewpoint, mat):
148+
return None # Possible to overload this function in a GUI application if used as a library
149+
150+
# https://blender.stackexchange.com/questions/68834/recreate-to-track-quat-with-two-vectors-using-python/141706#141706
151+
def get_track_to_matrix(self, camera_position, target_position):
152+
camera_direction = camera_position - target_position
153+
camera_direction = camera_direction / np.linalg.norm(camera_direction)
154+
camera_right = np.cross(np.array([0.0, 0.0, 1.0]), camera_direction)
155+
camera_right = camera_right / np.linalg.norm(camera_right)
156+
camera_up = np.cross(camera_direction, camera_right)
157+
camera_up = camera_up / np.linalg.norm(camera_up)
158+
rotation_transform = np.zeros((4, 4))
159+
rotation_transform[0, :3] = camera_right
160+
rotation_transform[1, :3] = camera_up
161+
rotation_transform[2, :3] = camera_direction
162+
rotation_transform[-1, -1] = 1
163+
translation_transform = np.eye(4)
164+
translation_transform[:3, -1] = -camera_position
165+
look_at_transform = np.matmul(rotation_transform, translation_transform)
166+
return np.linalg.inv(look_at_transform)
167+
168+
def export_json(self):
169+
results = self.clash_sets.copy()
170+
for result in results:
171+
del result["a_cm"]
172+
del result["b_cm"]
173+
for ab in ["a", "b"]:
174+
for data in result[ab]:
175+
if "ifc" in data:
176+
del data["ifc"]
177+
with open(self.settings.output, "w", encoding="utf-8") as clashes_file:
178+
json.dump(results, clashes_file, indent=4)
179+
180+
def get_element(self, clash_group, global_id):
181+
for data in clash_group:
182+
try:
183+
element = data["ifc"].by_guid(global_id)
184+
if element:
185+
return element
186+
except:
187+
pass
188+
189+
def smart_group_clashes(self, clash_sets, max_clustering_distance):
190+
from sklearn.cluster import OPTICS
191+
from collections import defaultdict
192+
193+
count_of_input_clashes = 0
194+
count_of_clash_sets = 0
195+
count_of_smart_groups = 0
196+
count_of_final_clash_sets = 0
197+
198+
count_of_clash_sets = len(clash_sets)
199+
200+
for clash_set in clash_sets:
201+
if not "clashes" in clash_set.keys():
202+
print(f"Skipping clash set [{clash_set['name']}] since it contains no clash results.")
203+
continue
204+
clashes = clash_set["clashes"]
205+
if len(clashes) == 0:
206+
print(f"Skipping clash set [{clash_set['name']}] since it contains no clash results.")
207+
continue
208+
209+
count_of_input_clashes += len(clashes)
210+
211+
positions = []
212+
for clash in clashes.values():
213+
positions.append(clash["position"])
214+
215+
data = np.array(positions)
216+
217+
# INPUTS
218+
# set the desired maximum distance between the grouped points
219+
if max_clustering_distance > 0:
220+
max_distance_between_grouped_points = max_clustering_distance
221+
else:
222+
max_distance_between_grouped_points = 3
223+
224+
model = OPTICS(min_samples=2, max_eps=max_distance_between_grouped_points)
225+
model.fit_predict(data)
226+
pred = model.fit_predict(data)
227+
228+
# Insert the smart groups into the clashes
229+
if len(pred) == len(clashes.values()):
230+
i = 0
231+
for clash in clashes.values():
232+
int_prediction = int(pred[i])
233+
if int_prediction == -1:
234+
# ungroup this clash since it's a single clash that we were not able to group.
235+
new_clash_group_number = np.amax(pred).item() + 1 + i
236+
clash["smart_group"] = new_clash_group_number
237+
else:
238+
clash["smart_group"] = int_prediction
239+
i += 1
240+
241+
# Create JSON with smart_groups that contain GlobalIDs
242+
output_clash_sets = defaultdict(list)
243+
for clash_set in clash_sets:
244+
if not "clashes" in clash_set.keys():
245+
continue
246+
smart_groups = defaultdict(list)
247+
for clash_id, content in clash_set["clashes"].items():
248+
if "smart_group" in content:
249+
object_id_list = list()
250+
# Clash has been grouped, let's extract it.
251+
object_id_list.append(content["a_global_id"])
252+
object_id_list.append(content["b_global_id"])
253+
smart_groups[content["smart_group"]].append(object_id_list)
254+
count_of_smart_groups += len(smart_groups)
255+
output_clash_sets[clash_set["name"]].append(smart_groups)
256+
257+
# Rename the clash groups to something more sensible
258+
for clash_set, smart_groups in output_clash_sets.items():
259+
clash_set_name = clash_set
260+
# Only select the clashes that correspond to the actively selected IFC Clash Set
261+
i = 1
262+
new_smart_group_name = ""
263+
for smart_group, global_id_pairs in list(smart_groups[0].items()):
264+
new_smart_group_name = f"{clash_set_name} - {i}"
265+
smart_groups[0][new_smart_group_name] = smart_groups[0].pop(smart_group)
266+
i += 1
267+
268+
count_of_final_clash_sets = len(output_clash_sets)
269+
print(
270+
f"Took {count_of_input_clashes} clashes in {count_of_clash_sets} clash sets and turned",
271+
f"them into {count_of_smart_groups} smart groups in {count_of_final_clash_sets} clash sets",
272+
)
273+
274+
return output_clash_sets
275+
276+
277+
class ClashSettings:
278+
def __init__(self):
279+
self.logger = None
280+
self.output = "clashes.json"
281+
282+
283+
if __name__ == "__main__":
284+
parser = argparse.ArgumentParser(description="Clashes geometry between two IFC files")
285+
parser.add_argument("input", type=str, help="A JSON dataset describing a series of clashsets")
286+
parser.add_argument(
287+
"-o", "--output", type=str, help="The JSON diff file to output. Defaults to output.json", default="output.json"
288+
)
289+
args = parser.parse_args()
290+
291+
settings = ClashSettings()
292+
settings.output = args.output
293+
settings.logger = logging.getLogger("Clash")
294+
settings.logger.setLevel(logging.DEBUG)
295+
handler = logging.StreamHandler(sys.stdout)
296+
handler.setLevel(logging.DEBUG)
297+
settings.logger.addHandler(handler)
298+
ifc_clasher = Clasher(settings)
299+
with open(args.input, "r") as clash_sets_file:
300+
ifc_clasher.clash_sets = json.loads(clash_sets_file.read())
301+
ifc_clasher.clash()
302+
ifc_clasher.export()

0 commit comments

Comments
 (0)