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))