mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-12 02:23:34 +00:00
1001 lines
41 KiB
Python
1001 lines
41 KiB
Python
import argparse
|
|
import json
|
|
import math
|
|
import os
|
|
import sys
|
|
import time
|
|
import operator
|
|
import itertools
|
|
import functools
|
|
import threading
|
|
|
|
import concurrent.futures
|
|
import multiprocessing
|
|
|
|
from collections import defaultdict
|
|
from functools import reduce
|
|
from dataclasses import dataclass, field, fields
|
|
|
|
try:
|
|
import igraph as graph
|
|
|
|
has_igraph = True
|
|
except:
|
|
import networkx as graph
|
|
print("Warning: networkx uses considerable amounts of memory consider install igraph")
|
|
|
|
has_igraph = False
|
|
|
|
import numpy
|
|
from scipy.spatial import KDTree
|
|
from scipy.spatial import ConvexHull
|
|
|
|
import voxec
|
|
import ifcopenshell
|
|
import ifcopenshell.geom
|
|
|
|
from ifcopenshell.util.unit import calculate_unit_scale
|
|
|
|
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
|
|
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.
|
|
"""
|
|
# list[list[int]]
|
|
# ~^ non_convex_halfspace_facets_equations[...]~
|
|
# ^ 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)
|
|
|
|
# list[pair[str, tuple[halfspacetree]]]
|
|
# used to apply mapping to
|
|
convex_halfspace_trees: list = field(default_factory=list)
|
|
|
|
# halfspaces > facets > plane_equation
|
|
# list[list[plane]]
|
|
non_convex_halfspace_facets_equations: list = field(default_factory=list)
|
|
# normalized list[ndarray[N, 3]]
|
|
float_facet_normals: list = field(default_factory=list)
|
|
# list[ndarray[N, 3]]
|
|
float_facet_centroids: list = field(default_factory=list)
|
|
|
|
def __add__(self, other):
|
|
"""Concatenate two model_geometry objects
|
|
|
|
Args:
|
|
other (model_geometry): Other set of interpreted geometries
|
|
|
|
Returns:
|
|
_type_: model_geometry
|
|
"""
|
|
return model_geometry(
|
|
self.epeck_equation_idxs + other.epeck_equation_idxs,
|
|
self.convex_halfspace_trees + other.convex_halfspace_trees,
|
|
self.non_convex_halfspace_facets_equations + other.non_convex_halfspace_facets_equations,
|
|
self.float_facet_normals + other.float_facet_normals,
|
|
self.float_facet_centroids + other.float_facet_centroids,
|
|
)
|
|
|
|
class context:
|
|
|
|
def __init__(self, fns : list, output : str, st : settings):
|
|
self.fns = fns
|
|
self.is_substituted = False
|
|
self.settings = st
|
|
self.fs = []
|
|
|
|
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):
|
|
self.is_substituted = True
|
|
self.fs.append(ifcopenshell.open(substituted_fn))
|
|
else:
|
|
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'))
|
|
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 and self.settings.detailed_element_substitution:
|
|
substituted_files = []
|
|
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))
|
|
openings = self.extract_geometry(include=self.opening_elems)
|
|
data = self.extract_geometry(include=self.elems)
|
|
|
|
openings = self.remove_narrow(openings)
|
|
data = self.remove_narrow(data)
|
|
|
|
all_geom = openings + data
|
|
|
|
if self.settings.existing_mapping:
|
|
my_mapping = json.load(open('epeck_mapping.json'))
|
|
def deser(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() if k in map(operator.attrgetter('GlobalId'), self.elems)}
|
|
else:
|
|
my_mapping = self.create_mapping(all_geom)
|
|
|
|
self.apply_mapping(all_geom, my_mapping, from_disk=self.settings.existing_mapping)
|
|
|
|
del my_mapping
|
|
|
|
new_data = utils.make_default(self.apply_openings(data, openings))
|
|
|
|
del data
|
|
del openings
|
|
|
|
result = self.union(itertools.chain.from_iterable(new_data.values()))
|
|
|
|
with open(output, "w") as ff:
|
|
ff.write(result.serialize_obj())
|
|
|
|
|
|
@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
|
|
|
|
Args:
|
|
file (ifcopenshell.file): file containing elem
|
|
elem (TriangulationElement): triangulated geometry
|
|
min_thickness (float, optional): minimal thickness of the oriented bounding box to create around elem
|
|
|
|
Returns:
|
|
tuple: <guid, <3x4 matrix, min, max>> with min and max being the local coords in the matrix
|
|
"""
|
|
vs = numpy.array(elem.geometry.verts).reshape((-1, 3))
|
|
fs = numpy.array(elem.geometry.faces).reshape((-1, 3))
|
|
|
|
def _():
|
|
for f in fs:
|
|
p, q, r = vs[f]
|
|
pq = q - p
|
|
pr = r - p
|
|
pq /= numpy.linalg.norm(pq)
|
|
pr /= numpy.linalg.norm(pr)
|
|
pqr = numpy.cross(pq, pr)
|
|
pqr /= numpy.linalg.norm(pqr)
|
|
yield pqr
|
|
|
|
tri_norms = numpy.array(list(_()))
|
|
|
|
def _():
|
|
for f in fs:
|
|
p, q, r = vs[f]
|
|
pq = q - p
|
|
pr = r - p
|
|
pqr = numpy.cross(pq, pr)
|
|
yield numpy.linalg.norm(pqr) / 2.
|
|
|
|
tri_areas = numpy.array(list(_()))
|
|
|
|
_, inv, cnts = numpy.unique(numpy.int_(tri_norms * 1000), return_counts=True, return_inverse=True, axis=0)
|
|
di = utils.make_default(sorted((j, i) for i, j in enumerate(inv)))
|
|
summed_area = [v[1] for v in sorted((k, sum(tri_areas[v])) for k, v in di.items())]
|
|
sorted_summed_areas = numpy.argsort(summed_area)
|
|
V = numpy.average(tri_norms[di[sorted_summed_areas[-1]]], axis=0)
|
|
candidates = []
|
|
for i in range(1, min(10, len(cnts))):
|
|
ref = numpy.average(tri_norms[di[sorted_summed_areas[-i]]], axis=0)
|
|
candidates.append((abs(ref @ V), ref))
|
|
if not candidates:
|
|
refs = [(0, 0, 1), (1, 0, 0)]
|
|
for ref in refs:
|
|
candidates.append((abs(ref @ V), ref))
|
|
ref = min(candidates, key=operator.itemgetter(0))[1]
|
|
Y = numpy.cross(V, ref)
|
|
X = numpy.cross(V, Y)
|
|
M = numpy.array((X, -Y, V))
|
|
|
|
Mi = numpy.linalg.inv(M)
|
|
vsi = numpy.array([v @ Mi for v in vs])
|
|
|
|
vsimi = vsi.min(axis=0)
|
|
vsima = vsi.max(axis=0)
|
|
|
|
for i in range(3):
|
|
d = vsima[i] - vsimi[i]
|
|
if d < min_thickness:
|
|
dd = (min_thickness - d) / 2.0
|
|
vsima[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)
|
|
vsima = vsima / calculate_unit_scale(file)
|
|
|
|
return (elem.id,) + tuple(x.tolist() for x in (M, vsimi, vsima))
|
|
|
|
@utils.trace
|
|
def prefilter_elements_using_voxelization(self, **kwargs):
|
|
"""Uses a course voxelization (5cm) to quickly detect the likely subset
|
|
of elements participating in the building exterior. In case of small cavities
|
|
protruding into the building, bounding elements may be omitted from the
|
|
return list of elements.
|
|
|
|
Returns:
|
|
list[ifcopenshell.entity_instance]
|
|
"""
|
|
if all(os.path.exists(bfn + ".elements.json") for bfn in map(os.path.basename, self.fns)):
|
|
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))), [])
|
|
|
|
results = []
|
|
|
|
s = ifcopenshell.geom.settings(
|
|
USE_WORLD_COORDS=True,
|
|
WELD_VERTICES=False,
|
|
DISABLE_OPENING_SUBTRACTIONS=True,
|
|
ITERATOR_OUTPUT=ifcopenshell.ifcopenshell_wrapper.SERIALIZED,
|
|
)
|
|
|
|
building_elements_union = None
|
|
building_elements = []
|
|
|
|
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():
|
|
# print(ifcopenshell.get_log())
|
|
# exit(1)
|
|
return result
|
|
|
|
while True:
|
|
elem = it.get()
|
|
geom = elem.geometry.brep_data
|
|
if f[int(elem.geometry.id.split("-")[0])].RepresentationIdentifier != "Box":
|
|
# breakpoint()
|
|
vox = voxec.run("voxelize", geom, method="volume")
|
|
building_elements.append((f[elem.id], vox))
|
|
if building_elements_union is None:
|
|
building_elements_union = vox
|
|
else:
|
|
building_elements_union = building_elements_union.boolean_union(vox)
|
|
if not it.next():
|
|
break
|
|
|
|
exterior = voxec.run("exterior", building_elements_union)
|
|
exterior_shell = [voxec.run("offset", exterior)]
|
|
for i in range(1):
|
|
exterior_shell.append(voxec.run("offset", exterior_shell[-1]))
|
|
exterior_shell_thick = reduce(lambda a, b: a.boolean_union(b), exterior_shell)
|
|
|
|
for elem, vox in building_elements:
|
|
if exterior_shell_thick.boolean_intersection(vox).count():
|
|
result.append(elem)
|
|
|
|
json.dump([i.id() for i in result], open(bfn + ".elements.json", "w"))
|
|
results.extend(result)
|
|
return results
|
|
|
|
def substitute_detailed_elements(self, file=None, force=False, **kwargs):
|
|
"""Substitute elements with a high vertex count with an
|
|
oriented bounding box.
|
|
|
|
Args:
|
|
force (bool, optional): Substitute regardless of vertex count. Defaults to False.
|
|
|
|
Returns:
|
|
ifcopenshell.file: file with substitutions made to the representation items
|
|
"""
|
|
s = ifcopenshell.geom.settings(
|
|
USE_WORLD_COORDS=True,
|
|
# ITERATOR_OUTPUT=ifcopenshell.ifcopenshell_wrapper.NATIVE,
|
|
ITERATOR_OUTPUT=ifcopenshell.ifcopenshell_wrapper.TRIANGULATED,
|
|
DISABLE_OPENING_SUBTRACTIONS=True,
|
|
)
|
|
it = ifcopenshell.geom.iterator(s, file, geometry_library="cgal", **kwargs)
|
|
if not it.initialize():
|
|
return
|
|
|
|
substitutions = []
|
|
while True:
|
|
nat = it.get_native()
|
|
elem = it.get()
|
|
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()))
|
|
if force or num_verts > 128 or ((num_verts / volume) > 2000 and num_faces > 12):
|
|
subs_result = self.substitute_with_box(f, elem, force=force)
|
|
if subs_result:
|
|
substitutions.append(subs_result)
|
|
if not it.next():
|
|
break
|
|
|
|
f = file
|
|
for elid, m3, mi, ma in substitutions:
|
|
elem = f[elid]
|
|
elem.ObjectPlacement = f.createIfcLocalPlacement(
|
|
RelativePlacement=f.createIfcAxis2Placement3D(
|
|
f.createIfcCartesianPoint((0.0, 0.0, 0.0)),
|
|
f.createIfcDirection(m3[2]),
|
|
f.createIfcDirection(m3[0]),
|
|
)
|
|
)
|
|
|
|
rep = [rep for rep in elem.Representation.Representations if rep.RepresentationIdentifier == "Body"][0]
|
|
elem.Representation = f.createIfcProductDefinitionShape(
|
|
None,
|
|
None,
|
|
[
|
|
f.createIfcShapeRepresentation(
|
|
rep[0],
|
|
rep[1],
|
|
"SweptSolid",
|
|
Items=[
|
|
f.createIfcExtrudedAreaSolid(
|
|
f.createIfcRectangleProfileDef(
|
|
"AREA",
|
|
None,
|
|
f.createIfcAxis2Placement2D(f.createIfcCartesianPoint(((ma[0] - mi[0]) / 2.0, (ma[1] - mi[1]) / 2.0))),
|
|
ma[0] - mi[0],
|
|
ma[1] - mi[1],
|
|
),
|
|
f.createIfcAxis2Placement3D(f.createIfcCartesianPoint(mi)),
|
|
f.createIfcDirection((0.0, 0.0, 1.0)),
|
|
ma[2] - mi[2],
|
|
)
|
|
],
|
|
)
|
|
],
|
|
)
|
|
return f
|
|
|
|
@utils.trace
|
|
# @profile
|
|
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
|
|
ALIGN_INNER = True
|
|
|
|
s = ifcopenshell.geom.settings(
|
|
USE_WORLD_COORDS=False,
|
|
# ITERATOR_OUTPUT=ifcopenshell.ifcopenshell_wrapper.NATIVE,
|
|
ITERATOR_OUTPUT=ifcopenshell.ifcopenshell_wrapper.TRIANGULATED,
|
|
DISABLE_OPENING_SUBTRACTIONS=True,
|
|
)
|
|
|
|
|
|
its = []
|
|
fffs = []
|
|
|
|
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():
|
|
# print(ifcopenshell.get_log())
|
|
# exit(1)
|
|
continue
|
|
|
|
# convex decomposition is expensive, geometries can be shared, apply product-level transformations after CD and cache results pre-transform
|
|
cd_cache = {}
|
|
|
|
while True:
|
|
elem = it.get()
|
|
elem_g_id = elem.geometry.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()
|
|
elem2 = None
|
|
for i in range(elem.geometry.size()):
|
|
elem_i = elem.geometry.item(i)
|
|
repitem = f[elem.geometry.item_id(i)]
|
|
|
|
if elem_i.num_vertices() < 6:
|
|
# try and detect single faces used sometime for glass panes which can't
|
|
# be represented as halfspace intersection and need to be 'solidified'
|
|
fs = elem_i.facets()
|
|
axes_ = [f.axis() for f in fs]
|
|
axes = list(map(utils.to_tuple, axes_))
|
|
if all(ax == axes[0] for ax in axes):
|
|
ff = ifcopenshell.file(schema=f.schema)
|
|
ff.add(*f.by_type("IfcProject"))
|
|
nelem = ff.add(f[elem.id])
|
|
body = [rep for rep in nelem.Representation.Representations if rep.RepresentationIdentifier == "Body"][0]
|
|
while body.Items[0].is_a("IfcMappedItem"):
|
|
body = body.Items[0].MappingSource.MappedRepresentation
|
|
body.Items = [body.Items[i]]
|
|
ff.write("temp.ifc")
|
|
fff = ifcopenshell.open("temp.ifc")
|
|
fffs.append(fff)
|
|
its.append(ifcopenshell.geom.iterator(s, fff, geometry_library="cgal"))
|
|
assert its[-1].initialize()
|
|
elem2 = its[-1].get_native()
|
|
elem_i = elem2.geometry.item(0)
|
|
repitem = body.Items[0]
|
|
assert not its[-1].next()
|
|
|
|
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:
|
|
parts = elem_i.convex_decomposition()
|
|
except:
|
|
# @todo likely due to self-intersections
|
|
parts = []
|
|
cd_cache[ke] = parts
|
|
else:
|
|
parts = [elem_i]
|
|
|
|
parts = [p.moved((elem2 if elem2 else elem).transformation.matrix) for p in parts]
|
|
|
|
for poly in parts:
|
|
if ALIGN_INNER:
|
|
cd = [poly]
|
|
else:
|
|
cd = poly.convex_decomposition()
|
|
|
|
for p in cd:
|
|
# print('part volume', p.volume().to_double())
|
|
# print('part area ', p.area().to_double())
|
|
pass
|
|
|
|
assert len(cd) == 1
|
|
|
|
fs = poly.facets()
|
|
|
|
phfs = poly.halfspaces().facets()
|
|
|
|
if len(phfs) == 0:
|
|
# @todo investigate why two cases of 0-length checks needed
|
|
continue
|
|
|
|
data.non_convex_halfspace_facets_equations.append(list(map(lambda f: f.plane_equation(), phfs)))
|
|
|
|
ns = [f.axis() for f in fs]
|
|
ps_ = [f.position() for f in fs]
|
|
# without this weird results on linux
|
|
ps = [tuple(ifcopenshell.ifcopenshell_wrapper.create_epeck(x.to_string()) for x in utils.to_tuple(t)) for t in ps_]
|
|
|
|
ds = list(map(utils.dot, ns, ps))
|
|
nsd = numpy.array(list(map(utils.to_double, ns)))
|
|
|
|
if nsd.size == 0:
|
|
continue
|
|
|
|
nsd /= numpy.linalg.norm(nsd, axis=1).reshape((-1, 1))
|
|
data.float_facet_normals.append(nsd)
|
|
data.float_facet_centroids.append(numpy.array(list(map(utils.to_double, ps))))
|
|
data.epeck_equation_idxs.append([])
|
|
|
|
last_hs_tups = tuple(
|
|
map(
|
|
lambda x: tuple(x.get(i) for i in range(4)),
|
|
data.non_convex_halfspace_facets_equations[-1],
|
|
)
|
|
)
|
|
|
|
data.convex_halfspace_trees.append((f[elem.id], tuple(p.halfspaces() for p in cd)))
|
|
|
|
# correlate halfspace planes back to polyhedral facets
|
|
for d, n1, n2 in zip(ds, ns, nsd.tolist()):
|
|
abcd = tuple(-n1.get(i) for i in range(3)) + (d,)
|
|
|
|
# @todo unable to find probably due to triangulation?
|
|
# ... yes it seems that triangulation has solved this (but only to a large extent)
|
|
# @todo should we divide by largest component?
|
|
|
|
try:
|
|
j = last_hs_tups.index(abcd)
|
|
except:
|
|
# breakpoint()
|
|
enumerated_plane_eq_diff = lambda t: reduce(
|
|
operator.add,
|
|
((abcd[i] - t[1][i]) * (abcd[i] - t[1][i]) for i in range(4)),
|
|
).to_double()
|
|
if (
|
|
min(
|
|
map(
|
|
enumerated_plane_eq_diff,
|
|
enumerate(last_hs_tups),
|
|
)
|
|
)
|
|
> 0.1
|
|
):
|
|
print(">", *(x.to_double() for x in abcd))
|
|
for h in last_hs_tups:
|
|
print(*(x.to_double() for x in h))
|
|
|
|
breakpoint()
|
|
j = min(enumerate(last_hs_tups), key=enumerated_plane_eq_diff)[0]
|
|
data.epeck_equation_idxs[-1].append(j)
|
|
|
|
if not it.next():
|
|
break
|
|
|
|
return data
|
|
|
|
@utils.trace
|
|
def remove_narrow(self, data):
|
|
negate = lambda x: utils.to_opaque(utils.negate(-1)(x))
|
|
|
|
astuple_nocopy = lambda dc: list(map(functools.partial(getattr, dc), map(operator.attrgetter('name'), fields(dc))))
|
|
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)]
|
|
|
|
to_remove = []
|
|
|
|
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
|
|
assert all(len(parts) == 1 for parts in decomps)
|
|
|
|
internal_mapping = []
|
|
|
|
for j, parts in zip(orig_ids, decomps):
|
|
|
|
hs = parts[0]
|
|
epecks = [h.plane_equation() for h in hs.facets()]
|
|
|
|
# print('I', original_index)
|
|
# for eq in epecks:
|
|
# print('eq', *(x.to_string() for x in utils.to_tuple(eq)))
|
|
|
|
rounded_negated = [tuple(-int(round(v * 10000)) for v in utils.to_double(eq)) for eq in epecks]
|
|
# for v in rounded_negated:
|
|
# print('ap', *v)
|
|
|
|
for eq in epecks:
|
|
try:
|
|
abcd_idx = rounded_negated.index(tuple(int(round(v * 10000)) for v in utils.to_double(eq)))
|
|
except ValueError as e:
|
|
continue
|
|
|
|
internal_mapping.append((eq, negate(epecks[abcd_idx])))
|
|
internal_mapping.append((negate(eq), epecks[abcd_idx]))
|
|
to_remove.append(j)
|
|
|
|
# print('removing', original_index)
|
|
break
|
|
|
|
for j, parts in zip(orig_ids, decomps):
|
|
|
|
if j in to_remove:
|
|
continue
|
|
|
|
hs = parts[0]
|
|
for ab in internal_mapping:
|
|
hs.map(*ab)
|
|
|
|
datas_filtered = [d for i, d in enumerate(datas) if i not in to_remove]
|
|
if not datas_filtered:
|
|
return model_geometry()
|
|
else:
|
|
return reduce(operator.add, datas_filtered)
|
|
|
|
@utils.trace
|
|
def create_mapping(self, data):
|
|
"""Finds groups of halfspace plane equations that are within a certain
|
|
angular and linear deviation, computes the average and construct a
|
|
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 = []
|
|
|
|
# First use a kd-tree to find planes with similar normals (the first three) components
|
|
# of the plane equations. Note that we search also for the opposite.
|
|
|
|
# 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
|
|
# indices into the original epeck equations.
|
|
|
|
vecs = numpy.concatenate(data.float_facet_normals)
|
|
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)))
|
|
|
|
points = numpy.concatenate(data.float_facet_centroids)
|
|
kdtree = KDTree(vecs_unique)
|
|
|
|
G = graph.Graph()
|
|
|
|
if has_igraph:
|
|
# @todo write a proper adaptor. igraph only supports integer vertex ids, so we
|
|
# need a separate mapping
|
|
# vertices = [(+1, i) for i in range(len(vecs_unique))] + [(-1, i) for i in range(len(vecs_unique))]
|
|
G.add_vertices(len(vecs_unique))
|
|
vidx = lambda x: x
|
|
getv = lambda x: x
|
|
add_edges = lambda g, es: g.add_edges(es)
|
|
components = lambda g: list(g.connected_components())
|
|
else:
|
|
vidx = lambda x: x
|
|
getv = lambda x: x
|
|
add_edges = lambda g, es: g.add_edges_from(es)
|
|
components = lambda g: list(graph.connected_components(g))
|
|
|
|
def yield_edges():
|
|
for i, p in enumerate(vecs_unique):
|
|
# @todo if i in G.nodes: continue?
|
|
for sign in (+1, -1):
|
|
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())
|
|
|
|
for comp in components(G):
|
|
print(f"[{utils.get_mem()} MB]", "component size", len(comp))
|
|
|
|
comp = list(map(getv, comp))
|
|
|
|
# construct the average plane normal (keeping in mind the sign)
|
|
# to within the component create a sorted sequence based on the dot
|
|
# product with the polyhedral facet centroid
|
|
|
|
# @todo should be weighted based on vecs_count?
|
|
# idx_pos = sorted(i for s, i in comp if s == +1)
|
|
# idx_neg = sorted(i for s, i in comp if s == -1)
|
|
|
|
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)
|
|
|
|
def augment(c):
|
|
for i in c:
|
|
for j in vecs_dict[i]:
|
|
yield j
|
|
|
|
comp = list(augment(comp))
|
|
|
|
# the original facet centroids
|
|
pts = points[comp]
|
|
ds = pts @ avgv
|
|
shuff = numpy.argsort(pts @ avgv)
|
|
srted = ds[shuff]
|
|
diff = numpy.diff(srted)
|
|
|
|
# cluster based on jumps in sorted array
|
|
chunks = numpy.split(shuff, numpy.where(diff > 2 * self.settings.resolution)[0] + 1)
|
|
|
|
for chunk in chunks:
|
|
comp_subset = [comp[c] for c in chunk]
|
|
|
|
Gcomp = graph.Graph()
|
|
if has_igraph:
|
|
Gcomp_vs = dict(map(reversed, enumerate(comp_subset)))
|
|
Gcomp.add_vertices(len(comp_subset))
|
|
Gcomp_vidx = lambda x: Gcomp_vs[x]
|
|
Gcomp_getv = lambda x: comp_subset[x]
|
|
else:
|
|
Gcomp_vidx = lambda x: x
|
|
Gcomp_getv = lambda x: x
|
|
|
|
def _():
|
|
# Project facet centroid onto plane both sides and compare
|
|
for a, b in itertools.combinations(comp_subset, 2):
|
|
d = abs((points[b] - points[a]) @ vecs[a]) + abs((points[a] - points[b]) @ vecs[b])
|
|
if d < self.settings.resolution:
|
|
yield Gcomp_vidx(a), Gcomp_vidx(b)
|
|
|
|
# This becomes the final connected component of plane equations to be averaged
|
|
add_edges(Gcomp, _())
|
|
|
|
for comp2 in components(Gcomp):
|
|
comp2 = list(map(Gcomp_getv, comp2))
|
|
|
|
signs = list(map(int, numpy.sign(vecs[comp2] @ vecs[comp2][0])))
|
|
|
|
eqt = []
|
|
idxs = set()
|
|
|
|
listidxs = [numpy.searchsorted(epeck_equation_list_idx, c, side='right')-1 for c in comp2]
|
|
modelo = [(j - epeck_equation_list_idx[i]) for i, j in zip(listidxs, comp2)]
|
|
eqs = [data.non_convex_halfspace_facets_equations[a][data.epeck_equation_idxs[a][b]] for a, b in zip(listidxs, modelo)]
|
|
idxs.update(listidxs)
|
|
# tuples
|
|
for a in map(lambda sign, tup: utils.negate(sign)(tup), signs, map(utils.to_tuple, eqs)):
|
|
if a not in eqt:
|
|
eqt.append(a)
|
|
|
|
N = ifcopenshell.ifcopenshell_wrapper.create_epeck(len(eqt))
|
|
# transpose
|
|
eqtt = list(zip(*eqt))
|
|
# sum and divide components
|
|
avg = tuple(
|
|
map(
|
|
functools.partial(utils.reserialize, to_double=False),
|
|
[reduce(operator.add, comps) / N for comps in eqtt],
|
|
)
|
|
)
|
|
avgs = tuple(map(lambda s: utils.to_opaque(utils.negate(s)(avg)), (+1, -1)))
|
|
for sign, pl in zip(signs, eqs):
|
|
mapping.append((pl, avgs[sign == -1], idxs))
|
|
return mapping
|
|
|
|
@utils.trace
|
|
def apply_mapping(self, data, mapping, from_disk=False):
|
|
if from_disk:
|
|
by_id = mapping
|
|
else:
|
|
by_id = defaultdict(lambda: (list(), list()))
|
|
for a, b, idxs in mapping:
|
|
fr, to = (" ".join(map(lambda n: n.to_string(), utils.to_tuple(x))) for x in (a,b))
|
|
if fr == to:
|
|
continue
|
|
for idx in idxs:
|
|
by_id[idx][0].append(a)
|
|
by_id[idx][1].append(b)
|
|
|
|
if self.settings.store_mapping:
|
|
# can be used to store global mapping and apply to individually extracted elements
|
|
mapping = defaultdict(list)
|
|
for k, vs in by_id.items():
|
|
guid = data.convex_halfspace_trees[k][0].GlobalId
|
|
for ab in zip(*vs):
|
|
from_to = tuple(" ".join(map(lambda n: n.to_string(), utils.to_tuple(x))) for x in ab)
|
|
mapping[guid].append(from_to)
|
|
json.dump(mapping, open('epeck_mapping.json', 'w'))
|
|
|
|
for i, (elem, ps) in enumerate(data.convex_halfspace_trees):
|
|
|
|
if from_disk:
|
|
maps = by_id[elem.GlobalId]
|
|
else:
|
|
maps = by_id[i]
|
|
|
|
for j, p in enumerate(ps):
|
|
|
|
if self.settings.verbose:
|
|
pps = p.solid()
|
|
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)
|
|
|
|
if self.settings.verbose:
|
|
for ab in zip(*maps):
|
|
c, d = map(utils.to_double, ab)
|
|
print(*c, '->', *d)
|
|
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
|
|
def write_obj(ofn, *, elem=None, item=None):
|
|
s = ifcopenshell.geom.settings(USE_WORLD_COORDS=True, WELD_VERTICES=False)
|
|
if item:
|
|
geom = item.Triangulate(s)
|
|
else:
|
|
geom = elem.geometry
|
|
|
|
vs_fs = geom.verts, geom.faces
|
|
vs, fs = map(lambda tup: numpy.array(tup).reshape((-1, 3)), vs_fs)
|
|
|
|
with open(ofn, "w") as obj:
|
|
for v in vs:
|
|
print('v', *v, file=obj)
|
|
for f in fs + 1:
|
|
print('f', *f, file=obj)
|
|
|
|
@utils.trace
|
|
def evaluate_st(self, data):
|
|
def inner():
|
|
for i, (elem, ps) in enumerate(data.convex_halfspace_trees):
|
|
print("Evaluating", elem)
|
|
solids = [p.solid() for p in ps]
|
|
# @todo use union()
|
|
|
|
if len(solids) == 0:
|
|
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
|
|
|
|
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
|
|
def apply_openings(self, data, openings):
|
|
def inner():
|
|
opgeom = utils.make_default(self.evaluate_mt(openings))
|
|
for k, v in self.evaluate_mt(data):
|
|
for el in getattr(k, "HasOpenings", ()):
|
|
print("opening", k, el.RelatedOpeningElement)
|
|
for p in opgeom[el.RelatedOpeningElement]:
|
|
v = v.subtract(p)
|
|
# print('v.volume', v.volume().to_double())
|
|
yield k, v
|
|
|
|
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
|
|
@utils.trace
|
|
def union(shapes):
|
|
return ifcopenshell.ifcopenshell_wrapper.nary_union(list(shapes))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("files", type=str, nargs="+")
|
|
|
|
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)
|
|
|