From 9d6230eb16fce4c10a4e8c296169ecad368314ba Mon Sep 17 00:00:00 2001 From: Thomas Krijnen Date: Tue, 17 Aug 2021 14:55:32 +0200 Subject: [PATCH] Add ifcopenshell.draw module --- src/ifcopenshell-python/ifcopenshell/draw.py | 294 +++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 src/ifcopenshell-python/ifcopenshell/draw.py diff --git a/src/ifcopenshell-python/ifcopenshell/draw.py b/src/ifcopenshell-python/ifcopenshell/draw.py new file mode 100644 index 0000000000..03ca90066b --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/draw.py @@ -0,0 +1,294 @@ +############################################################################### +# # +# This file is part of IfcOpenShell. # +# # +# IfcOpenShell is free software: you can redistribute it and/or modify # +# it under the terms of the Lesser GNU General Public License as published by # +# the Free Software Foundation, either version 3.0 of the License, or # +# (at your option) any later version. # +# # +# IfcOpenShell is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# Lesser GNU General Public License for more details. # +# # +# You should have received a copy of the Lesser GNU General Public License # +# along with this program. If not, see . # +# # +############################################################################### + +import math +import json +import functools + +import ifcopenshell +import ifcopenshell.geom + +from xml.dom.minidom import parseString +from dataclasses import dataclass, fields + +import numpy + +W = ifcopenshell.ifcopenshell_wrapper + +SETTINGS = ifcopenshell.geom.settings( + # this is required for serialization + APPLY_DEFAULT_MATERIALS=True, + DISABLE_TRIANGULATION=True, +) + +WHITE = numpy.array((1.0, 1.0, 1.0)) + +DO_NOTHING = lambda *args: None + + +@dataclass +class draw_settings: + width: float = 297.0 + height: float = 420.0 + scale: float = 1.0 / 100.0 + auto_elevation: bool = True + auto_section: bool = True + include_entities: str = "" + exclude_entities: str = "IfcOpeningElement" + + +def main(settings, files, progress_function=DO_NOTHING): + iterator_kwargs = {} + if settings.include_entities: + iterator_kwargs["include"] = settings.include_entities.split(",") + elif settings.exclude_entities: + iterator_kwargs["exclude"] = settings.exclude_entities.split(",") + + # We have to keep the iterator in memory because otherwise + # the styles are cleared up. + iterators = list( + map( + functools.partial(ifcopenshell.geom.iterator, SETTINGS, **iterator_kwargs), + files, + ) + ) + + def yield_from_iterator(it): + if it.initialize(): + while True: + yield it.get() + if not it.next(): + break + + # Initialize serializer + buffer = ifcopenshell.geom.serializers.buffer() + sr = ifcopenshell.geom.serializers.svg(buffer, SETTINGS) + + # sr.setFile(files[0]) + # sr.setSectionHeightsFromStoreys() + + # required for svgfill + sr.setPolygonal(True) + sr.setUseNamespace(True) + + # sane default? + sr.setAlwaysProject(True) + + sr.setBoundingRectangle(settings.width, settings.height) + sr.setScale(settings.scale) + sr.setAutoElevation(settings.auto_elevation) + sr.setAutoSection(settings.auto_section) + + """ + # It is also possible to add drawing planes manually + sr.addDrawing( + obj.matrix_world().transposed()[3][0:3], + -obj.matrix_world().transposed()[2][0:3], + -obj.matrix_world().transposed()[0][0:3], + "Test", # drawing name + True # include projection + ) + """ + + # Initialize tree + tree = ifcopenshell.geom.tree() + # This instructs the tree to explode BReps into faces and return + # the style of the face when running tree.select_ray() + tree.enable_face_styles(True) + + # Loop over iterators for geometric content + for i, it in enumerate(iterators): + for elem in yield_from_iterator(it): + sr.write(elem) + if elem.type != "IfcSpace": + tree.add_element(elem) + progress_function("file", i, "progress", it.progress()) + + progress_function("hidden line rendering") + sr.finalize() + + # Obtain SVG output from serializer buffer + svg_data_1 = buffer.get_value() + + # Parse SVG into vector of line segments + # + # The second argument 'projection' tells the parser to only include groups + # that have the classname 'projection'. The IfcOpenShell SVG serializer puts + # the hidden line rendering output into this group. So the sections are not + # included here as they already form closed loops. + ls = W.svg_to_line_segments(svg_data_1, "projection") + progress_function("creating cells") + ps = W.line_segments_to_polygons(W.FILTERED_CARTESIAN_QUOTIENT, 1.0e-3, ls) + progress_function("done creating cells") + + """ + # Debugging tool to plot line segments and cells + from matplotlib import pyplot as plt + + arr = numpy.array(ls).reshape((-1, 2, 2)) + for x in arr: + plt.plot(x.T[0], x.T[1]) + for x in ps[0]: + plt.fill(numpy.array(x.boundary).T[0], numpy.array(x.boundary).T[1]) + """ + + # Reserialize cells into an SVG string + svg_data_2 = W.polygons_to_svg(ps, True) + + # We parse both SVG files to create on document with the combination of sections from + # the output directly from the serializer and the cells found from the hidden line + # rendering + dom1 = parseString(svg_data_1) + dom2 = parseString(svg_data_2) + + svg1 = dom1.childNodes[0] + svg2 = dom2.childNodes[0] + + def yield_groups(n): + if n.nodeType == n.ELEMENT_NODE and n.tagName == "g": + yield n + for c in n.childNodes: + yield from yield_groups(c) + + # From file 1 we take the groups to be substituted + groups1 = [g for g in yield_groups(svg1) if g.getAttribute("class") == "projection"] + # file 2 only has the groups we are interested in. + groups2 = list(yield_groups(svg2)) + + assert len(groups1) == len(groups2) + + for ii, (g1, g2) in enumerate(zip(groups1, groups2)): + projection, g1 = g1, g1.parentNode + + # These are attributes on the original group that we can use to reconstruct + # a 4x4 matrix of the projection used in the SVG generation process + nm = g1.getAttribute("ifc:name") + m4 = numpy.array(json.loads(g1.getAttribute("ifc:plane"))) + m3 = numpy.array(json.loads(g1.getAttribute("ifc:matrix3"))) + m44 = numpy.eye(4) + m44[0][0:2] = m3[0][0:2] + m44[1][0:2] = m3[1][0:2] + m44[0][3] = m3[0][2] + m44[1][3] = m3[1][2] + m44 = numpy.linalg.inv(m44) + + def project(xy, z=0.0): + xyzw = m44 @ numpy.array(xy + [z, 1.0]) + xyzw[1] *= -1.0 + return (m4 @ xyzw)[0:3] + + def pythonize(arr): + return tuple(map(float, arr)) + + # Loop over the cell paths + for i, p in enumerate(g2.getElementsByTagName("path")): + + progress_function("group", ii, "path", i) + + d = p.getAttribute("d") + # point inside is an attribute that comes from line_segments_to_polygons() + # it is an arbitrary point guaranteed to be inside the polygon and outside + # of any potential inner bounds. We can use this to construct a ray to find + # the face of the IFC element that the cell belongs to. + assert p.hasAttribute("ifc:pointInside") + + xy = list(map(float, p.getAttribute("ifc:pointInside").split(","))) + + a, b = project(xy, 0.0), project(xy, -100.0) + elements = tree.select_ray(pythonize(a), pythonize(b - a)) + + if elements: + # Put the IFC element entity type on the path for CSS-based styling + p.setAttribute("class", elements[0].instance.is_a()) + + # Obtain style (IfcOpenShell IfcGeom::Material) + style = tree.styles()[elements[0].style_index] + + # This is just a demonstration. We compose a factor of using: + # - ray intersection distance + # - dot product ray . face normal + # - style transparency + # the factor determines how much white will be interpolated + # into the style diffuse color. + clr = numpy.array(style.diffuse) + factor = (math.log(elements[0].distance + 2.0) / 7.0) * ( + 1.0 - 0.5 * abs(elements[0].dot_product) + ) + if style.has_transparency: + factor *= 1.0 - style.transparency + clr = WHITE * (1.0 - factor) + clr * factor + + svg_fill = "rgb(%s)" % ", ".join(str(f * 255.0) for f in clr[0:3]) + else: + svg_fill = "none" + + p.setAttribute("style", "fill: " + svg_fill) + + # Swap the XML nodes from the files + # Remove the original hidden line node we still have in the serializer output + g1.removeChild(projection) + g2.setAttribute("class", "projection") + # Find the children of the projection node parent + children = [x for x in g1.childNodes if x.nodeType == x.ELEMENT_NODE] + if children: + # Insert the new semantically enriched cell-based projection node + # *before* the node with sections from the serializer. SVG derives + # draw order from node order in the DOM so sections are draw over + # the projections. + g1.insertBefore(g2, children[0]) + else: + # This generally shouldn't happen + g1.appendChild(g2) + + data = dom1.toxml() + data = data.encode("ascii", "xmlcharrefreplace") + + return data + + +if __name__ == "__main__": + import sys + import argparse + + parser = argparse.ArgumentParser() + + parser.add_argument("files", type=str, nargs="+") + + for field in fields(draw_settings): + if field.type == bool: + parser.add_argument( + "--" + field.name, dest=field.name, action="store_true" + ) + parser.add_argument( + "--no-" + field.name, dest=field.name, action="store_false" + ) + else: + parser.add_argument( + "--" + field.name, type=field.type, default=field.default + ) + + args = vars(parser.parse_args(sys.argv)) + files = args.pop("files") + files.remove(__file__) + output = files.pop() + + settings = draw_settings(**args) + + files = list(map(ifcopenshell.open, files)) + open(output, "wb").write(main(settings, files, progress_function=print))