Merging LBT Face3Ds?

Hi all,

This is a weird question, but I’m just wondering if anyone has ever run into something similar and might have any suggestions as to how to approach the problem?

Scenario:

As a the result of how it was created, I have some HB-Rooms with wall, roof and floor surfaces that are made up of many smaller Face3Ds (don’t ask… its just a result of how it was built). So now what I would like to do is ‘merge’ those Face3Ds back together, in an effort to simplify the model. Basically, the equivalent of Rhino’s “MergeCoplanarFaces” command

The wildcard here is that this operation has to be done ‘outside’ Rhino or Revit. So using only the Ladybug Face3D edges, vertices, and native methods. I cannot use any Rhino commands such as “MergeCoplanarFaces” in this particular task.


So I am wondering if anyone has ever attempted anything similar? Are there any methods built into LBT that might help here with such a ‘merge’ operation?

I am able to filter and sort all the faces properly using methods like .is_point_on_face() method to find the ‘touching’ ones (I think). But then I’m sort of looking for the opposite of the .coplanar_split() method? Has anyone tried to every do such a thing?

Any advice is much appreciated.

thanks!
@edpmay

Hi @edpmay,

I can’t say for sure it’ll fit your use, but looking at the ladybug_geometry polygon methods Chris pointed me at on another post I spotted this that I’m thinking of using for a similar operation to convert 2d face polygons into a single face to get an outline.

https://www.ladybug.tools/ladybug-geometry/docs/ladybug_geometry.geometry2d.polygon.html#ladybug_geometry.geometry2d.polygon.Polygon2D.remove_colinear_vertices

Thanks @charlie.brooker ! That’s a great idea - this looks like it might be just what I am after.

I think I must be doing something wrong here, or misunderstanding something though.

if I do something like this:

from ladybug_rhino.togeometry import to_face3d
from ladybug_rhino.fromgeometry import from_point3d
from ladybug_geometry.geometry3d.pointvector import Vector3D
import scriptcontext as sc
TOLERANCE = sc.doc.ModelAbsoluteTolerance

# -- get the geometry as LBT Face3D
face1 = to_face3d(_face1)[0]
face2 = to_face3d(_face2)[0]

# -- perform the intersection
new_polygon = face1.polygon2d.boolean_union(face2.polygon2d, TOLERANCE)[0]
new_points = [from_point3d(face1.plane.xy_to_xyz(p)) for p in new_polygon.vertices]

I get sort of what I’m after, but something is off about the new points created by the union?

It seems to be doing the intersection well - but it sort of puts both Polygon2D elements centered (sort of? Not really…)

Can you tell where I’m going wrong there? Do I need to align / shift the polygons somehow before the union operation do you think? Does face2 need it’s polygon to somehow be shifted to match face1’s plane, or something along those lines?

but yes: this looks like the right idea for sure! Just need to figure out what I’m doing wrong here.

thanks!
@edpmay

FWIW, I was able to make this work (so far):

import scriptcontext as sc
TOLERANCE = sc.doc.ModelAbsoluteTolerance

from ladybug_rhino.togeometry import to_face3d
from ladybug_rhino.fromgeometry import from_face3d, from_point3d
from ladybug_geometry.geometry2d.polygon import Polygon2D
from ladybug_geometry.geometry3d.face import Face3D
from ladybug_geometry.geometry3d.plane import Plane
from ladybug_geometry.geometry2d.pointvector import Vector2D, Point2D
from ladybug_geometry.geometry3d.pointvector import Vector3D, Point3D

# ------------------------------------------------------------------------
# -- Get the geometry as LBT Face3d
face1 = to_face3d(_face1)[0]
face2 = to_face3d(_face2)[0]

# ------------------------------------------------------------------------
# -- Pull out the Face's polygon2d
poly1 = face1.polygon2d
poly2 = face2.polygon2d

# ------------------------------------------------------------------------
# -- Pull out each  Face's Plane Origin
f1_o = face1.plane.o
f2_o = face2.plane.o

# ------------------------------------------------------------------------
# -- Create a Vecctor2D from Face1's origin to Face2's origin 
f1_o_in_f1_space = face1.plane.xyz_to_xy(f1_o)
f2_o_in_f1_space = face1.plane.xyz_to_xy(f2_o)

