@@ -1005,14 +1005,12 @@ def generate_linework(self, context: bpy.types.Context) -> Union[str, None]:
10051005 if self .cprops .generate_material_layers :
10061006 self .generate_material_layers (context , root )
10071007 self .merge_linework_and_add_metadata (root )
1008- self .remove_coplanar_boundary_lines (root )
10091008 self .move_elements_to_top (root )
10101009 elif self .cprops .cut_mode == "OPENCASCADE" :
10111010 self .move_projection_to_bottom (root )
10121011 if self .cprops .generate_material_layers :
10131012 self .generate_material_layers (context , root )
10141013 self .merge_linework_and_add_metadata (root )
1015- self .remove_coplanar_boundary_lines (root )
10161014 self .move_elements_to_top (root )
10171015
10181016 if self .cprops .fill_mode == "SHAPELY" :
@@ -1090,7 +1088,6 @@ def generate_linework(self, context: bpy.types.Context) -> Union[str, None]:
10901088 if self .cprops .fill_mode == "SVGFILL" :
10911089 results = etree .tostring (root ).decode ("utf8" )
10921090 svg_data_1 = results
1093- from collections import defaultdict
10941091 from xml .dom .minidom import parseString
10951092
10961093 def yield_groups (n ):
@@ -1105,31 +1102,19 @@ def yield_groups(n):
11051102
11061103 ls_groups = ifcopenshell .ifcopenshell_wrapper .svg_to_line_segments (results , "projection" )
11071104
1108- # Group projection elements by their parent section-view group so that all
1109- # projection linework from the same view is merged in one cell decomposition.
1110- # This enables coplanar surfaces from *different* elements to be joined.
1111- groups_by_parent = defaultdict (list )
1112- ls_by_parent = defaultdict (list )
1113- for ls , g in zip (ls_groups , groups1 ):
1114- pid = id (g .parentNode )
1115- groups_by_parent [pid ].append (g )
1116- ls_by_parent [pid ].extend (ls )
1117-
1118- for pid , projection_groups in groups_by_parent .items ():
1119- section_parent = projection_groups [0 ].parentNode
1120- combined_ls = ls_by_parent [pid ]
1105+ for i , (ls , g1 ) in enumerate (zip (ls_groups , groups1 )):
1106+ projection , g1 = g1 , g1 .parentNode
11211107
11221108 svgfill_context = ifcopenshell .ifcopenshell_wrapper .context (
11231109 ifcopenshell .ifcopenshell_wrapper .EXACT_CONSTRUCTIONS , 1.0e-3
11241110 )
11251111
11261112 # EXACT_CONSTRUCTIONS is significantly faster than FILTERED_CARTESIAN_QUOTIENT
11271113 # remove duplicates (without tolerance)
1128- combined_ls = [l for l in map (tuple , set (map (frozenset , combined_ls ))) if len (l ) == 2 and l [0 ] != l [1 ]]
1129- svgfill_context .add (combined_ls )
1114+ ls = [l for l in map (tuple , set (map (frozenset , ls ))) if len (l ) == 2 and l [0 ] != l [1 ]]
1115+ svgfill_context .add (ls )
11301116
1131- num_passes = 1
1132- g2 = None
1117+ num_passes = 0
11331118
11341119 for iteration in range (num_passes + 1 ):
11351120 # initialize empty group, note that in the current approach only one
@@ -1204,57 +1189,46 @@ def yield_groups(n):
12041189 if iteration != num_passes :
12051190 to_remove = []
12061191
1207- material_cache = {}
12081192 for he_idx in range (0 , len (pairs ), 2 ):
1193+ # @todo instead of ray_distance, better do (x.point - y.point).dot(x.normal)
1194+ # to see if they're coplanar, because ray-distance will be different in case
1195+ # of element surfaces non-orthogonal to the view direction
1196+
12091197 def format (x ):
12101198 if x is None :
12111199 return None
12121200 elif isinstance (x , tuple ):
12131201 # found to be inside element using tree.select() no face or style info
12141202 return x
12151203 else :
1216- return (x .instance , tuple ( x . position ) , tuple (x .normal ), x . style_index )
1204+ return (x .instance . is_a (), x . ray_distance , tuple (x .position ) )
12171205
12181206 pp = pairs [he_idx : he_idx + 2 ]
12191207 if pp == (- 1 , - 1 ):
12201208 continue
12211209 data = list (map (format , map (semantics .__getitem__ , pp )))
1222- if None not in data and data [0 ][0 ].is_a () == data [1 ][0 ].is_a ():
1223- if len (data [0 ]) == 2 and len (data [1 ]) == 2 :
1224- # Both from tree.select() -> same element = same surface
1225- if data [0 ][0 ] == data [1 ][0 ]:
1226- to_remove .append (he_idx // 2 )
1227- elif len (data [0 ]) == 4 and len (data [1 ]) == 4 :
1228- # Both from tree.select_ray() -> coplanar + same style + same material
1229- p1 , n1 , s1 = np .array (data [0 ][1 ]), np .array (data [0 ][2 ]), data [0 ][3 ]
1230- p2 , n2 , s2 = np .array (data [1 ][1 ]), np .array (data [1 ][2 ]), data [1 ][3 ]
1231-
1232- if s1 == s2 :
1233- if abs (1.0 - abs (np .dot (n1 , n2 ))) < 1.0e-4 :
1234- if abs (np .dot (p1 - p2 , n1 )) < 1.0e-4 :
1235- def get_cached_material (inst ):
1236- id_ = inst .id ()
1237- if id_ not in material_cache :
1238- mats = ifcopenshell .util .element .get_materials (inst )
1239- material_cache [id_ ] = tuple (m .id () for m in mats ) if mats else (- 1 ,)
1240- return material_cache [id_ ]
1241-
1242- if get_cached_material (data [0 ][0 ]) == get_cached_material (data [1 ][0 ]):
1243- to_remove .append (he_idx // 2 )
1244- # print(he_idx // 2, *data)
1210+ if None not in data and data [0 ][0 ] == data [1 ][0 ] and abs (data [0 ][1 ] - data [1 ][1 ]) < 1.0e-5 :
1211+ to_remove .append (he_idx // 2 )
1212+ # Print edge index and semantic data
1213+ # print(he_idx // 2, *data)
12451214
12461215 svgfill_context .merge (to_remove )
12471216
1248- # Replace all per-element projection groups with one merged cell group.
1249- # SVG draw order: projections must be below sections, so insert first.
1250- for pg in projection_groups :
1251- section_parent .removeChild (pg )
1217+ # Swap the XML nodes from the files
1218+ # Remove the original hidden line node we still have in the serializer output
1219+ g1 .removeChild (projection )
12521220 g2 .setAttribute ("class" , "projection" )
1253- children = [x for x in section_parent .childNodes if x .nodeType == x .ELEMENT_NODE ]
1221+ # Find the children of the projection node parent
1222+ children = [x for x in g1 .childNodes if x .nodeType == x .ELEMENT_NODE ]
12541223 if children :
1255- section_parent .insertBefore (g2 , children [0 ])
1224+ # Insert the new semantically enriched cell-based projection node
1225+ # *before* the node with sections from the serializer. SVG derives
1226+ # draw order from node order in the DOM so sections are draw over
1227+ # the projections.
1228+ g1 .insertBefore (g2 , children [0 ])
12561229 else :
1257- section_parent .appendChild (g2 )
1230+ # This generally shouldn't happen
1231+ g1 .appendChild (g2 )
12581232
12591233 results = dom1 .toxml ()
12601234 results = results .encode ("ascii" , "xmlcharrefreplace" )
@@ -1626,171 +1600,6 @@ def merge_linework_and_add_metadata(self, root):
16261600 g .set ("class" , " " .join (list (polygon_classes )))
16271601 group .append (g )
16281602
1629- def remove_coplanar_boundary_lines (self , root ):
1630- """Remove projection line segments shared between same-material elements.
1631-
1632- After merge_linework_and_add_metadata() adds material-* CSS classes,
1633- this scans all per-element projection <g> groups under each common
1634- parent, finds path segments (M x0,y0 L x1,y1) that appear in two or
1635- more groups that carry the same material-* class, and deletes them from
1636- both groups so coplanar surfaces of the same material appear seamless.
1637- """
1638- SVG = "http://www.w3.org/2000/svg"
1639- TOL = 0.01 # SVG coordinate tolerance for matching line endpoints
1640-
1641- obj_cache = {}
1642-
1643- def get_obj (guid ):
1644- if guid in obj_cache :
1645- return obj_cache [guid ]
1646- element = self .get_element_by_guid (guid )
1647- obj = tool .Ifc .get_object (element ) if element is not None else None
1648- obj_cache [guid ] = obj
1649- return obj
1650-
1651- adjacency_cache = {}
1652-
1653- def are_coplanar_and_adjacent (guid_a , guid_b , tol = 0.01 ):
1654- """True if the two meshes share a vertex AND have parallel face normals.
1655-
1656- Sharing a vertex confirms physical adjacency (rules out depth-stacked elements
1657- whose 2D projections accidentally overlap). Parallel normals confirms the
1658- shared face is coplanar — elements meeting at a fold angle are rejected.
1659- """
1660- key = (min (guid_a , guid_b ), max (guid_a , guid_b ))
1661- if key in adjacency_cache :
1662- return adjacency_cache [key ]
1663- obj_a = get_obj (guid_a )
1664- obj_b = get_obj (guid_b )
1665- if obj_a is None or obj_b is None or obj_a .type != "MESH" or obj_b .type != "MESH" :
1666- adjacency_cache [key ] = True
1667- return True
1668- # Quick AABB guard
1669- corners_a = [obj_a .matrix_world @ Vector (c ) for c in obj_a .bound_box ]
1670- corners_b = [obj_b .matrix_world @ Vector (c ) for c in obj_b .bound_box ]
1671- for axis in range (3 ):
1672- if min (c [axis ] for c in corners_a ) > max (c [axis ] for c in corners_b ) + tol :
1673- adjacency_cache [key ] = False
1674- return False
1675- if min (c [axis ] for c in corners_b ) > max (c [axis ] for c in corners_a ) + tol :
1676- adjacency_cache [key ] = False
1677- return False
1678- # Shared vertex check
1679- tol_sq = tol * tol
1680- verts_a = [obj_a .matrix_world @ v .co for v in obj_a .data .vertices ]
1681- verts_b = [obj_b .matrix_world @ v .co for v in obj_b .data .vertices ]
1682- has_shared = any ((va - vb ).length_squared < tol_sq for va in verts_a for vb in verts_b )
1683- if not has_shared :
1684- adjacency_cache [key ] = False
1685- return False
1686- # Coplanarity check: use the largest-face normal for each object.
1687- # Area-weighted averages fail for slabs because top/bottom faces cancel.
1688- def dominant_world_normal (obj ):
1689- mat3 = obj .matrix_world .to_3x3 ().normalized ()
1690- best = max (obj .data .polygons , key = lambda p : p .area , default = None )
1691- if best is None or best .area < 1e-10 :
1692- return None
1693- return (mat3 @ best .normal ).normalized ()
1694-
1695- n_a = dominant_world_normal (obj_a )
1696- n_b = dominant_world_normal (obj_b )
1697- if n_a is None or n_b is None :
1698- adjacency_cache [key ] = True
1699- return True
1700- result = abs (n_a .dot (n_b )) > 1.0 - 1e-3
1701- adjacency_cache [key ] = result
1702- return result
1703-
1704- def parse_line (d ):
1705- # Format is "Mx0,y0 Lx1,y1" (no space after M/L)
1706- parts = d .strip ().split ()
1707- if len (parts ) == 2 and parts [0 ].startswith ("M" ) and parts [1 ].startswith ("L" ):
1708- try :
1709- x0 , y0 = map (float , parts [0 ][1 :].split ("," ))
1710- x1 , y1 = map (float , parts [1 ][1 :].split ("," ))
1711- return (x0 , y0 ), (x1 , y1 )
1712- except ValueError :
1713- pass
1714- return None
1715-
1716- def lines_match (a , b ):
1717- (x0a , y0a ), (x1a , y1a ) = a
1718- (x0b , y0b ), (x1b , y1b ) = b
1719- return (
1720- abs (x0a - x0b ) < TOL and abs (y0a - y0b ) < TOL and abs (x1a - x1b ) < TOL and abs (y1a - y1b ) < TOL
1721- ) or (
1722- abs (x0a - x1b ) < TOL and abs (y0a - y1b ) < TOL and abs (x1a - x0b ) < TOL and abs (y1a - y0b ) < TOL
1723- )
1724-
1725- # Group projection <g> elements by their immediate parent
1726- parent_to_groups = {}
1727- for g in root .iter (f"{{{ SVG } }}g" ):
1728- cls_list = g .get ("class" , "" ).split ()
1729- if "projection" not in cls_list :
1730- continue
1731- parent = g .getparent ()
1732- if parent is None :
1733- continue
1734- parent_to_groups .setdefault (id (parent ), []).append (g )
1735-
1736- for pid , proj_groups in parent_to_groups .items ():
1737- if len (proj_groups ) < 2 :
1738- continue
1739-
1740- def get_material_key (guid ):
1741- element = self .get_element_by_guid (guid )
1742- if element is None :
1743- return None
1744- mats = ifcopenshell .util .element .get_materials (element )
1745- return tuple (sorted (m .id () for m in mats )) if mats else ()
1746-
1747- def get_style_key (guid ):
1748- """IDs of IfcPresentationStyles directly on the element's geometry items."""
1749- element = self .get_element_by_guid (guid )
1750- if element is None or not getattr (element , "Representation" , None ):
1751- return ()
1752- style_ids = set ()
1753- for rep in element .Representation .Representations :
1754- for item in rep .Items :
1755- for si in getattr (item , "StyledByItem" , ()):
1756- for style in si .Styles :
1757- style_ids .add (style .id ())
1758- return tuple (sorted (style_ids ))
1759-
1760- group_data = []
1761- for grp in proj_groups :
1762- guid = grp .get ("{http://www.ifcopenshell.org/ns}guid" , "" )
1763- mat_key = get_material_key (guid )
1764- style_key = get_style_key (guid )
1765- segs = []
1766- for path_el in grp .findall (f"{{{ SVG } }}path" ):
1767- line = parse_line (path_el .get ("d" , "" ))
1768- if line is not None :
1769- segs .append ((path_el , line ))
1770- group_data .append ((grp , mat_key , style_key , segs , guid ))
1771-
1772- to_remove = set ()
1773- for i , (grp_i , mat_i , style_i , segs_i , guid_i ) in enumerate (group_data ):
1774- if mat_i is None :
1775- continue
1776- for j , (grp_j , mat_j , style_j , segs_j , guid_j ) in enumerate (group_data ):
1777- if j <= i :
1778- continue
1779- if mat_j != mat_i or style_j != style_i :
1780- continue
1781- if not are_coplanar_and_adjacent (guid_i , guid_j ):
1782- continue
1783- for path_i , line_i in segs_i :
1784- for path_j , line_j in segs_j :
1785- if lines_match (line_i , line_j ):
1786- to_remove .add (id (path_i ))
1787- to_remove .add (id (path_j ))
1788- if to_remove :
1789- for grp , mat , style , segs , guid in group_data :
1790- for path_el , _ in segs :
1791- if id (path_el ) in to_remove :
1792- grp .remove (path_el )
1793-
17941603 def drawing_to_model_co (self , x : float , y : float ) -> Vector :
17951604 camera_xy = np .array ((x , - y )) / self .scale / 1000
17961605 camera_xy += np .array ((self .cprops .width / - 2 , self .cprops .height / 2 )) # top left offset
0 commit comments