Controlled Brep Unrolling for Production

Hi,

I’m currently working on a custom unroll script in Python for Grasshopper, intended for production data generation, specifically to create milling files for panels.

The usual unroll components like UnrollSrf or plugin-based solutions haven’t been helpful, because they don’t provide enough control over the output. For production purposes, it’s essential to have a clear and reliable mapping of faces and edges, which standard tools don’t maintain.

Has anyone here worked on something similar?

The logic of my script is as follows:
First, it processes all faces that are fully manifold, meaning they only have shared edges with adjacent faces. Placing these faces in 2D should be straightforward, since there’s no ambiguity about which edge to use for alignment—they can be laid out without complex decisions.

After that, the script handles the remaining faces, including those with partially open edges, by aligning them to the previously placed geometry.

This workflow initially worked well, but after I changed the Brep input, it started to break. Some faces are no longer unrolled correctly or at all, and I’m trying to understand why.

If anyone has experience with controlled Brep unrolling for fabrication workflows, or ideas on how to approach this differently, I’d appreciate your input.

I’m happy to share the script if someone is interested.

Thanks!

# Rhino Brep 2D Unfold Script (RhinoCommon Python)

import Rhino
import scriptcontext as sc
import rhinoscriptsyntax as rs
import math
import System.Drawing
from collections import deque

debug_log = []

# Funktion um Prio 1 Faces zu identifizieren
def is_prio1(face):
    for loop in face.Loops:
        for trim in loop.Trims:
            edge = trim.Edge
            if edge is None:
                return False
            if len(edge.AdjacentFaces()) < 2:
                return False
    return True

# Funktion um benachbarte Faces über Kanten zu finden
def get_adjacent_faces_via_edges(face, brep):
    adjacent = set()
    for loop in face.Loops:
        for trim in loop.Trims:
            edge = trim.Edge
            if edge is None:
                continue
            for fi in edge.AdjacentFaces():
                if fi != face.FaceIndex:
                    adjacent.add((fi, edge))
    return list(adjacent)

# Funktion um den Normalenvektor einer Face zu bekommen
def get_face_normal(face):
    success, plane = face.TryGetPlane()
    if success:
        return plane.Normal
    else:
        return None

if 'edge_mapping_unrolled_to_original' not in globals():
    edge_mapping_unrolled_to_original = dict()

reference_normal = None  # globale Referenznormalenrichtung


