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 ![]()


