You are definitely right @AbrahamYezioro , I tried applying this to a more complex project and realized that there are many items that need to be included.
- Preserve all apertures
- Handle ‘donut’ shapes
- Preserve all user_data and properties
- Handle rotation and scaling of the Polygons
I refined it a bit here, and its close. When I give it a large, more shap-ily complicated building mass such as:
Before Merge:
It ‘almost’ works… but there is some significant weirdness in a few spots.
After Merge:
where a lot of it works great, but some random (ish) faces have decided to fly off on me
very odd…
I’m pretty confident in the ‘grouping’ here before the merge. I have a 3-part evaluation to group faces based on their ‘type’, their plane, and if they are ‘touching’ one another
Sort By Type:
from collections import defaultdict
from Grasshopper import DataTree
from System import Object
from Grasshopper.Kernel.Data import GH_Path
def _hb_face_type_unique_key(_hb_face):
# type: (Face) -> str
"""Return a unique key for the HB-Face's type."""
face_type = str(_hb_face.type)
face_bc = str(_hb_face.boundary_condition)
const_name = _hb_face.properties.energy.construction.display_name
normal = str(_hb_face.geometry.normal)
return "{}_{}_{}_{}".format(face_type, face_bc, const_name, normal)
def sort_faces_by_type(_faces):
# type: (List[Face]) -> List[List[Face]]
"""Group HB-Faces by their type."""
d = defaultdict(list)
for face in _faces:
key = _hb_face_type_unique_key(face)
d[key].append(face.duplicate())
return list(d.values())
faces_ = DataTree[Object]()
for i, face_group in enumerate(sort_faces_by_type(_faces)):
faces_.AddRange(face_group, GH_Path(i))
Sort by Plane:
from Grasshopper import DataTree
from System import Object
from Grasshopper.Kernel.Data import GH_Path
import scriptcontext as sc
def sort_faces_by_co_planar(_faces, _tolerance, _angle_tolerance):
# type: (List[Face], float, float) -> List[List[Face]]
"""Group HB-Faces with their co-planar neighbors."""
d = {}
for f in _faces:
if not d:
d[id(f)] = [f]
else:
for k, v in d.items():
if f.geometry.plane.is_coplanar_tolerance(
v[0].geometry.plane, _tolerance, _angle_tolerance
):
d[k].append(f)
break
else:
d[id(f)] = [f]
return d.values()
face_groups_ = DataTree[Object]()
for i, b in enumerate(_face_groups.Branches):
for j, g in enumerate(
sort_faces_by_co_planar(
b,sc.doc.ModelAbsoluteTolerance,
sc.doc.ModelAngleToleranceDegrees
)
):
face_groups_.AddRange(g, GH_Path(i, j))
Sort By Touching:
from Grasshopper import DataTree
from System import Object
from Grasshopper.Kernel.Data import GH_Path
import scriptcontext as sc
def are_coincident_points(_pt1, _pt2, _tolerance):
# type (Point3D, Point3D, float) -> bool
"""Return True if two Point3D objects are coincident within the tolerance."""
return _pt1.distance_to_point(_pt2) < _tolerance
def are_touching(_face_2, _face_1, _tolerance):
# type: (Face, Face, float) -> bool
"""Return True if the faces are 'touching' one another within the tolerance."""
for v in _face_1.vertices:
if _face_2.geometry.is_point_on_face(v, _tolerance):
return True
elif any([are_coincident_points(v, v2, _tolerance) for v2 in _face_2.vertices]):
return True
return False
def find_connected_components(_hb_faces, _tolerance):
# type: (List[Face], float) -> List[List[Face]]
"""ChatGPT gave me this... not 100% sure what its doing. Seems to work though."""
visited = set()
components = []
def dfs(node, component):
# type: (Face, List[Face]) -> None
visited.add(node)
component.append(node)
for _neighbor_face in _hb_faces:
if _neighbor_face not in visited and are_touching(node, _neighbor_face, _tolerance):
dfs(_neighbor_face, component)
for hb_face in _hb_faces:
if hb_face not in visited:
component = []
dfs(hb_face, component)
components.append(component)
return components
face_groups_ = DataTree[Object]()
for i, b in enumerate(_face_groups.Branches):
for j, group in enumerate(find_connected_components(b, sc.doc.ModelAbsoluteTolerance)):
face_groups_.AddRange(group, GH_Path(i, j))
which all seems to do a decent job of sorting and grouping the faces into “merge-able” sets before the merge.
For the Merge, I revised it to at least handle the Apertures and the Properties:
import scriptcontext as sc
from copy import copy
from ladybug_geometry.geometry2d.pointvector import Vector2D
from ladybug_geometry.geometry2d.polygon import Polygon2D
from ladybug_geometry.geometry3d.face import Face3D
from honeybee import room, face
def _get_polygon2d_in_reference_space(_polygon2d, _poly2d_plane, _base_plane):
# type: (Polygon2D, Plane, Plane) -> Polygon2D
"""Return a Polygon2D in the reference space of the base polygon."""
base_plane_origin = copy(_base_plane.xyz_to_xy(_base_plane.o))
# -- Create a Vector2D from each face's origin to the base-geom's origin
face_origin_in_base_plane_space = _base_plane.xyz_to_xy(_poly2d_plane.o)
mv_x = face_origin_in_base_plane_space.x - base_plane_origin.x
mv_y = face_origin_in_base_plane_space.y - base_plane_origin.y
move_vec = Vector2D(mv_x, mv_y) # type: ignore
# ------------------------------------------------------------------------
# -- Move the face's Polygon2D into the base polygon's space
return _polygon2d.move(move_vec)
def _create_new_Face3D(_poly2D, _base_plane, _ref_face):
# type: (Polygon2D, Plane, Face) -> Face
"""Create a new Face from a Polygon2D and a reference HB-Face."""
new_face = face.Face(
identifier=_ref_face.identifier,
geometry=Face3D(
boundary=tuple(_base_plane.xy_to_xyz(v) for v in _poly2D.vertices),
plane=_ref_face.geometry.plane,
),
type=_ref_face.type,
boundary_condition=_ref_face.boundary_condition,
)
new_face.display_name = _ref_face.display_name
new_face._user_data = (
None if _ref_face.user_data is None else _ref_face.user_data.copy()
)
new_face._properties._duplicate_extension_attr(_ref_face._properties)
return new_face
def _add_sub_face(_face, _aperture):
# type: (Face, Aperture) -> Face
"""Add an HB-sub-face (either HB-Aperture or HB-Door) to a parent Face.
NOTE: this method is copied from honeybee's Grasshopper component "HB Add Subface"
"""
if isinstance(_aperture, Aperture): # the sub-face is an Aperture
_face.add_aperture(_aperture)
else: # the sub-face is a Door
_face.add_door(_aperture)
return _face
def _check_and_add_sub_face(_face, _apertures, _tolerance, _angle_tolerance,):
# type: (Face, List[Aperture], float, float) -> None
"""Check whether a HB-sub-face is valid for an HB-face and, if so, add it.
NOTE: this method is copied from honeybee's Grasshopper component "HB Add Subface"
"""
for aperture in _apertures:
if _face.geometry.is_sub_face(aperture.geometry, _tolerance, _angle_tolerance):
_add_sub_face(_face, aperture)
def _merge_hb_face_group(_faces, _tolerance, _angle_tolerance):
# type: (List[Face], float, float) -> List[Face]
"""Merge a group of HB-Faces into the fewest number of faces possible."""
if not _faces:
return []
if len(_faces) == 1:
return _faces
# -------------------------------------------------------------------------
# -- Before anything else, preserve all the Apertures for adding back in later
apertures = []
for f in _faces:
apertures.extend([ap.duplicate() for ap in f.apertures])
# -------------------------------------------------------------------------
# -- This will be the reference face for everything else to match
reference_face = _faces.pop(0).duplicate() # type: face.Face
reference_plane = copy(reference_face.geometry.plane)
polygons_in_ref_space = [
reference_face.geometry.polygon2d,
]
# -------------------------------------------------------------------------
# -- Get all the Polygon2Ds in the same reference space
poly2ds = (f.geometry.polygon2d for f in _faces)
planes = (f.geometry.plane for f in _faces)
for poly2d_plane, poly2d in zip(planes, poly2ds):
polygons_in_ref_space.append(
_get_polygon2d_in_reference_space(poly2d, poly2d_plane, reference_plane)
)
# -------------------------------------------------------------------------
# -- Merge all the new Polygon2Ds together.
merged_polygons = Polygon2D.boolean_union_all(polygons_in_ref_space, _tolerance)
# -- Create new faces for each of the merged Polygon2Ds
faces = []
for p in merged_polygons:
faces.append(_create_new_Face3D(p, reference_plane, reference_face))
# -------------------------------------------------------------------------
# -- Add the apertures back in
faces_with_apertures_ = []
for fce in faces:
_check_and_add_sub_face(fce, apertures, _tolerance, _angle_tolerance)
faces_with_apertures_.append(fce)
return faces_with_apertures_
# -- Create new merged faces
new_faces_ = []
for face_group in _face_groups.Branches:
new_faces_.extend(
_merge_hb_face_group(
list(face_group),
sc.doc.ModelAbsoluteTolerance,
sc.doc.ModelAngleToleranceDegrees
)
)
but as I showed, I still get some really weird fly-offs after the merge.
Example attached in case you are curious or have any thoughts.
@edpmay
merge_example.gh (411.5 KB)