def transform_face(prev_face, prev_xform, curr_face, edge, edge_mapping=None):
    try:
        global reference_normal

        pt0 = Rhino.Geometry.Point3d(edge.PointAtStart)
        pt1 = Rhino.Geometry.Point3d(edge.PointAtEnd)
        pt0.Transform(prev_xform)
        pt1.Transform(prev_xform)

        x_axis = pt1 - pt0
        x_axis.Unitize()
        z_axis = Rhino.Geometry.Vector3d(0, 0, 1)
        y_axis = Rhino.Geometry.Vector3d.CrossProduct(z_axis, x_axis)
        y_axis.Unitize()
        target_plane = Rhino.Geometry.Plane(pt0, x_axis, y_axis)

        normal = curr_face.NormalAt(0.5, 0.5)
        orig_x = edge.PointAtEnd - edge.PointAtStart
        orig_x.Unitize()
        orig_y = Rhino.Geometry.Vector3d.CrossProduct(normal, orig_x)
        orig_y.Unitize()
        source_plane = Rhino.Geometry.Plane(edge.PointAtStart, orig_x, orig_y)

        xform = Rhino.Geometry.Transform.PlaneToPlane(source_plane, target_plane)

        face_brep = curr_face.DuplicateFace(False)
        face_brep.Transform(xform)
        transformed_face = face_brep.Faces[0]

        # --- Hybrid Flip Check ---
        def robust_flip_decision(prev_face, prev_xform, curr_face, edge):
            # Normale Transformation (ohne Flip)
            face_brep_normal, xform_normal = transform_face(prev_face, prev_xform, curr_face, edge)
            
            # Falls Transformation fehlschlägt, abbrechen
            if face_brep_normal is None:
                return None, None

            # Flip-Transformation (gespiegelt)
            center = face_brep_normal.GetBoundingBox(True).Center
            flip = Rhino.Geometry.Transform.Mirror(Rhino.Geometry.Plane(center, Rhino.Geometry.Vector3d(0, 0, 1)))
            
            face_brep_flipped = face_brep_normal.Duplicate()
            face_brep_flipped.Transform(flip)
            xform_flipped = flip * xform_normal

            # Schwerpunktvergleich
            prev_brep = prev_face.DuplicateFace(False)
            prev_brep.Transform(prev_xform)
            prev_center = prev_brep.GetBoundingBox(True).Center

            center_normal = face_brep_normal.GetBoundingBox(True).Center
            center_flipped = face_brep_flipped.GetBoundingBox(True).Center

            dist_normal = prev_center.DistanceTo(center_normal)
            dist_flipped = prev_center.DistanceTo(center_flipped)

            # Größeren Abstand wählen
            if dist_flipped > dist_normal:
                return face_brep_flipped, xform_flipped
            else:
                return face_brep_normal, xform_normal


        if edge_mapping is not None:
            orig_edges = [trim.Edge for loop in curr_face.Loops for trim in loop.Trims if trim.Edge]
            transformed_orig_edges = []
            for orig_edge in orig_edges:
                crv = orig_edge.DuplicateCurve()
                crv.Transform(xform)
                transformed_orig_edges.append((crv, orig_edge))

            for i in range(face_brep.Edges.Count):
                e = face_brep.Edges[i]
                pt1_new = e.PointAtStart
                pt2_new = e.PointAtEnd
                for crv_transformed, orig_edge in transformed_orig_edges:
                    pt1_orig = crv_transformed.PointAtStart
                    pt2_orig = crv_transformed.PointAtEnd
                    if (pt1_new.DistanceTo(pt1_orig) < 1e-4 and pt2_new.DistanceTo(pt2_orig) < 1e-4) or \
                       (pt1_new.DistanceTo(pt2_orig) < 1e-4 and pt2_new.DistanceTo(pt1_orig) < 1e-4):
                        edge_mapping[e] = orig_edge
                        break

        return face_brep, xform

    except Exception as e:
        print("❌ Fehler in transform_face:", str(e))
        return None, None



# Berechne einheitliches Referenz-Plane basierend auf Normalrichtung
def compute_reference_plane(normal, origin, tol=1e-6):
    world_x = Rhino.Geometry.Vector3d(1, 0, 0)
    world_y = Rhino.Geometry.Vector3d(0, 1, 0)
    world_z = Rhino.Geometry.Vector3d(0, 0, 1)

    if normal.IsParallelTo(world_z, tol) == 1:
        z_axis = normal
        x_axis = world_x
        y_axis = world_y
    elif normal.IsParallelTo(world_y, tol) == 1:
        z_axis = normal
        x_axis = world_x
        y_axis = -world_z
    elif abs(normal.Z) < tol:
        z_axis = world_z
        y_axis = normal
        x_axis = Rhino.Geometry.Vector3d.CrossProduct(z_axis, y_axis)
    else:
        base_axis = world_x if abs(Rhino.Geometry.Vector3d.Multiply(world_x, normal)) < abs(Rhino.Geometry.Vector3d.Multiply(world_y, normal)) else world_y
        x_axis = Rhino.Geometry.Vector3d.CrossProduct(normal, base_axis)
        x_axis.Unitize()
        y_axis = Rhino.Geometry.Vector3d.CrossProduct(normal, x_axis)
        z_axis = normal

    return Rhino.Geometry.Plane(origin, x_axis, y_axis)


def find_opposite_edge(open_edge, edges, tol=1e-6):
    open_pts = [open_edge.PointAtStart, open_edge.PointAtEnd]

    def is_same_point(pt1, pt2):
        return pt1.DistanceTo(pt2) < tol

    for edge in edges:
        if edge == open_edge:
            continue
        e_pts = [edge.PointAtStart, edge.PointAtEnd]
        if all(not any(is_same_point(ep, op) for op in open_pts) for ep in e_pts):
            return edge
    return None