mv_x = f2_o_in_f1_space.x - f1_o_in_f1_space.x
mv_y = f2_o_in_f1_space.y - f1_o_in_f1_space.y

f2_move_vec = Vector2D(mv_x, mv_y)

# ------------------------------------------------------------------------
# -- Move Face2's Polygon2D into Face1's space
f2_polygon_moved = face2.polygon2d.move(f2_move_vec)

# ------------------------------------------------------------------------
# -- perform the intersection
new_polygon = face1.polygon2d.boolean_union(f2_polygon_moved, TOLERANCE)[0]
new_points = [from_point3d(face1.plane.xy_to_xyz(p)) for p in new_polygon.vertices]
new_vertices = tuple(face1.plane.xy_to_xyz(v) for v in new_polygon.vertices)

# ------------------------------------------------------------------------
# -- Build a new Face3D from the results
new_face = Face3D(boundary=new_vertices)
face3_ = from_face3d(new_face)

If there is an easier / better way using built-in methods for HB Faces or Polygons, I’d love to know it. This seems to mostly get me what I’m after though I think. I’m not sure how it will deal with holes or other such things, but so far so good on the merging at least.

@edpmay

3 Likes

Nice one @edpmay, thanks for sharing - I gave it a quick go but quickly struggled and needed to move onto other things. I’m sure your example there will be very useful for a project I have in mind :slight_smile:

Thanks for sharing @edpmay !
Works nicely and i can see its usefulness.

One bug in it is that it works for unrotated surfaces. If you rotate the (one of them), then it doesn’t work as expected. It uses kind of bounding box of the surface. Don’t know enough the LBT code to suggest solutions for that … :thinking:

-A.

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)

3 Likes

FWIW, in case anyone finds this thread in the future, I was able to make this work reasonably well (it’s not fast… but fairly consistent at least) using the implementation shown below (GH file attached for reference).

import scriptcontext as sc
from copy import copy
import math
    
from ladybug_geometry.geometry3d.plane import Plane
from ladybug_geometry.geometry2d.pointvector import Vector2D
from ladybug_geometry.geometry3d.pointvector import Vector3D
from ladybug_geometry.geometry2d.polygon import Polygon2D
from ladybug_geometry.geometry3d.face import Face3D

from ladybug_rhino.fromgeometry import from_point3d

from honeybee import room, face
from honeybee.aperture import Aperture


def cross_product(a, b):
    # type: (List[float], List[float]) -> List[float]
    return [
        a[1]*b[2] - a[2]*b[1], 
        a[2]*b[0] - a[0]*b[2],
        a[0]*b[1] - a[1]*b[0]
    ]


def dot_product(a, b):
    # type: (List[float], List[float]) -> float
    return sum([a[i]*b[i] for i in range(len(a))])


def magnitude(a):
    # type: (List[float]) -> float
    return sum([a[i]**2 for i in range(len(a))])**0.5


def normalize(a):
    # type: (List[float) -> List[float]
    mag = magnitude(a)
    return [a[i]/mag for i in range(len(a))]


def angle_between_planes(plane1, plane2, _tolerance):
    # type: (Plane, Plane, float) -> float
    # Calculate the x-axes of the planes
    plane1_xaxis = cross_product(plane1.n, plane1.x)
    plane2_xaxis = cross_product(plane2.n, plane2.x)

    # Normalize the x-axes
    plane1_xaxis = normalize(plane1_xaxis)
    plane2_xaxis = normalize(plane2_xaxis)

    # Calculate the dot product between the x-axes
    dot_product_value = dot_product(plane1_xaxis, plane2_xaxis)

    # Calculate the angle between the x-axes
    # Handle parallel or coincident planes
    if (1.0 - dot_product_value) < _tolerance:
        return 0.0
        
    angle_rad = math.acos(dot_product_value)

    # Determine the counterclockwise angle
    orientation = cross_product(plane1_xaxis, plane2_xaxis)
    if dot_product(plane1.n, orientation) < 0:
        angle_rad = 2 * math.pi - angle_rad

    return angle_rad

    
