Skip to content

Commit 195ef74

Browse files
committed
See IfcOpenShell#3002. Experimental SVGFill approach to polygon merging.
1 parent 8478eb9 commit 195ef74

3 files changed

Lines changed: 163 additions & 3 deletions

File tree

src/blenderbim/blenderbim/bim/module/drawing/operator.py

Lines changed: 158 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -485,7 +485,7 @@ def generate_linework(self, context):
485485
self.serialiser.finalize()
486486
results = self.svg_buffer.get_value()
487487

488-
if self.camera.data.BIMCameraProperties.calculate_surfaces:
488+
if self.camera.data.BIMCameraProperties.calculate_shapely_surfaces:
489489
# shapely variant
490490
root = etree.fromstring(results)
491491
group = root.findall('.//{http://www.w3.org/2000/svg}g[@{http://www.ifcopenshell.org/ns}name]')[0]
@@ -532,6 +532,160 @@ def generate_linework(self, context):
532532
path.set("class", " ".join(list(classes)))
533533

534534
results = etree.tostring(root)
535+
536+
if self.camera.data.BIMCameraProperties.calculate_svgfill_surfaces:
537+
svg_data_1 = results
538+
from xml.dom.minidom import parseString
539+
540+
def yield_groups(n):
541+
if n.nodeType == n.ELEMENT_NODE and n.tagName == "g":
542+
yield n
543+
for c in n.childNodes:
544+
yield from yield_groups(c)
545+
546+
dom1 = parseString(svg_data_1)
547+
svg1 = dom1.childNodes[0]
548+
groups1 = [g for g in yield_groups(svg1) if g.getAttribute("class") == "projection"]
549+
550+
ls_groups = ifcopenshell.ifcopenshell_wrapper.svg_to_line_segments(results, "projection")
551+
552+
for i, (ls, g1) in enumerate(zip(ls_groups, groups1)):
553+
projection, g1 = g1, g1.parentNode
554+
555+
svgfill_context = ifcopenshell.ifcopenshell_wrapper.context(
556+
ifcopenshell.ifcopenshell_wrapper.EXACT_CONSTRUCTIONS, 1.0e-3
557+
)
558+
559+
# EXACT_CONSTRUCTIONS is significantly faster than FILTERED_CARTESIAN_QUOTIENT
560+
# remove duplicates (without tolerance)
561+
ls = [l for l in map(tuple, set(map(frozenset, ls))) if len(l) == 2 and l[0] != l[1]]
562+
svgfill_context.add(ls)
563+
564+
num_passes = 0
565+
566+
for iteration in range(num_passes + 1):
567+
# initialize empty group, note that in the current approach only one
568+
# group is stored
569+
ps = ifcopenshell.ifcopenshell_wrapper.svg_groups_of_polygons()
570+
if iteration != 0 or svgfill_context.build():
571+
svgfill_context.write(ps)
572+
573+
if iteration != num_passes:
574+
pairs = svgfill_context.get_face_pairs()
575+
semantics = [None] * (max(pairs) + 1)
576+
577+
# Reserialize cells into an SVG string
578+
svg_data_2 = ifcopenshell.ifcopenshell_wrapper.polygons_to_svg(ps, True)
579+
580+
# We parse both SVG files to create on document with the combination of sections from
581+
# the output directly from the serializer and the cells found from the hidden line
582+
# rendering
583+
dom2 = parseString(svg_data_2)
584+
svg2 = dom2.childNodes[0]
585+
# file 2 only has the groups we are interested in.
586+
# in fact in the approach, it's only a single group
587+
588+
g2 = list(yield_groups(svg2))[0]
589+
590+
# These are attributes on the original group that we can use to reconstruct
591+
# a 4x4 matrix of the projection used in the SVG generation process
592+
nm = g1.getAttribute("ifc:name")
593+
m4 = np.array(json.loads(g1.getAttribute("ifc:plane")))
594+
m3 = np.array(json.loads(g1.getAttribute("ifc:matrix3")))
595+
m44 = np.eye(4)
596+
m44[0][0:2] = m3[0][0:2]
597+
m44[1][0:2] = m3[1][0:2]
598+
m44[0][3] = m3[0][2]
599+
m44[1][3] = m3[1][2]
600+
m44 = np.linalg.inv(m44)
601+
602+
# Loop over the cell paths
603+
for pi, p in enumerate(g2.getElementsByTagName("path")):
604+
605+
d = p.getAttribute("d")
606+
coords = [co[1:].split(",") for co in d.split() if co[1:]]
607+
polygon = shapely.Polygon(coords)
608+
# 1mm2 polygons aren't worth styling in paperspace. Raycasting is expensive!
609+
if polygon.area < 1:
610+
continue
611+
# point inside is an attribute that comes from line_segments_to_polygons() polygons_to_svg?
612+
# it is an arbitrary point guaranteed to be inside the polygon and outside
613+
# of any potential inner bounds. We can use this to construct a ray to find
614+
# the face of the IFC element that the cell belongs to.
615+
assert p.hasAttribute("ifc:pointInside")
616+
617+
xy = list(map(float, p.getAttribute("ifc:pointInside").split(",")))
618+
619+
a, b = self.drawing_to_model_co(m44, m4, xy, 0.0), self.drawing_to_model_co(m44, m4, xy, -100.0)
620+
621+
inside_elements = [e for e in tree.select(self.pythonize(a)) if not e.is_a("IfcAnnotation")]
622+
if inside_elements:
623+
elements = None
624+
if iteration != num_passes:
625+
semantics[pi] = (inside_elements[0], -1)
626+
else:
627+
elements = [e for e in tree.select_ray(self.pythonize(a), self.pythonize(b - a)) if not e.instance.is_a("IfcAnnotation")]
628+
629+
if elements:
630+
classes = self.get_svg_classes(ifc.by_id(elements[0].instance.id()))
631+
classes.append("projection")
632+
633+
if iteration != num_passes:
634+
semantics[pi] = elements[0]
635+
else:
636+
classes = ["projection"]
637+
638+
p.setAttribute("style", "")
639+
p.setAttribute("class", " ".join(classes))
640+
641+
if iteration != num_passes:
642+
to_remove = []
643+
644+
for he_idx in range(0, len(pairs), 2):
645+
# @todo instead of ray_distance, better do (x.point - y.point).dot(x.normal)
646+
# to see if they're coplanar, because ray-distance will be different in case
647+
# of element surfaces non-orthogonal to the view direction
648+
649+
def format(x):
650+
if x is None:
651+
return None
652+
elif isinstance(x, tuple):
653+
# found to be inside element using tree.select() no face or style info
654+
return x
655+
else:
656+
return (x.instance.is_a(), x.ray_distance, tuple(x.position))
657+
658+
pp = pairs[he_idx : he_idx + 2]
659+
if pp == (-1, -1):
660+
continue
661+
data = list(map(format, map(semantics.__getitem__, pp)))
662+
if None not in data and data[0][0] == data[1][0] and abs(data[0][1] - data[1][1]) < 1.0e-5:
663+
to_remove.append(he_idx // 2)
664+
# Print edge index and semantic data
665+
# print(he_idx // 2, *data)
666+
667+
svgfill_context.merge(to_remove)
668+
669+
# Swap the XML nodes from the files
670+
# Remove the original hidden line node we still have in the serializer output
671+
g1.removeChild(projection)
672+
g2.setAttribute("class", "projection")
673+
# Find the children of the projection node parent
674+
children = [x for x in g1.childNodes if x.nodeType == x.ELEMENT_NODE]
675+
if children:
676+
# Insert the new semantically enriched cell-based projection node
677+
# *before* the node with sections from the serializer. SVG derives
678+
# draw order from node order in the DOM so sections are draw over
679+
# the projections.
680+
g1.insertBefore(g2, children[0])
681+
else:
682+
# This generally shouldn't happen
683+
g1.appendChild(g2)
684+
685+
data = dom1.toxml()
686+
data = data.encode("ascii", "xmlcharrefreplace")
687+
688+
results = data
535689
with profile("Post processing"):
536690
root = etree.fromstring(results)
537691
self.move_projection_to_bottom(root)
@@ -664,6 +818,9 @@ def pythonize(self, arr):
664818
def move_projection_to_bottom(self, root):
665819
# https://stackoverflow.com/questions/36018627/sorting-child-elements-with-lxml-based-on-attribute-value
666820
group = root.find("{http://www.w3.org/2000/svg}g")
821+
if self.camera.data.BIMCameraProperties.calculate_svgfill_surfaces:
822+
# SVGFill already places the projection in the right spot.
823+
return
667824
# group[:] = sorted(group, key=lambda e : "projection" in e.get("class"))
668825
if group is not None:
669826
group[:] = reversed(group)

src/blenderbim/blenderbim/bim/module/drawing/prop.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,8 @@ class DocProperties(PropertyGroup):
330330

331331

332332
class BIMCameraProperties(PropertyGroup):
333-
calculate_surfaces: BoolProperty(name="Calculate Surfaces", default=False)
333+
calculate_shapely_surfaces: BoolProperty(name="Calculate Shapely Surfaces", default=False)
334+
calculate_svgfill_surfaces: BoolProperty(name="Calculate SVGFill Surfaces", default=False)
334335
has_underlay: BoolProperty(name="Underlay", default=False, update=update_has_underlay)
335336
has_linework: BoolProperty(name="Linework", default=True, update=update_has_linework)
336337
has_annotation: BoolProperty(name="Annotation", default=True, update=update_has_annotation)

src/blenderbim/blenderbim/bim/module/drawing/ui.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,9 @@ def draw(self, context):
6363
row.prop(dprops, "should_use_annotation_cache", text="", icon="FILE_REFRESH")
6464

6565
row = layout.row()
66-
row.prop(props, "calculate_surfaces")
66+
row.prop(props, "calculate_shapely_surfaces")
67+
row = layout.row()
68+
row.prop(props, "calculate_svgfill_surfaces")
6769

6870
row = layout.row()
6971
row.prop(dprops, "should_extract")

0 commit comments

Comments
 (0)