settings, data model changes (no longer graph on both orientations of plane eq, group equations per product), multiple files, convex ifc definition test, reuse convex decomps for instanced geom, fix remove narrow, option to store eq mapping, multi-threading, command line options

This commit is contained in:
Thomas Krijnen
2024-02-18 11:57:51 +01:00
parent ec5bfd6a68
commit 49e8f8bdca
+363 -116
View File
@@ -1,14 +1,20 @@
import argparse
import json import json
import math
import os import os
import sys import sys
import time import time
import operator import operator
import itertools import itertools
import functools import functools
import threading
import concurrent.futures
import multiprocessing
from collections import defaultdict from collections import defaultdict
from functools import reduce from functools import reduce
from dataclasses import dataclass, field from dataclasses import dataclass, field, fields
try: try:
import igraph as graph import igraph as graph
@@ -22,6 +28,7 @@ except:
import numpy import numpy
from scipy.spatial import KDTree from scipy.spatial import KDTree
from scipy.spatial import ConvexHull
import voxec import voxec
import ifcopenshell import ifcopenshell
@@ -31,23 +38,46 @@ from ifcopenshell.util.unit import calculate_unit_scale
import utils import utils
# numpy.seterr(all='raise')
to_str = lambda eq: tuple(x.to_string() for x in utils.to_tuple(eq))
@dataclass
class settings:
debug : bool = False
verbose : bool = False
resolution : float = 1.e-5
voxel_prefiltering : bool = True
detailed_element_substitution : bool = True
element_categories : list = None
element_guids : list = None
store_mapping : bool = False
existing_mapping : bool = False
@dataclass @dataclass
class model_geometry: class model_geometry:
""" """
Stores the extracted geometric detail for a certain set of elements, including the arbitrarily precise plain equations and their correspondence to polyhedral facets. Stores the extracted geometric detail for a certain set of elements, including the arbitrarily precise plain equations and their correspondence to polyhedral facets.
""" """
# list[pair[int, int]] # list[list[int]]
# ^ convex_halfspace_trees[...] # ~^ non_convex_halfspace_facets_equations[...]~
# ^ convex_halfspace_trees[n][...] # ^ non_convex_halfspace_facets_equations[n][...]
#
# used to map after finding clusters on plane equations
# float_facet_normals[i] -> non_convex_halfspace_facets_equations[i][j]
epeck_equation_idxs: list = field(default_factory=list) epeck_equation_idxs: list = field(default_factory=list)
# list[pair[str, tuple[plane]]] # list[pair[str, tuple[halfspacetree]]]
# used to apply mapping to
convex_halfspace_trees: list = field(default_factory=list) convex_halfspace_trees: list = field(default_factory=list)
# halfspaces > facets > plane_equation
# list[list[plane]] # list[list[plane]]
non_convex_halfspace_facets_equations: list = field(default_factory=list) non_convex_halfspace_facets_equations: list = field(default_factory=list)
# normalized list[ndarray[N, 3]]
float_facet_normals: list = field(default_factory=list) float_facet_normals: list = field(default_factory=list)
# list[ndarray[N, 3]]
float_facet_centroids: list = field(default_factory=list) float_facet_centroids: list = field(default_factory=list)
def __add__(self, other): def __add__(self, other):
@@ -59,9 +89,8 @@ class model_geometry:
Returns: Returns:
_type_: model_geometry _type_: model_geometry
""" """
l = len(self.convex_halfspace_trees)
return model_geometry( return model_geometry(
self.epeck_equation_idxs + [(i + l, j) for i, j in other.epeck_equation_idxs], self.epeck_equation_idxs + other.epeck_equation_idxs,
self.convex_halfspace_trees + other.convex_halfspace_trees, self.convex_halfspace_trees + other.convex_halfspace_trees,
self.non_convex_halfspace_facets_equations + other.non_convex_halfspace_facets_equations, self.non_convex_halfspace_facets_equations + other.non_convex_halfspace_facets_equations,
self.float_facet_normals + other.float_facet_normals, self.float_facet_normals + other.float_facet_normals,
@@ -70,44 +99,69 @@ class model_geometry:
class context: class context:
def __init__(self, fn, debug=False, existing_mapping=False): def __init__(self, fns : list, output : str, st : settings):
self.fn = fn self.fns = fns
self.bfn = os.path.basename(fn)
self.is_substituted = False self.is_substituted = False
self.debug = debug self.settings = st
self.existing_mapping = existing_mapping self.fs = []
substituted_fn = self.bfn + ".substituted.ifc"
if self.settings.detailed_element_substitution:
for fn in fns:
bfn = os.path.basename(fn)
substituted_fn = bfn + ".substituted.ifc"
if os.path.exists(substituted_fn): if os.path.exists(substituted_fn):
self.is_substituted = True self.is_substituted = True
self.f = ifcopenshell.open(substituted_fn) self.fs.append(ifcopenshell.open(substituted_fn))
else: else:
self.f = ifcopenshell.open(fn) self.fs.append(ifcopenshell.open(fn))
if self.settings.voxel_prefiltering:
if self.settings.element_categories:
self.elems = self.prefilter_elements_using_voxelization(exclude=('IfcOpeningElement', 'IfcSpace')) self.elems = self.prefilter_elements_using_voxelization(exclude=('IfcOpeningElement', 'IfcSpace'))
else:
self.elems = self.prefilter_elements_using_voxelization(include=self.settings.element_categories)
elif self.settings.element_categories:
self.elems = reduce(operator.add, itertools.chain.from_iterable((map(f.by_type, self.settings.element_categories) for f in self.fs)))
elif self.settings.element_guids:
def wrap_try(fn, default = None):
def inner():
try:
return fn()
except:
return default
return inner
self.elems = sum((list(map(wrap_try(f.by_guid), self.settings.element_guids)) for f in self.fs), [])
else:
self.elems = [inst for f in self.fs for inst in f.by_type('IfcProduct') if not inst.is_a('IfcOpeningElement') or inst.is_a('IfcSpace')]
if not self.is_substituted: if not self.is_substituted and self.settings.detailed_element_substitution:
self.f, self.orig_f = self.substitute_detailed_elements(include=self.elems), self.f substituted_files = []
self.f.write(substituted_fn) for fn, f in zip(self.fns, self.fs):
bfn = os.path.basename(fn)
substituted_fn = bfn + ".substituted.ifc"
substituted_files.append((self.substitute_detailed_elements(f, include=self.elems), f))
substituted_files[-1][0].write(substituted_fn)
self.fs, self.orig_files = zip(*substituted_files)
self.opening_elems = list(itertools.chain.from_iterable([rel.RelatedOpeningElement for rel in getattr(el, "HasOpenings", ())] for el in self.elems)) self.opening_elems = list(itertools.chain.from_iterable([rel.RelatedOpeningElement for rel in getattr(el, "HasOpenings", ())] for el in self.elems))
openings = self.extract_geometry(include=self.opening_elems) openings = self.extract_geometry(include=self.opening_elems)
data = self.extract_geometry(include=self.elems) data = self.extract_geometry(include=self.elems)
self.remove_narrow(openings) openings = self.remove_narrow(openings)
self.remove_narrow(data) data = self.remove_narrow(data)
all_geom = openings + data all_geom = openings + data
if existing_mapping: if self.settings.existing_mapping:
my_mapping = json.load(open('epeck_mapping.json')) my_mapping = json.load(open('epeck_mapping.json'))
def deser(strs): def deser(strs):
return tuple(utils.to_opaque(tuple(map(utils.create_epeck, st.split(' ')))) for st in strs) return tuple(utils.to_opaque(tuple(map(utils.create_epeck, st.split(' ')))) for st in strs)
my_mapping = {k: list(map(list, zip(*map(deser, vs)))) for k, vs in my_mapping.items()} my_mapping = {k: list(map(list, zip(*map(deser, vs)))) for k, vs in my_mapping.items() if k in map(operator.attrgetter('GlobalId'), self.elems)}
else: else:
my_mapping = self.create_mapping(all_geom) my_mapping = self.create_mapping(all_geom)
self.apply_mapping(all_geom, my_mapping, from_disk=existing_mapping) self.apply_mapping(all_geom, my_mapping, from_disk=self.settings.existing_mapping)
del my_mapping del my_mapping
@@ -118,10 +172,35 @@ class context:
result = self.union(itertools.chain.from_iterable(new_data.values())) result = self.union(itertools.chain.from_iterable(new_data.values()))
with open(self.bfn + ".obj", "w") as ff: with open(output, "w") as ff:
ff.write(result.serialize_obj()) ff.write(result.serialize_obj())
def substitute_with_box(self, file, elem, min_thickness=0.01):
@staticmethod
def definition_is_convex(repitem):
if repitem.is_a('IfcExtrudedAreaSolid'):
if repitem.SweptArea.is_a('IfcRectangleProfileDef'):
return True
if repitem.SweptArea.is_a() == 'IfcArbitraryClosedProfileDef':
crv = repitem.SweptArea.OuterCurve
if crv.is_a('IfcPolyline'):
points = numpy.array([p.Coordinates for p in crv.Points])[:-1, :]
elif crv.is_a('IfcIndexedPolyCurve'):
points = numpy.array(crv.Points.CoordList)
if crv.Segments:
if any(seg.is_a('IfcArcIndex') for seg in crv.Segments):
return False
idxs = numpy.array(seg[0][0] for seg in crv.Segments) - 1
points = points[idxs]
else:
# ?
points = points[:, :-1]
else:
return False
return len(ConvexHull(points[:, 0:2]).vertices) == len(points)
def substitute_with_box(self, file, elem, min_thickness=0.01, force=False):
""" """
Computes a (somewhat) optimal oriented bounding box around the triangulated geometry described in elem by constructing a local reference frame based on the prevalent triangle normals Computes a (somewhat) optimal oriented bounding box around the triangulated geometry described in elem by constructing a local reference frame based on the prevalent triangle normals
@@ -178,7 +257,7 @@ class context:
M = numpy.array((X, -Y, V)) M = numpy.array((X, -Y, V))
Mi = numpy.linalg.inv(M) Mi = numpy.linalg.inv(M)
vsi = numpy.array([Mi @ v for v in vs]) vsi = numpy.array([v @ Mi for v in vs])
vsimi = vsi.min(axis=0) vsimi = vsi.min(axis=0)
vsima = vsi.max(axis=0) vsima = vsi.max(axis=0)
@@ -190,10 +269,36 @@ class context:
vsima[i] += dd vsima[i] += dd
vsimi[i] -= dd vsimi[i] -= dd
def norm(v):
return v / numpy.linalg.norm(v)
def approx_diff():
for tri in vsi[fs]:
e1, e2 = tri[1:] - tri[0]
c = numpy.cross(e1, e2)
a = numpy.linalg.norm(c) / 2.
n = norm(c)
cent = numpy.average(tri, axis=0)
def distances():
for bnd in (vsimi, vsima):
for v in numpy.diag(cent - bnd):
if numpy.linalg.norm(v) < 1.e-9:
yield numpy.inf, 0.
else:
yield norm(v) @ n, numpy.linalg.norm(v)
yield max(distances())[1] * a
if not force:
bbox_dim = functools.reduce(operator.mul, vsima - vsimi)
approx_volume_diff = sum(approx_diff())
volume_factor = approx_volume_diff / bbox_dim
if volume_factor >= 0.25:
return None
vsimi = vsimi / calculate_unit_scale(file) vsimi = vsimi / calculate_unit_scale(file)
vsima = vsima / calculate_unit_scale(file) vsima = vsima / calculate_unit_scale(file)
return (elem.id,) + tuple(x.tolist() for x in (M.T, vsimi, vsima)) return (elem.id,) + tuple(x.tolist() for x in (M, vsimi, vsima))
@utils.trace @utils.trace
def prefilter_elements_using_voxelization(self, **kwargs): def prefilter_elements_using_voxelization(self, **kwargs):
@@ -205,9 +310,10 @@ class context:
Returns: Returns:
list[ifcopenshell.entity_instance] list[ifcopenshell.entity_instance]
""" """
if os.path.exists(self.bfn + ".elements.json"): if all(os.path.exists(bfn + ".elements.json") for bfn in map(os.path.basename, self.fns)):
return [self.f[i] for i in json.load(open(self.bfn + ".elements.json"))] return sum(([f[i] for i in json.load(open(bfn + ".elements.json"))] for f, bfn in zip(self.fs, map(os.path.basename, self.fns))), [])
result = []
results = []
s = ifcopenshell.geom.settings( s = ifcopenshell.geom.settings(
USE_WORLD_COORDS=True, USE_WORLD_COORDS=True,
@@ -219,7 +325,9 @@ class context:
building_elements_union = None building_elements_union = None
building_elements = [] building_elements = []
it = ifcopenshell.geom.iterator(s, self.f, geometry_library="opencascade", **kwargs) for bfn, f in zip(map(os.path.basename, self.fns), self.fs):
result = []
it = ifcopenshell.geom.iterator(s, f, geometry_library="opencascade", **kwargs)
if not it.initialize(): if not it.initialize():
# print(ifcopenshell.get_log()) # print(ifcopenshell.get_log())
@@ -229,10 +337,10 @@ class context:
while True: while True:
elem = it.get() elem = it.get()
geom = elem.geometry.brep_data geom = elem.geometry.brep_data
if self.f[int(elem.geometry.id.split("-")[0])].RepresentationIdentifier != "Box": if f[int(elem.geometry.id.split("-")[0])].RepresentationIdentifier != "Box":
# breakpoint() # breakpoint()
vox = voxec.run("voxelize", geom, method="volume") vox = voxec.run("voxelize", geom, method="volume")
building_elements.append((self.f[elem.id], vox)) building_elements.append((f[elem.id], vox))
if building_elements_union is None: if building_elements_union is None:
building_elements_union = vox building_elements_union = vox
else: else:
@@ -250,8 +358,9 @@ class context:
if exterior_shell_thick.boolean_intersection(vox).count(): if exterior_shell_thick.boolean_intersection(vox).count():
result.append(elem) result.append(elem)
json.dump([i.id() for i in result], open(self.bfn + ".elements.json", "w")) json.dump([i.id() for i in result], open(bfn + ".elements.json", "w"))
return result results.extend(result)
return results
def substitute_detailed_elements(self, file=None, force=False, **kwargs): def substitute_detailed_elements(self, file=None, force=False, **kwargs):
"""Substitute elements with a high vertex count with an """Substitute elements with a high vertex count with an
@@ -269,8 +378,7 @@ class context:
ITERATOR_OUTPUT=ifcopenshell.ifcopenshell_wrapper.TRIANGULATED, ITERATOR_OUTPUT=ifcopenshell.ifcopenshell_wrapper.TRIANGULATED,
DISABLE_OPENING_SUBTRACTIONS=True, DISABLE_OPENING_SUBTRACTIONS=True,
) )
f = file or self.f it = ifcopenshell.geom.iterator(s, file, geometry_library="cgal", **kwargs)
it = ifcopenshell.geom.iterator(s, f, geometry_library="cgal", **kwargs)
if not it.initialize(): if not it.initialize():
return return
@@ -279,12 +387,16 @@ class context:
nat = it.get_native() nat = it.get_native()
elem = it.get() elem = it.get()
num_verts = len(elem.geometry.verts) // 3 num_verts = len(elem.geometry.verts) // 3
num_faces = len(elem.geometry.faces) // 3
volume = sum(nat.geometry.item(i).volume().to_double() for i in range(nat.geometry.size())) volume = sum(nat.geometry.item(i).volume().to_double() for i in range(nat.geometry.size()))
if force or num_verts > 128 or (num_verts / volume) > 2000: if force or num_verts > 128 or ((num_verts / volume) > 2000 and num_faces > 12):
substitutions.append(self.substitute_with_box(f, elem)) subs_result = self.substitute_with_box(f, elem, force=force)
if subs_result:
substitutions.append(subs_result)
if not it.next(): if not it.next():
break break
f = file
for elid, m3, mi, ma in substitutions: for elid, m3, mi, ma in substitutions:
elem = f[elid] elem = f[elid]
elem.ObjectPlacement = f.createIfcLocalPlacement( elem.ObjectPlacement = f.createIfcLocalPlacement(
@@ -324,73 +436,115 @@ class context:
return f return f
@utils.trace @utils.trace
# @profile
def extract_geometry(self, **kwargs): def extract_geometry(self, **kwargs):
# not only align facets part of the (potentially concave) input polyhedron, but also align facets resulting from the convex decomposition # not only align facets part of the (potentially concave) input polyhedron, but also align facets resulting from the convex decomposition
ALIGN_INNER = True ALIGN_INNER = True
s = ifcopenshell.geom.settings( s = ifcopenshell.geom.settings(
USE_WORLD_COORDS=True, USE_WORLD_COORDS=False,
# ITERATOR_OUTPUT=ifcopenshell.ifcopenshell_wrapper.NATIVE, # ITERATOR_OUTPUT=ifcopenshell.ifcopenshell_wrapper.NATIVE,
ITERATOR_OUTPUT=ifcopenshell.ifcopenshell_wrapper.TRIANGULATED, ITERATOR_OUTPUT=ifcopenshell.ifcopenshell_wrapper.TRIANGULATED,
DISABLE_OPENING_SUBTRACTIONS=True, DISABLE_OPENING_SUBTRACTIONS=True,
) )
it = ifcopenshell.geom.iterator(s, self.f, geometry_library="cgal", **kwargs)
its = [] its = []
fffs = []
data = model_geometry() data = model_geometry()
for f in self.fs:
if kwargs.keys() == {'include'}:
kwargs2 = {'include': [e for e in kwargs['include'] if e.wrapped_data.file == f]}
else:
kwargs2 = kwargs
it = ifcopenshell.geom.iterator(s, f, geometry_library="cgal", **kwargs2)
if not it.initialize(): if not it.initialize():
# print(ifcopenshell.get_log()) # print(ifcopenshell.get_log())
# exit(1) # exit(1)
return data continue
# convex decomposition is expensive, geometries can be shared, apply product-level transformations after CD and cache results pre-transform
cd_cache = {}
while True: while True:
elem = it.get() elem = it.get()
if self.f[int(elem.geometry.id.split("-")[0])].RepresentationIdentifier != "Box": elem_g_id = elem.geometry.id
print(f"[{utils.get_mem()} MB]", "reading", self.f[elem.id])
if f[int(elem_g_id.split("-")[0])].RepresentationIdentifier != "Box":
print(f"[{utils.get_mem()} MB]", "reading", f[elem.id])
elem = it.get_native() elem = it.get_native()
elem2 = None
for i in range(elem.geometry.size()): for i in range(elem.geometry.size()):
elem_i = elem.geometry.item(i) elem_i = elem.geometry.item(i)
repitem = f[elem.geometry.item_id(i)]
if elem_i.num_vertices() < 6: if elem_i.num_vertices() < 6:
# try and detect single faces used sometime for glass panes which can't # try and detect single faces used sometime for glass panes which can't
# be represented as halfspace intersection and need to be 'solidified' # be represented as halfspace intersection and need to be 'solidified'
fs = elem_i.facets() fs = elem_i.facets()
axes = list(utils.to_tuple(f.axis()) for f in fs) axes_ = [f.axis() for f in fs]
axes = list(map(utils.to_tuple, axes_))
if all(ax == axes[0] for ax in axes): if all(ax == axes[0] for ax in axes):
ff = ifcopenshell.file(schema=self.f.schema) ff = ifcopenshell.file(schema=f.schema)
ff.add(*self.f.by_type("IfcProject")) ff.add(*f.by_type("IfcProject"))
nelem = ff.add(self.f[elem.id]) nelem = ff.add(f[elem.id])
body = [rep for rep in nelem.Representation.Representations if rep.RepresentationIdentifier == "Body"][0] body = [rep for rep in nelem.Representation.Representations if rep.RepresentationIdentifier == "Body"][0]
while body.Items[0].is_a("IfcMappedItem"): while body.Items[0].is_a("IfcMappedItem"):
body = body.Items[0].MappingSource.MappedRepresentation body = body.Items[0].MappingSource.MappedRepresentation
body.Items = [body.Items[i]] body.Items = [body.Items[i]]
self.substitute_detailed_elements(file=ff, force=True)
ff.write("temp.ifc") ff.write("temp.ifc")
fff = ifcopenshell.open("temp.ifc") fff = ifcopenshell.open("temp.ifc")
fffs.append(fff)
its.append(ifcopenshell.geom.iterator(s, fff, geometry_library="cgal")) its.append(ifcopenshell.geom.iterator(s, fff, geometry_library="cgal"))
assert its[-1].initialize() assert its[-1].initialize()
elem2 = its[-1].get_native() elem2 = its[-1].get_native()
elem_i = elem2.geometry.item(0) elem_i = elem2.geometry.item(0)
repitem = body.Items[0]
assert not its[-1].next()
if ALIGN_INNER: if ALIGN_INNER:
ke = elem_g_id, elem.geometry.item_id(i)
parts = cd_cache.get(ke)
if parts is None:
# @todo reuse decomp on shape instances
if self.definition_is_convex(repitem):
# convex decomposition is expensive, figure out the
# convexity from a 2d extrusion basis where possible
parts = [elem_i]
parts[0].convex_tag(True)
else:
try: try:
parts = elem_i.convex_decomposition() parts = elem_i.convex_decomposition()
except: except:
# @todo likely due to self-intersections # @todo likely due to self-intersections
parts = [] parts = []
cd_cache[ke] = parts
else: else:
parts = [elem_i] parts = [elem_i]
parts = [p.moved((elem2 if elem2 else elem).transformation.matrix) for p in parts]
for poly in parts: for poly in parts:
if ALIGN_INNER:
cd = [poly]
else:
cd = poly.convex_decomposition() cd = poly.convex_decomposition()
for p in cd: for p in cd:
# print('part volume', p.volume().to_double()) # print('part volume', p.volume().to_double())
# print('part area ', p.area().to_double()) # print('part area ', p.area().to_double())
pass pass
assert len(cd) == 1
fs = poly.facets() fs = poly.facets()
phfs = poly.halfspaces().facets() phfs = poly.halfspaces().facets()
@@ -415,6 +569,7 @@ class context:
nsd /= numpy.linalg.norm(nsd, axis=1).reshape((-1, 1)) nsd /= numpy.linalg.norm(nsd, axis=1).reshape((-1, 1))
data.float_facet_normals.append(nsd) data.float_facet_normals.append(nsd)
data.float_facet_centroids.append(numpy.array(list(map(utils.to_double, ps)))) data.float_facet_centroids.append(numpy.array(list(map(utils.to_double, ps))))
data.epeck_equation_idxs.append([])
last_hs_tups = tuple( last_hs_tups = tuple(
map( map(
@@ -423,7 +578,7 @@ class context:
) )
) )
data.convex_halfspace_trees.append((self.f[elem.id], tuple(p.halfspaces() for p in cd))) data.convex_halfspace_trees.append((f[elem.id], tuple(p.halfspaces() for p in cd)))
# correlate halfspace planes back to polyhedral facets # correlate halfspace planes back to polyhedral facets
for d, n1, n2 in zip(ds, ns, nsd.tolist()): for d, n1, n2 in zip(ds, ns, nsd.tolist()):
@@ -456,7 +611,7 @@ class context:
breakpoint() breakpoint()
j = min(enumerate(last_hs_tups), key=enumerated_plane_eq_diff)[0] j = min(enumerate(last_hs_tups), key=enumerated_plane_eq_diff)[0]
data.epeck_equation_idxs.append((len(data.non_convex_halfspace_facets_equations) - 1, j)) data.epeck_equation_idxs[-1].append(j)
if not it.next(): if not it.next():
break break
@@ -467,17 +622,23 @@ class context:
def remove_narrow(self, data): def remove_narrow(self, data):
negate = lambda x: utils.to_opaque(utils.negate(-1)(x)) negate = lambda x: utils.to_opaque(utils.negate(-1)(x))
trees = [(k, i, v) for i, (k, v) in enumerate(data.convex_halfspace_trees)] astuple_nocopy = lambda dc: list(map(functools.partial(getattr, dc), map(operator.attrgetter('name'), fields(dc))))
trees = [(k, list(vs)) for k, vs in itertools.groupby(sorted(trees, key=operator.itemgetter(0)), key=operator.itemgetter(0))] datas = [model_geometry(*map(lambda x: [x], xs)) for xs in zip(*astuple_nocopy(data))]
by_elem_id = lambda i_d: i_d[1].convex_halfspace_trees[0][0].id()
datas2 = [(k, list(vs)) for k, vs in itertools.groupby(sorted(enumerate(datas), key=by_elem_id), key=by_elem_id)]
internal_mapping = []
to_remove = [] to_remove = []
for elem, decomps in trees: for i, rest in datas2:
decomps = list(map(lambda d_i: d_i[1].convex_halfspace_trees[0][1], rest))
orig_ids = list(map(lambda d_i: d_i[0], rest))
# only tested on align inner # only tested on align inner
assert all(len(parts) == 1 for _, __, parts in decomps) assert all(len(parts) == 1 for parts in decomps)
for _, original_index, parts in decomps: internal_mapping = []
for j, parts in zip(orig_ids, decomps):
hs = parts[0] hs = parts[0]
epecks = [h.plane_equation() for h in hs.facets()] epecks = [h.plane_equation() for h in hs.facets()]
@@ -498,24 +659,25 @@ class context:
internal_mapping.append((eq, negate(epecks[abcd_idx]))) internal_mapping.append((eq, negate(epecks[abcd_idx])))
internal_mapping.append((negate(eq), epecks[abcd_idx])) internal_mapping.append((negate(eq), epecks[abcd_idx]))
to_remove.append(j)
to_remove.append(original_index)
# print('removing', original_index) # print('removing', original_index)
break break
new_decomps = [] for j, parts in zip(orig_ids, decomps):
for elem, decomps in trees:
for _, original_index, parts in decomps: if j in to_remove:
if original_index in to_remove:
continue continue
hs = parts[0] hs = parts[0]
for ab in internal_mapping: for ab in internal_mapping:
hs.map(*ab) hs.map(*ab)
new_decomps.append((elem, [hs])) datas_filtered = [d for i, d in enumerate(datas) if i not in to_remove]
if not datas_filtered:
data.convex_halfspace_trees = new_decomps return model_geometry()
else:
return reduce(operator.add, datas_filtered)
@utils.trace @utils.trace
def create_mapping(self, data): def create_mapping(self, data):
@@ -523,6 +685,16 @@ class context:
angular and linear deviation, computes the average and construct a angular and linear deviation, computes the average and construct a
mapping from original to cluster average. mapping from original to cluster average.
""" """
if self.settings.verbose and self.settings.debug:
for ii, eqs in enumerate(data.non_convex_halfspace_facets_equations):
print('ELEMENT', ii)
for i, eq in enumerate(eqs):
print(i, *to_str(eq))
epeck_equation_list_idx = numpy.cumsum([0] + list(map(len, data.epeck_equation_idxs)))
# epeck_equation_idxs_flat = list(itertools.chain.from_iterable(data.epeck_equation_idxs))
mapping = [] mapping = []
# First use a kd-tree to find planes with similar normals (the first three) components # First use a kd-tree to find planes with similar normals (the first three) components
@@ -531,6 +703,7 @@ class context:
# A single float64 vector might be associated to multiple distinct epeck equations. # A single float64 vector might be associated to multiple distinct epeck equations.
# in our kd-tree we store unique float64 coordinates and maintain a mapping back to # in our kd-tree we store unique float64 coordinates and maintain a mapping back to
# indices into the original epeck equations. # indices into the original epeck equations.
vecs = numpy.concatenate(data.float_facet_normals) vecs = numpy.concatenate(data.float_facet_normals)
vecs_unique, vecs_inverse = numpy.unique(vecs, return_inverse=True, axis=0) vecs_unique, vecs_inverse = numpy.unique(vecs, return_inverse=True, axis=0)
vecs_dict = utils.make_default(sorted((j, i) for i, j in enumerate(vecs_inverse))) vecs_dict = utils.make_default(sorted((j, i) for i, j in enumerate(vecs_inverse)))
@@ -543,10 +716,10 @@ class context:
if has_igraph: if has_igraph:
# @todo write a proper adaptor. igraph only supports integer vertex ids, so we # @todo write a proper adaptor. igraph only supports integer vertex ids, so we
# need a separate mapping # need a separate mapping
vertices = [(+1, i) for i in range(len(vecs_unique))] + [(-1, i) for i in range(len(vecs_unique))] # vertices = [(+1, i) for i in range(len(vecs_unique))] + [(-1, i) for i in range(len(vecs_unique))]
G.add_vertices(len(vertices)) G.add_vertices(len(vecs_unique))
vidx = lambda x: x[1] if x[0] == +1 else x[1] + len(vecs_unique) vidx = lambda x: x
getv = lambda x: vertices[x] getv = lambda x: x
add_edges = lambda g, es: g.add_edges(es) add_edges = lambda g, es: g.add_edges(es)
components = lambda g: list(g.connected_components()) components = lambda g: list(g.connected_components())
else: else:
@@ -559,7 +732,9 @@ class context:
for i, p in enumerate(vecs_unique): for i, p in enumerate(vecs_unique):
# @todo if i in G.nodes: continue? # @todo if i in G.nodes: continue?
for sign in (+1, -1): for sign in (+1, -1):
yield from ((vidx((+1, i)), vidx((sign, j))) for j in kdtree.query_ball_point(p * sign, r=0.01)) yield from ((i,j) for j in kdtree.query_ball_point(p * sign, r=0.2))
# for i in range(len(vecs_unique)):
# yield (vidx((+1, i)), vidx((-1, i)))
add_edges(G, yield_edges()) add_edges(G, yield_edges())
@@ -573,28 +748,29 @@ class context:
# product with the polyhedral facet centroid # product with the polyhedral facet centroid
# @todo should be weighted based on vecs_count? # @todo should be weighted based on vecs_count?
idx_pos = sorted(i for s, i in comp if s == +1) # idx_pos = sorted(i for s, i in comp if s == +1)
idx_neg = sorted(i for s, i in comp if s == -1) # idx_neg = sorted(i for s, i in comp if s == -1)
avgv = numpy.average(numpy.concatenate((vecs_unique[idx_pos], -vecs_unique[idx_neg])), axis=0)
signs = numpy.sign(vecs_unique[comp] @ vecs_unique[comp][0]).reshape((-1,1))
avgv = numpy.average(vecs_unique[comp] * signs, axis=0)
avgv /= numpy.linalg.norm(avgv) avgv /= numpy.linalg.norm(avgv)
def augment(c): def augment(c):
for s, i in c: for i in c:
for j in vecs_dict[i]: for j in vecs_dict[i]:
yield s, j yield j
comp = list(augment(comp)) comp = list(augment(comp))
idx_both = [i for s, i in comp]
# the original facet centroids # the original facet centroids
pts = points[idx_both] pts = points[comp]
ds = pts @ avgv ds = pts @ avgv
shuff = numpy.argsort(pts @ avgv) shuff = numpy.argsort(pts @ avgv)
srted = ds[shuff] srted = ds[shuff]
diff = numpy.diff(srted) diff = numpy.diff(srted)
# cluster based on jumps in sorted array # cluster based on jumps in sorted array
chunks = numpy.split(shuff, numpy.where(diff > 0.002)[0] + 1) chunks = numpy.split(shuff, numpy.where(diff > 2 * self.settings.resolution)[0] + 1)
for chunk in chunks: for chunk in chunks:
comp_subset = [comp[c] for c in chunk] comp_subset = [comp[c] for c in chunk]
@@ -611,25 +787,28 @@ class context:
def _(): def _():
# Project facet centroid onto plane both sides and compare # Project facet centroid onto plane both sides and compare
for (sa, a), (sb, b) in itertools.combinations(comp_subset, 2): for a, b in itertools.combinations(comp_subset, 2):
d = abs((points[b] - points[a]) @ vecs[a]) + abs((points[a] - points[b]) @ vecs[b]) d = abs((points[b] - points[a]) @ vecs[a]) + abs((points[a] - points[b]) @ vecs[b])
if d < 0.001: if d < self.settings.resolution:
yield Gcomp_vidx((sa, a)), Gcomp_vidx((sb, b)) yield Gcomp_vidx(a), Gcomp_vidx(b)
# This becomes the final connected component of plane equations to be averaged # This becomes the final connected component of plane equations to be averaged
add_edges(Gcomp, _()) add_edges(Gcomp, _())
for comp2 in components(Gcomp): for comp2 in components(Gcomp):
comp2 = list(map(Gcomp_getv, comp2)) comp2 = list(map(Gcomp_getv, comp2))
signs = list(map(int, numpy.sign(vecs[comp2] @ vecs[comp2][0])))
eqt = [] eqt = []
idxs = set() idxs = set()
for sign in (+1, -1):
# eqids = sum((double_to_orig[V] for V in set(map(tuple, vecs[[c for s, c in comp2 if s == sign]].tolist()))), []) listidxs = [numpy.searchsorted(epeck_equation_list_idx, c, side='right')-1 for c in comp2]
eqids = [data.epeck_equation_idxs[c] for s, c in comp2 if s == sign] modelo = [(j - epeck_equation_list_idx[i]) for i, j in zip(listidxs, comp2)]
eqs = [data.non_convex_halfspace_facets_equations[a][b] for a, b in eqids] eqs = [data.non_convex_halfspace_facets_equations[a][data.epeck_equation_idxs[a][b]] for a, b in zip(listidxs, modelo)]
idxs.update(a for a, b in eqids) idxs.update(listidxs)
# tuples # tuples
for a in map(utils.negate(sign), map(utils.to_tuple, eqs)): for a in map(lambda sign, tup: utils.negate(sign)(tup), signs, map(utils.to_tuple, eqs)):
if a not in eqt: if a not in eqt:
eqt.append(a) eqt.append(a)
@@ -637,16 +816,15 @@ class context:
# transpose # transpose
eqtt = list(zip(*eqt)) eqtt = list(zip(*eqt))
# sum and divide components # sum and divide components
avg = utils.to_opaque( avg = tuple(
list(
map( map(
functools.partial(utils.reserialize, to_double=False), functools.partial(utils.reserialize, to_double=False),
[reduce(operator.add, comps) / N for comps in eqtt], [reduce(operator.add, comps) / N for comps in eqtt],
) )
) )
) avgs = tuple(map(lambda s: utils.to_opaque(utils.negate(s)(avg)), (+1, -1)))
for pl in map(utils.to_opaque, eqt): for sign, pl in zip(signs, eqs):
mapping.append((pl, avg, idxs)) mapping.append((pl, avgs[sign == -1], idxs))
return mapping return mapping
@utils.trace @utils.trace
@@ -663,7 +841,7 @@ class context:
by_id[idx][0].append(a) by_id[idx][0].append(a)
by_id[idx][1].append(b) by_id[idx][1].append(b)
""" if self.settings.store_mapping:
# can be used to store global mapping and apply to individually extracted elements # can be used to store global mapping and apply to individually extracted elements
mapping = defaultdict(list) mapping = defaultdict(list)
for k, vs in by_id.items(): for k, vs in by_id.items():
@@ -672,25 +850,40 @@ class context:
from_to = tuple(" ".join(map(lambda n: n.to_string(), utils.to_tuple(x))) for x in ab) from_to = tuple(" ".join(map(lambda n: n.to_string(), utils.to_tuple(x))) for x in ab)
mapping[guid].append(from_to) mapping[guid].append(from_to)
json.dump(mapping, open('epeck_mapping.json', 'w')) json.dump(mapping, open('epeck_mapping.json', 'w'))
"""
for i, (elem, ps) in enumerate(data.convex_halfspace_trees): for i, (elem, ps) in enumerate(data.convex_halfspace_trees):
if from_disk: if from_disk:
maps = by_id[elem.GlobalId] maps = by_id[elem.GlobalId]
else: else:
maps = by_id[i] maps = by_id[i]
for j, p in enumerate(ps): for j, p in enumerate(ps):
# pps = p.solid()
# old_area = pps.area().to_double() if self.settings.verbose:
# old_volume = pps.volume().to_double() pps = p.solid()
# open(f'{i}_{j}_before.obj', 'w').write(pps.serialize_obj()) old_area = pps.area().to_double()
old_volume = pps.volume().to_double()
open(f'{i}_{j}_before.obj', 'w').write(pps.serialize_obj())
p.map(*maps) p.map(*maps)
# pps = p.solid()
# new_area = pps.area().to_double() if self.settings.verbose:
# new_volume = pps.volume().to_double() for ab in zip(*maps):
# open(f'{i}_{j}_after.obj', 'w').write(pps.serialize_obj()) c, d = map(utils.to_double, ab)
# if new_area: print(*c, '->', *d)
# print(i, j, new_area / old_area, old_area, new_area, old_volume, new_volume) c, d = map(to_str, ab)
print(*c, '->', *d)
pps = p.solid()
new_area = pps.area().to_double()
new_volume = pps.volume().to_double()
open(f'{i}_{j}_after.obj', 'w').write(pps.serialize_obj())
if new_area:
print(i, j, new_area / old_area, old_area, new_area, old_volume, new_volume)
if new_area / old_area > 100:
breakpoint()
@staticmethod @staticmethod
def write_obj(ofn, *, elem=None, item=None): def write_obj(ofn, *, elem=None, item=None):
@@ -710,28 +903,47 @@ class context:
print('f', *f, file=obj) print('f', *f, file=obj)
@utils.trace @utils.trace
def evaluate(self, data): def evaluate_st(self, data):
def inner(): def inner():
for i, (elem, ps) in enumerate(data.convex_halfspace_trees): for i, (elem, ps) in enumerate(data.convex_halfspace_trees):
print("Evaluating", elem) print("Evaluating", elem)
solids = [p.solid() for p in ps] solids = [p.solid() for p in ps]
# @todo use union() # @todo use union()
v = solids[0]
for p in solids[1:]:
v = v.add(p)
if self.debug: if len(solids) == 0:
self.write_obj(f"adjusted_{elem.GlobalId}_{i}.obj", item=v) continue
elif len(solids) == 1:
v = solids[0]
else:
v = ifcopenshell.ifcopenshell_wrapper.nary_union(solids)
if self.settings.debug:
self.write_obj(f"{elem.GlobalId}_{i}.obj", item=v)
yield elem, v yield elem, v
return list(inner()) return list(inner())
def evaluate_mt(self, data):
def ev(i_elem_ps):
i, (elem, ps) = i_elem_ps
v = ps[0].solid_mt()
if self.settings.debug:
self.write_obj(f"{elem.GlobalId}_{i}.obj", item=v)
return (elem, v)
# yield from map(ev, data.convex_halfspace_trees)
# return
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
# futures = (executor.submit(ev, el) for el in data.convex_halfspace_trees)
# yield from map(lambda f: f.result(), concurrent.futures.as_completed(futures))
return executor.map(ev, enumerate(data.convex_halfspace_trees))
@utils.trace @utils.trace
def apply_openings(self, data, openings): def apply_openings(self, data, openings):
def inner(): def inner():
opgeom = utils.make_default(self.evaluate(openings)) opgeom = utils.make_default(self.evaluate_mt(openings))
for k, v in self.evaluate(data): for k, v in self.evaluate_mt(data):
for el in getattr(k, "HasOpenings", ()): for el in getattr(k, "HasOpenings", ()):
print("opening", k, el.RelatedOpeningElement) print("opening", k, el.RelatedOpeningElement)
for p in opgeom[el.RelatedOpeningElement]: for p in opgeom[el.RelatedOpeningElement]:
@@ -741,13 +953,48 @@ class context:
return list(inner()) return list(inner())
@staticmethod
@utils.trace
def union_mt(shapes):
shps = list(shapes)
n = int(math.ceil(len(shps) / 4))
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
# futures = (executor.submit(ev, el) for el in data.convex_halfspace_trees)
# yield from map(lambda f: f.result(), concurrent.futures.as_completed(futures))
return ifcopenshell.ifcopenshell_wrapper.nary_union(list(executor.map(ifcopenshell.ifcopenshell_wrapper.nary_union, (shps[i*n:i*n+n] for i in range(4)))))
@staticmethod @staticmethod
@utils.trace @utils.trace
def union(shapes): def union(shapes):
return ifcopenshell.ifcopenshell_wrapper.nary_union(list(shapes)) return ifcopenshell.ifcopenshell_wrapper.nary_union(list(shapes))
if __name__ == "__main__": if __name__ == "__main__":
fn = sys.argv[1] parser = argparse.ArgumentParser()
debug = "-d" in sys.argv parser.add_argument("files", type=str, nargs="+")
existing_mapping = "-m" in sys.argv
context(fn, debug=debug, existing_mapping=existing_mapping) for field in fields(settings):
if field.type == bool:
parser.add_argument("--" + field.name.replace("_", "-"), dest=field.name, action="store_true")
parser.add_argument("--no-" + field.name.replace("_", "-"), dest=field.name, action="store_false")
parser.set_defaults(**{field.name: field.default})
else:
if field.type is list:
parser.add_argument(
"--" + field.name.replace("_", "-"), dest=field.name, type=lambda s: s.split(','), default=field.default
)
else:
parser.add_argument(
"--" + field.name.replace("_", "-"), dest=field.name, type=field.type, default=field.default
)
args = vars(parser.parse_args(sys.argv))
files = args.pop("files")
if os.path.basename(__file__) == os.path.basename(files[0]):
files = files[1:]
output = files.pop()
assert files
settings = settings(**args)
context(files, output, settings)