def _get_polygon2d_in_reference_space(_polygon2d, _poly2d_plane, _base_plane, _tolerance):
    # type: (Polygon2D, Plane, Plane, float) -> Polygon2D
    """Return a Polygon2D in the reference space of the base polygon."""
    
    # -- Create a Vector2D from each face's origin to the base-geom's origin
    base_plane_x_vec = _base_plane.x
    face_origin_in_base_plane_space = _base_plane.xyz_to_xy(_poly2d_plane.o)
    base_plane_origin = copy(_base_plane.xyz_to_xy(_base_plane.o))
    
    # -- Construct a Move vector from the face's origin to the base-plane's origin
    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
    moved_polygon = _polygon2d.move(move_vec)
    
    # ------------------------------------------------------------------------
    # -- Rotate the Polygon to align with the base-plane
    angle = angle_between_planes(_base_plane, _poly2d_plane, _tolerance)
    rotated_polygon = moved_polygon.rotate(angle=angle, origin=face_origin_in_base_plane_space)
    return rotated_polygon



def _create_new_HB_Face(_face3D, _ref_face):
    # type: (Face3D, Face) -> Face
    """Create a new HB-Face using a Face3D and a reference HB-Face."""
    new_face = face.Face(
        identifier=_ref_face.identifier,
        geometry=_face3D,
        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 _create_new_Face3D(_poly2D, _base_plane, _ref_face):
    # type: (Polygon2D, Plane, Face) -> Face3D
    return Face3D(
            boundary=tuple(_base_plane.xy_to_xyz(v) for v in _poly2D.vertices),
            plane=_ref_face.geometry.plane,
        )


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 find_parent_and_child_polygons(_polygons):
    # type: (List[Polygon2D]): -> Tuple[List[Polygon2D], List[Polyon2D]]
    
    # Initialize empty lists for parent and child surfaces
    parent_polygon = []
    child_polygons = []

    # Compare each surface with all other surfaces
    for i, _polygon in enumerate(_polygons):
        is_inside_any = False

        for j, other_polygon in enumerate(_polygons):
            if i == j:
                continue

            if _polygon.is_polygon_inside(other_polygon):
                is_inside_any = True
                break

        # If the surface is inside any other surface, add it to child_surfaces
        # Otherwise, it is a parent surface
        if is_inside_any:
            parent_polygon.append(_polygon)
        else:
            child_polygons.append(_polygon)

    return parent_polygon, child_polygons





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

    # -------------------------------------------------------------------------
    # -- Merge all the new Polygon2Ds together.
    merged_polygons = Polygon2D.boolean_union_all(polygons_in_ref_space, _tolerance)

    
    # ------------------------------------------------------------------------- 
    # -- Create new Face3D and HB-Faces from the Polygon2Ds
    faces = []
    if len(merged_polygons) == 1:
        # -- Create new faces for the merged Polygon2Ds
        face3ds = [_create_new_Face3D(p, reference_plane, reference_face) for p in merged_polygons]
        faces = [_create_new_HB_Face(f3d, reference_face) for f3d in face3ds]
    elif len(merged_polygons) > 1:
        # -- It may mean that there are 'holes' in a surface? So try and find 
        # -- the parent and any child surfaces.
        
        parent_polygon, child_polygons = find_parent_and_child_polygons(merged_polygons)
        
        # -- Check the results
        if len(parent_polygon) != 1:
            # -- Something went wrong, give up.
            _faces.append(reference_face)
            return _faces
 
        # -- If only 1 parent, lets make some Face3Ds and Faces
        parent_face_3d = [_create_new_Face3D(p, reference_plane, reference_face) for p in parent_polygon]
        child_face_3ds = [_create_new_Face3D(p, reference_plane, reference_face) for p in child_polygons]
        face_3ds = [Face3D.from_punched_geometry(parent_face_3d[0], child_face_3ds)]
        faces = [_create_new_HB_Face(f3d, reference_face) for f3d in face_3ds]
    

    # -------------------------------------------------------------------------
    # -- Add the apertures back in
    faces_with_apertures_ = []
    for _face in faces:
        _check_and_add_sub_face(_face, apertures, _tolerance, _angle_tolerance)
        faces_with_apertures_.append(_face)

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

I’m sure it could use a lot of cleaning up, but seems good enough for now.

best
@edpmay
merge_example.gh (421.2 KB)

6 Likes