# ------------------------- Hauptprogramm -------------------------

if face_srf is None or point_on_face is None or plane_on_xy is None or brep is None:
    a = None
    b = None
    mapped_lichtband = None
else:
    try:
        ref_face = None
        center_ref = Rhino.Geometry.AreaMassProperties.Compute(face_srf).Centroid
        min_dist = float("inf")

        for f in brep.Faces:
            center = Rhino.Geometry.AreaMassProperties.Compute(f).Centroid
            dist = center.DistanceTo(center_ref)
            if dist < min_dist:
                min_dist = dist
                ref_face = f

        if ref_face is None:
            a = None
            b = None
            mapped_lichtband = None
        else:
            success, ref_plane = ref_face.TryGetPlane()
            if not success:
                raise Exception("❌ Referenzfläche hat keine stabile Plane.")
            face_normal = ref_plane.Normal
            print("✅ Referenz-Normale:", face_normal)
            for ni, _ in get_adjacent_faces_via_edges(ref_face, brep):
                neighbor = brep.Faces[ni]
                success_n, neighbor_plane = neighbor.TryGetPlane()
                if success_n:
                    dot = face_normal * neighbor_plane.Normal
                    angle = math.degrees(math.acos(max(min(dot, 1.0), -1.0)))
                    print(f"🧭 Winkel zu Nachbar {ni}: {round(angle, 2)}°")
            reference_plane = compute_reference_plane(face_normal, point_on_face)

            face_transforms = {}

            face_transforms = {ref_face.FaceIndex: Rhino.Geometry.Transform.PlaneToPlane(reference_plane, plane_on_xy)}

            ref_geo = ref_face.DuplicateFace(False)
            ref_geo.Transform(Rhino.Geometry.Transform.PlaneToPlane(reference_plane, plane_on_xy))
            ref_geo_plane = None
            success, ref_geo_plane = ref_geo.Faces[0].TryGetPlane()
            if success:
                dot = face_normal * ref_geo_plane.Normal
                if dot < 0:
                    center = ref_geo.GetBoundingBox(True).Center
                    flip = Rhino.Geometry.Transform.Mirror(Rhino.Geometry.Plane(center, Rhino.Geometry.Vector3d(0, 0, 1)))
                    ref_geo.Transform(flip)
                    face_transforms[ref_face.FaceIndex] = flip * face_transforms[ref_face.FaceIndex]
                    print("🔄 Ref-Fläche wurde geflippt")

            result_faces = [ref_geo]
            visited_faces = set([ref_face.FaceIndex])
            face_transforms = {ref_face.FaceIndex: Rhino.Geometry.Transform.PlaneToPlane(reference_plane, plane_on_xy)}
            # ---------- Traversal PHASE 1: Nur Prio1 Faces ----------

            queue = deque()
            queue.append((ref_face, face_transforms[ref_face.FaceIndex]))
            visited_faces = set([ref_face.FaceIndex])

            while queue:
                curr_face, curr_xform = queue.popleft()
                debug_log.append(f"🌀 Bearbeite Face {curr_face.FaceIndex}")

                neighbors = get_adjacent_faces_via_edges(curr_face, brep)

                for ni, edge in neighbors:
                    if ni in visited_faces:
                        debug_log.append(f"🔁 Face {ni} bereits besucht.")
                        continue

                    neighbor = brep.Faces[ni]

                    if not is_prio1(neighbor):
                        debug_log.append(f"⏸️ Nachbar-Face {ni} ist kein Prio1 – wird für Phase 2 vorgemerkt.")
                        continue

                    face_geo, face_xform = transform_face(curr_face, curr_xform, neighbor, edge, edge_mapping_unrolled_to_original)

                    if face_geo is None:
                        debug_log.append(f"⚠️ Transformation von Prio1-Face {ni} fehlgeschlagen.")
                        continue

                    debug_log.append(f"✅ Prio1-Face {ni} erfolgreich transformiert.")

                    result_faces.append(face_geo)
                    face_transforms[ni] = face_xform
                    visited_faces.add(ni)
                    queue.append((neighbor, face_xform))



            # ----- PRIO 2 -----
            for face in brep.Faces:
                if face.FaceIndex in visited_faces:
                    debug_log.append(f"⏩ Face {face.FaceIndex} wurde bereits verarbeitet.")
                    continue

                if face.Loops.Count != 1:
                    debug_log.append(f"❌ Face {face.FaceIndex} hat mehr als 1 Loop – wird übersprungen.")
                    continue

                edges = [trim.Edge for trim in face.Loops[0].Trims if trim.Edge is not None]
                open_edges = [e for e in edges if len(e.AdjacentFaces()) == 1]

                if len(open_edges) != 1:
                    debug_log.append(f"❌ Face {face.FaceIndex} hat {len(open_edges)} offene Kanten – nicht Prio2.")
                    continue

                open_edge = open_edges[0]
                opposite_edge = find_opposite_edge(open_edge, edges)
                if opposite_edge is None:
                    debug_log.append(f"❌ Keine gegenüberliegende Kante gefunden für Face {face.FaceIndex}.")
                    continue

                neighbor_ids = [fid for fid in opposite_edge.AdjacentFaces() if fid != face.FaceIndex and fid in visited_faces]
                if not neighbor_ids:
                    debug_log.append(f"❌ Kein verarbeiteter Nachbar für Face {face.FaceIndex}.")
                    continue

                nface = brep.Faces[neighbor_ids[0]]
                xform = face_transforms[nface.FaceIndex]

                face_geo, face_xform = transform_face(nface, xform, face, opposite_edge, edge_mapping_unrolled_to_original)

                if face_geo is None:
                    debug_log.append(f"⚠️ Prio2-Face {face.FaceIndex} konnte nicht transformiert werden.")
                    continue

                debug_log.append(f"➕ Prio2-Face {face.FaceIndex} erfolgreich ergänzt.")

                result_faces.append(face_geo)
                visited_faces.add(face.FaceIndex)
                face_transforms[face.FaceIndex] = face_xform

            # ----- PRIO 3 -----
            prio3_faces = []
            face_to_neighbors = {}

            for face in brep.Faces:
                if face.FaceIndex in visited_faces:
                    continue

                edges = [trim.Edge for loop in face.Loops for trim in loop.Trims if trim.Edge]
                open_edges = [e for e in edges if len(e.AdjacentFaces()) == 1]
                if len(open_edges) < 2:
                    continue

                prio3_faces.append(face)

                neighbors = set()
                for edge in edges:
                    if len(edge.AdjacentFaces()) < 2:
                        continue
                    for fid in edge.AdjacentFaces():
                        if fid != face.FaceIndex and fid in visited_faces:
                            neighbors.add(fid)
                face_to_neighbors[face.FaceIndex] = neighbors

            for i, f1 in enumerate(prio3_faces):
                for f2 in prio3_faces[i+1:]:
                    nid1 = face_to_neighbors.get(f1.FaceIndex, set())
                    nid2 = face_to_neighbors.get(f2.FaceIndex, set())
                    common = nid1 & nid2
                    if not common:
                        continue

                    anchor_id = list(common)[0]
                    anchor_face = brep.Faces[anchor_id]
                    xform = face_transforms[anchor_id]

                    for face in [f1, f2]:
                        if face.FaceIndex in visited_faces:
                            continue
                        shared_edge = None
                        for loop in face.Loops:
                            for trim in loop.Trims:
                                edge = trim.Edge
                                if edge and anchor_id in edge.AdjacentFaces():
                                    shared_edge = edge
                                    break
                            if shared_edge:
                                break
                        if shared_edge:
                            face_geo, face_xform = transform_face(anchor_face, xform, face, shared_edge, edge_mapping_unrolled_to_original)
                            print("→ Face transformiert.")
                            result_faces.append(face_geo)
                            face_transforms[face.FaceIndex] = face_xform
                            visited_faces.add(face.FaceIndex)

            # ----- CURVE MAPPING -----
            mapped_curves = []
            if lichtband:
                if not isinstance(lichtband, list):
                    lichtband = [lichtband]

                is_gh = sc.doc != Rhino.RhinoDoc.ActiveDoc

                for idx, crv in enumerate(lichtband):
                    mp = crv.PointAtNormalizedLength(0.5)
                    matched = False
                    for face in brep.Faces:
                        rc, u, v = face.ClosestPoint(mp)
                        if not rc:
                            continue
                        if not face.IsPointOnFace(u, v):
                            continue
                        if face.FaceIndex not in face_transforms:
                            continue
                        xform = face_transforms[face.FaceIndex]
                        crv_copy = crv.DuplicateCurve()
                        crv_copy.Transform(xform)
                        mapped_curves.append(crv_copy)

                        if not is_gh:
                            layer_name = "Mapped_Lichtband"
                            layer_index = sc.doc.Layers.FindByFullPath(layer_name, True)
                            if layer_index < 0:
                                layer_index = sc.doc.Layers.Add(layer_name, System.Drawing.Color.Orange)
                            attr = Rhino.DocObjects.ObjectAttributes()
                            attr.LayerIndex = layer_index
                            sc.doc.Objects.AddCurve(crv_copy, attr)
                            sc.doc.Objects.AddTextDot(str(idx), crv_copy.PointAtNormalizedLength(0.5), attr)
                        matched = True
                        break

            mapped_surfaces = result_faces
            mapped_lichtband = mapped_curves
            winkel_in_grad = winkel_in_grad
            

        # --- Zusatzfunktion: Kontur & innere Kanten aus mapped_surfaces ---

        kontur = None
        innere_kanten = None

        

        def edge_key(p1, p2, tol):
            return tuple(sorted((
                (round(p1.X / tol), round(p1.Y / tol), round(p1.Z / tol)),
                (round(p2.X / tol), round(p2.Y / tol), round(p2.Z / tol))
            )))

        try:
            if mapped_surfaces:
                tol = sc.doc.ModelAbsoluteTolerance if sc.doc else 1e-6

                # --- Kontur mit MergeCoplanarFaces ---
                breps_for_kontur = []
                for s in mapped_surfaces:
                    if isinstance(s, list):
                        breps_for_kontur.extend(s)
                    else:
                        breps_for_kontur.append(s)

                joined_kontur = Rhino.Geometry.Brep.JoinBreps(breps_for_kontur, tol)
                if joined_kontur and len(joined_kontur) == 1:
                    brep_k = joined_kontur[0]
                    Rhino.Geometry.Brep.MergeCoplanarFaces(brep_k, tol)

                    naked_edges = [edge.DuplicateCurve() for edge in brep_k.Edges
                                if edge.Valence == Rhino.Geometry.EdgeAdjacency.Naked]
                    joined_curves = Rhino.Geometry.Curve.JoinCurves(naked_edges, tol)
                    kontur = joined_curves if joined_curves else naked_edges

                # --- Innere Kanten ohne MergeCoplanarFaces ---
                breps_for_innen = []
                for surf in mapped_surfaces:
                    if isinstance(surf, Rhino.Geometry.Brep):
                        breps_for_innen.append(surf)
                    elif isinstance(surf, Rhino.Geometry.Surface):
                        b = Rhino.Geometry.Brep.CreateFromSurface(surf)
                        if b: breps_for_innen.append(b)

                joined_innen = Rhino.Geometry.Brep.JoinBreps(breps_for_innen, tol)
                if joined_innen and len(joined_innen) == 1:
                    brep_i = joined_innen[0]

                    seen = set()
                    innere_kanten = []
                    for edge in brep_i.Edges:
                        if edge.Valence != Rhino.Geometry.EdgeAdjacency.Naked:
                            key = edge_key(edge.PointAtStart, edge.PointAtEnd, tol)
                            if key not in seen:
                                seen.add(key)
                                innere_kanten.append(edge.DuplicateCurve())

                # Zusatzfunktion: Winkel zwischen den Normals angrenzender Faces pro Kante berechnen
                if edge_mapping_unrolled_to_original is not None:
                    print("🧭 Anzahl gemappter Kanten:", len(edge_mapping_unrolled_to_original))
                
                winkel_in_grad = []

                print("🔍 DEBUG: Starte Winkelberechnung")
                print("📌 Anzahl innere_kanten:", len(innere_kanten) if innere_kanten else 0)
                print("📌 Mapping-Größe:", len(edge_mapping_unrolled_to_original))

                for k in innere_kanten or []:
                    orig = edge_mapping_unrolled_to_original.get(k, None)
                    if not orig:
                        print("⚠️ Kein Mapping gefunden für Kante:", k)
                        continue
                    faces = orig.AdjacentFaces()
                    if len(faces) != 2:
                        print("⛔ Weniger als 2 angrenzende Faces:", faces)
                        continue
                    f1, f2 = brep.Faces[faces[0]], brep.Faces[faces[1]]
                    n1, n2 = get_face_normal(f1), get_face_normal(f2)
                    if not n1 or not n2:
                        print("❌ Keine Normale für Face:", f1.FaceIndex, f2.FaceIndex)
                    else:
                        angle_rad = Rhino.Geometry.Vector3d.VectorAngle(n1, n2)
                        angle_deg = math.degrees(angle_rad)
                        winkel_in_grad.append(angle_deg)
                        print("✅ Winkel:", round(angle_deg, 2), "Grad")
                
                try:
                    tol = sc.doc.ModelAbsoluteTolerance if sc.doc else 1e-6
                    if innere_kanten:
                        print("Kanten für Winkelvergleich:", len(innere_kanten))
                    for kante in innere_kanten or []:
                        original_edge = None
                        for k, v in edge_mapping_unrolled_to_original.items():
                            if k.PointAtStart.DistanceTo(kante.PointAtStart) < 1e-6 and k.PointAtEnd.DistanceTo(kante.PointAtEnd) < 1e-6:
                                original_edge = v
                                break
                        if not original_edge:
                            continue
                        face_ids = original_edge.AdjacentFaces()
                        if len(face_ids) != 2:
                            continue

                        face1 = brep.Faces[face_ids[0]]
                        face2 = brep.Faces[face_ids[1]]

                        n1 = get_face_normal(face1)
                        n2 = get_face_normal(face2)
                        if not n1 or not n2:
                            continue

                        angle_rad = Rhino.Geometry.Vector3d.VectorAngle(n1, n2)
                        angle_deg = math.degrees(angle_rad)
                        print("Berechne Winkel:", angle_deg)
                        winkel_in_grad.append(angle_deg)
                
                        

                except Exception as e:
                    debug_log.append("FEHLER beim Winkel-Berechnen: {}".format(str(e)))

        except Exception as e:
            debug_log.append("FEHLER beim Winkel-Berechnen: {}".format(str(e)))






    except Exception as e:
        print("FEHLER im Unroll-Skript:", str(e))
        a = None
        b = None
        mapped_lichtband = None

print("Anzahl innere_kanten:", len(innere_kanten) if innere_kanten else 0)
print("Mapping-Größe:", len(edge_mapping_unrolled_to_original))
print("\n=== DEBUG LOG ===")
for entry in debug_log:
    print(entry)
print("=== ENDE DEBUG LOG ===\n")
winkel_in_grad = winkel_in_grad

Excuse my German comments :melting_face:

Check out this c# script

check out the pod plugin it have more control over the unrollbrep
unjoin the edges based on index or length
unroll brep from plane to plane

Thanks! Just to be sure - do you mean pOd_GH_Button by Levin?
I thought that was mainly UI tools, not unroll features. Or is there another pOd component for Brep unrolling?

@Til_Frank Yes same there is full other than ui there is full plugin which has many good tools