This commit is contained in:
Thomas Krijnen
2025-10-02 21:44:52 +02:00
parent 92f9d5f506
commit efa8382fc5
+223 -43
View File
@@ -18,14 +18,18 @@
"""2D drawing generation and serialisation""" """2D drawing generation and serialisation"""
from functools import reduce
import itertools
import math import math
import json import json
import functools import operator
import re
import warnings import warnings
import shapely
import ifcopenshell import ifcopenshell
import ifcopenshell.geom import ifcopenshell.geom
import ifcopenshell.util.element
import ifcopenshell.util.selector
from xml.dom.minidom import parseString from xml.dom.minidom import parseString
from dataclasses import dataclass, fields, field from dataclasses import dataclass, fields, field
@@ -53,8 +57,9 @@ class draw_settings:
door_arcs: bool = False door_arcs: bool = False
subtract_before_hlr: bool = False subtract_before_hlr: bool = False
cache: bool = False cache: bool = False
css: bool = True css: str = ""
storey_heights: str = "none" storey_heights: str = "none"
storey_filter: str = ""
include_entities: str = "" include_entities: str = ""
exclude_entities: str = "IfcOpeningElement" exclude_entities: str = "IfcOpeningElement"
drawing_guid: str = field( drawing_guid: str = field(
@@ -75,9 +80,24 @@ class draw_settings:
include_curves: bool = False include_curves: bool = False
unify_inputs: bool = True unify_inputs: bool = True
arrange_spaces: bool = False arrange_spaces: bool = False
arrange_zones: bool = False
zone_filter: str = ""
zone_label: str = ""
mirror_y: bool = False mirror_y: bool = False
class type_unsafe_value:
def __init__(self, value):
self.value = value
def __eq__(self, other):
try:
v = type(other)(self.value)
except:
return False
return v == other
def main( def main(
settings: draw_settings, settings: draw_settings,
files: list[ifcopenshell.file], files: list[ifcopenshell.file],
@@ -86,6 +106,13 @@ def main(
progress_function: Callable = DO_NOTHING, progress_function: Callable = DO_NOTHING,
): ):
def by_guid(g):
for f in files:
try:
return f.by_guid(g)
except:
pass
geom_settings = ifcopenshell.geom.settings( geom_settings = ifcopenshell.geom.settings(
# when not doing booleans, proper solids from shells isn't a requirement # when not doing booleans, proper solids from shells isn't a requirement
REORIENT_SHELLS=settings.subtract_before_hlr, REORIENT_SHELLS=settings.subtract_before_hlr,
@@ -106,11 +133,26 @@ def main(
elif settings.exclude_entities: elif settings.exclude_entities:
iterator_kwargs["exclude"] = settings.exclude_entities.split(",") iterator_kwargs["exclude"] = settings.exclude_entities.split(",")
def yield_parents(el):
yield el
if par := ifcopenshell.util.element.get_parent(el):
yield from yield_parents(par)
def has_selected_parent(el):
return any(settings.storey_filter in (x.Name or x.GlobalId) for x in yield_parents(el))
def create_iter(f):
if settings.storey_filter and iterator_kwargs.get("include"):
iterator_kwargs["include"] = list(
filter(has_selected_parent, sum((f.by_type(x) for x in iterator_kwargs["include"]), []))
)
return ifcopenshell.geom.iterator(geom_settings, f, **iterator_kwargs)
# We have to keep the iterator in memory because otherwise # We have to keep the iterator in memory because otherwise
# the styles are cleared up. # the styles are cleared up.
iterators = list( iterators = list(
map( map(
functools.partial(ifcopenshell.geom.iterator, geom_settings, **iterator_kwargs), create_iter,
files, files,
) )
) )
@@ -135,14 +177,7 @@ def main(
# it's up to user to keep them valid for the provided projects. # it's up to user to keep them valid for the provided projects.
if settings.drawing_guid or settings.drawing_object_type: if settings.drawing_guid or settings.drawing_object_type:
if settings.drawing_guid: if settings.drawing_guid:
found_guid = False if not by_guid(settings.drawing_guid):
for f in files:
try:
f.by_guid(settings.drawing_guid)
found_guid = True
except:
pass
if not found_guid:
raise ValueError(f"Unable to find guid {settings.drawing_guid!r}") raise ValueError(f"Unable to find guid {settings.drawing_guid!r}")
sr.setElevationRefGuid(settings.drawing_guid) sr.setElevationRefGuid(settings.drawing_guid)
elif settings.drawing_object_type: elif settings.drawing_object_type:
@@ -163,7 +198,7 @@ def main(
sr.setPrintSpaceNames(settings.space_names) sr.setPrintSpaceNames(settings.space_names)
sr.setPrintSpaceAreas(settings.space_areas) sr.setPrintSpaceAreas(settings.space_areas)
sr.setDrawDoorArcs(settings.door_arcs) sr.setDrawDoorArcs(settings.door_arcs)
sr.setNoCSS(not settings.css) sr.setNoCSS(not not settings.css)
if settings.subtract_before_hlr: if settings.subtract_before_hlr:
sr.setSubtractionSettings(W.ALWAYS) sr.setSubtractionSettings(W.ALWAYS)
@@ -421,26 +456,25 @@ def main(
# This generally shouldn't happen # This generally shouldn't happen
g1.appendChild(g2) g1.appendChild(g2)
if settings.arrange_spaces: if settings.arrange_spaces or settings.arrange_zones:
if settings.storey_filter:
# delete storey groups not selected by filter
# sometimes happens in case of elements protruding multiple stories
# are assigned wrongly in the decomposition tree
for rg in (g for g in yield_groups(svg1) if g.parentNode.tagName == "svg"):
storey_guid = ifcopenshell.guid.compress(rg.attributes["id"].value.split("-", 1)[1].replace("-", ""))
storey = by_guid(storey_guid)
if settings.storey_filter not in (storey.Name or storey.GlobalId):
rg.parentNode.removeChild(rg)
root_groups = [g for g in yield_groups(svg1) if g.parentNode.tagName == "svg"] root_groups = [g for g in yield_groups(svg1) if g.parentNode.tagName == "svg"]
ref_node = root_groups[-1].nextSibling
parent = root_groups[0].parentNode
zone_groups = [] zone_groups = []
for i in range(len(root_groups)): for grp in root_groups:
for j in range(len(root_groups)): path_objects = [p for p in yield_groups(grp, "path") if "IfcSpace" in p.parentNode.getAttribute("class")]
if i != j:
parent.removeChild(root_groups[j])
# wasteful and looses data for unknown reasons polies = [p.getAttribute("d") for p in path_objects]
# svg_contents = svg1.toxml()
# polies = W.svg_to_polygons(svg_contents, "IfcSpace")
polies = [
p.getAttribute("d")
for p in yield_groups(svg1, "path")
if "IfcSpace" in p.parentNode.getAttribute("class")
]
def break_at_second(char, s, offset=1): def break_at_second(char, s, offset=1):
i = s.find(char, offset) i = s.find(char, offset)
@@ -450,39 +484,185 @@ def main(
warnings.warn("Polygons with holes are not supported") warnings.warn("Polygons with holes are not supported")
return s[0:i] return s[0:i]
polies = [ def svg_to_coordlist(str):
[[*map(float, s[1:].split(","))] for s in break_at_second("M", d).split(" ")[:-1]] for d in polies return [[*map(float, s[1:].split(","))] for s in break_at_second("M", str).split(" ")[:-1]]
]
polies = [svg_to_coordlist(d) for d in polies]
def create_poly(b): def create_poly(b):
p = ifcopenshell.ifcopenshell_wrapper.polygon_2() p = ifcopenshell.ifcopenshell_wrapper.polygon_2()
p.boundary = b p.boundary = b
return p return p
polies = [create_poly(p) for p in polies] def min_bound_extent_p2(p):
def min_bound_extent(p):
arr = numpy.array(p.boundary) arr = numpy.array(p.boundary)
return (arr.max(axis=0) - arr.min(axis=0)).min() > 0.5 return (arr.max(axis=0) - arr.min(axis=0)).min() > 0.5
polies = type(polies)(filter(min_bound_extent, polies)) def min_bound_extent_li(p):
arr = numpy.array(p)
return (arr.max(axis=0) - arr.min(axis=0)).min() > 0.5
def polygon_to_svg_path(doc, polygon: shapely.Polygon) -> str:
def ring_to_path(coords):
coords = list(coords)[:-1]
return "M " + " L ".join(f"{x},{y}" for x, y in coords) + " Z"
rings = [polygon.exterior, *polygon.interiors]
d = " ".join(ring_to_path(r.coords) for r in rings)
path = doc.createElement("path")
path.setAttribute("d", d)
path.setAttribute("fill-rule", "evenodd")
return path
included = list(map(min_bound_extent_li, polies))
polies = [p for p, incl in zip(polies, included) if incl]
path_objects = [p for p, incl in zip(path_objects, included) if incl]
section_polies = list(map(shapely.Polygon, polies))
polies = [create_poly(p) for p in polies]
def has_relevant_zone(i):
inst = by_guid(path_objects[i].parentNode.attributes["ifc:guid"].value)
groups = [rel.RelatingGroup for rel in inst.HasAssignments if rel.is_a("IfcRelAssignsToGroup")]
if settings.zone_filter:
query, val = settings.zone_filter.rsplit("=", 1)
# cast to type encountered in pset prop
val = type_unsafe_value(val)
groups = [g for g in groups if ifcopenshell.util.selector.get_element_value(g, query) == val]
return len(groups) > 0
if settings.arrange_zones:
path_objects, section_polies, polies = zip(
*(tup for i, tup in enumerate(zip(path_objects, section_polies, polies)) if has_relevant_zone(i))
)
arranged = W.arrange_polygons(polies) arranged = W.arrange_polygons(polies)
svg_data_3 = W.polygons_to_svg(arranged, False) svg_data_3 = W.polygons_to_svg(arranged, False)
dom3 = parseString(svg_data_3) dom3 = parseString(svg_data_3)
svg3 = dom3.childNodes[0] svg3 = dom3.childNodes[0]
g3 = next(yield_groups(svg3)) g3 = next(yield_groups(svg3))
arranged_polies = []
for p in g3.getElementsByTagName("path"): for p in g3.getElementsByTagName("path"):
p.setAttribute("style", "fill: none; stroke: black; stroke-width: 0.2") # arranged path
zone_groups.append(g3) sp = shapely.Polygon(svg_to_coordlist(p.getAttribute("d")))
parent.removeChild(root_groups[i]) arranged_polies.append(sp)
for j in range(len(root_groups)):
parent.insertBefore(root_groups[j], ref_node) # Mapping of arrange_poly_idx (stored as list) -> instance
# Obtained by point containment of sectionpoly in arranged
mapping = [None] * len(arranged_polies)
for i, p in enumerate(section_polies):
g = by_guid(path_objects[i].parentNode.attributes["ifc:guid"].value)
contains = [ap.contains(p.representative_point()) for ap in arranged_polies]
# arrangement, so this can generally be assumed
if sum(contains) == 1:
idx = contains.index(True)
# mapping[idx].append(g)
if mapping[idx] is None:
mapping[idx] = g
else:
warnings.warn(f"Not applying {g}; overlapping with {mapping[idx]}")
else:
warnings.warn(f"Point {p.representative_point()} not covered by arrangement")
if settings.arrange_zones:
# @todo test whether output with only arrange_spaces is unaltered for bimlegal to function
zone_assignment = [None] * len(arranged_polies)
for i, m in enumerate(mapping):
if m is not None:
groups = [rel.RelatingGroup for rel in m.HasAssignments if rel.is_a("IfcRelAssignsToGroup")]
if settings.zone_filter:
query, val = settings.zone_filter.rsplit("=", 1)
# cast to type encountered in pset prop
val = type_unsafe_value(val)
groups = [
g for g in groups if ifcopenshell.util.selector.get_element_value(g, query) == val
]
zone_assignment[i] = frozenset(groups)
all_zones = list(set(itertools.chain.from_iterable(a or () for a in zone_assignment)))
all_zone_memberships = [
{i for i, zns in enumerate(zone_assignment) if zns is not None and zn in zns} for zn in all_zones
]
def union_find(sets):
merged = True
while merged:
merged = False
result = []
while sets:
first, *rest = sets
first = set(first)
# merge any overlapping sets into `first`
changed = True
while changed:
changed = False
new_rest = []
for r in rest:
if first & r: # overlap?
first |= r
changed = True
merged = True
else:
new_rest.append(r)
rest = new_rest
result.append(first)
sets = rest
sets[:] = result
return sets
to_merge = union_find(all_zone_memberships)
zone_groups.append(dom1.createElement("g"))
for idx_set in to_merge:
zone = next(iter(zone_assignment[max(idx_set)]))
poly = shapely.unary_union([arranged_polies[i] for i in idx_set])
poly = poly.geoms if poly.geom_type == "MultiPolygon" else [poly]
g = dom1.createElement("g")
def classes():
yield zone.is_a()
for propname, propval in reduce(
operator.or_, ifcopenshell.util.element.get_psets(zone).values()
).items():
if propval is True:
yield propname
g.setAttribute("class", " ".join(classes()))
for p in poly:
g.appendChild(polygon_to_svg_path(dom1, p))
if (lbl := settings.zone_label) and (
val := ifcopenshell.util.selector.get_element_value(zone, lbl)
):
pt = p.representative_point()
text_el = dom1.createElement("text")
text_el.setAttribute("x", str(pt.x))
text_el.setAttribute("y", str(pt.y))
text_el.appendChild(dom1.createTextNode(str(val)))
g.appendChild(text_el)
zone_groups[-1].appendChild(g)
else:
zone_groups.append(g3)
for rg, zg in zip(root_groups, zone_groups): for rg, zg in zip(root_groups, zone_groups):
for p in rg.getElementsByTagName("path"): if not settings.arrange_zones:
p.setAttribute("style", "fill: none; stroke: black; stroke-width: 0.05") for p in rg.getElementsByTagName("path"):
p.setAttribute("style", "fill: none; stroke: black; stroke-width: 0.05")
rg.appendChild(zg) rg.appendChild(zg)
if settings.css:
svg = dom1.documentElement
style = dom1.createElement("style")
style.setAttribute("type", "text/css")
style.appendChild(
dom1.createTextNode(open(settings.css[1:]).read() if settings.css.startswith("@") else settings.css)
)
svg.insertBefore(style, svg.firstChild)
data = dom1.toprettyxml() data = dom1.toprettyxml()
data = data.encode("ascii", "xmlcharrefreplace") data = data.encode("ascii", "xmlcharrefreplace")