Python Scripts for Turning Hair Cards into Curves in Maya
Summary
So, I recently had to make some realistic hair for a character, but the only thing I had to work with was this old asset full of polygon hair cards. And since I wanted to bring it into MetaHuman, I needed to convert those cards into curves for the grooming system.
Turns out, you can’t just drag and drop cards into MetaHuman. Nope — you basically have to rebuild the hair in XGen, which I really didn’t want to do. I mean, I love Maya... but opening XGen and doing it all manually? Hard pass.
Instead, I went the "lazy-but-smart" route. I didn’t write everything from scratch — I just started digging around online finding other artists that run into the same problem, troubleshooting threads, and with some personal scripting knowledge (good enough to work my way around it) and tools that help , I hacked together a solution that actually worked.
Now that I’ve got it working, I figured I’d share it with everyone — because honestly, we’re all trying to save time and keep things moving. And if this saves even one artist from opening XGen, it's a win.
If you’ve worked with hair cards in Maya, you know the struggle: turning those flat polygon strips into clean curves for XGen or other grooming tools can be tedious — especially when you’ve got dozens (or hundreds) of them. So, I put together a collection of Python scripts that make this process way easier.
These tools help you automatically generate, organize, and tweak curves from polygon hair cards. Whether your cards are clean with good UVs or a bit of a mess without a center edgeloop, there’s something here to get the job done.
Here’s everything you need to turn hair cards into curves with just a few scripts and way less stress.
Let’s walk through it :)
1. Turn Hair Cards Into Curves Using UVs
Got cards with vertical UVs and a center edgeloop? Perfect.
This script grabs the centerline of each card based on the UV layout and creates a simple linear curve right down the middle. Great for generating guides for XGen.
Just select your hair cards and run it — it’ll build curves and group them for you automatically.
import maya.cmds as cmds import maya.api.OpenMaya as om from collections import defaultdict def get_uv_spine_vertices(obj, uv_bin_count=20): sel = om.MSelectionList() sel.add(obj) dagPath = sel.getDagPath(0) meshFn = om.MFnMesh(dagPath) # Get UVs u_array, v_array = meshFn.getUVs() uv_counts, uv_ids = meshFn.getAssignedUVs() # Group vertices by V bins face_iter = om.MItMeshFaceVertex(dagPath) uv_bins = defaultdict(list) while not face_iter.isDone(): vtx_id = face_iter.vertexId() uv_id = face_iter.getUVIndex() u, v = u_array[uv_id], v_array[uv_id] # Get world position of this vertex pos = face_iter.position(om.MSpace.kWorld) pos = (pos.x, pos.y, pos.z) # Bin by V coordinate bin_idx = int(v * uv_bin_count) uv_bins[bin_idx].append((u, pos)) face_iter.next() # Sort bins by V (root to tip) and pick center of each U center_line = [] for bin_idx in sorted(uv_bins.keys()): u_pos_list = sorted(uv_bins[bin_idx], key=lambda x: x[0]) # sort by U if u_pos_list: mid = len(u_pos_list) // 2 center_line.append(u_pos_list[mid][1]) # get world position return center_line def create_curve_from_points(points, name): if len(points) < 2: return None return cmds.curve(p=points, degree=1, name=name + "_curve") def convert_cards_to_curves_by_uv(): selected_objs = cmds.ls(selection=True, long=True) if not selected_objs: cmds.warning("Please select one or more polygon hair cards.") return curve_group = cmds.group(empty=True, name="UVHairCardCurves_grp") for obj in selected_objs: points = get_uv_spine_vertices(obj) if not points or len(points) < 2: cmds.warning(f"Could not extract UV spine from: {obj}") continue curve = create_curve_from_points(points, obj) if curve: cmds.parent(curve, curve_group) cmds.select(curve_group) print("✅ Curves created from UV center lines. Ready for XGen guides.") # Run it convert_cards_to_curves_by_uv()
2. Offset Curves Up and Down to Add Volume
Want to give your curves more shape or simulate layers of hair? This one creates offset curves above and below the original, helping you fill the space between cards or build more natural-looking hair bundles.
Think of it like “inflating” the curve layout vertically.
import maya.cmds as cmds import maya.api.OpenMaya as om from collections import defaultdict def get_uv_spine_vertices(obj, uv_bin_count=20): sel = om.MSelectionList() sel.add(obj) dagPath = sel.getDagPath(0) meshFn = om.MFnMesh(dagPath) # Get UVs u_array, v_array = meshFn.getUVs() uv_counts, uv_ids = meshFn.getAssignedUVs() # Group vertices by V bins face_iter = om.MItMeshFaceVertex(dagPath) uv_bins = defaultdict(list) while not face_iter.isDone(): vtx_id = face_iter.vertexId() uv_id = face_iter.getUVIndex() u, v = u_array[uv_id], v_array[uv_id] # Get world position of this vertex pos = face_iter.position(om.MSpace.kWorld) pos = (pos.x, pos.y, pos.z) # Bin by V coordinate bin_idx = int(v * uv_bin_count) uv_bins[bin_idx].append((u, pos)) face_iter.next() # Sort bins by V (root to tip) and pick center of each U center_line = [] for bin_idx in sorted(uv_bins.keys()): u_pos_list = sorted(uv_bins[bin_idx], key=lambda x: x[0]) # sort by U if u_pos_list: mid = len(u_pos_list) // 2 center_line.append(u_pos_list[mid][1]) # get world position return center_line def create_curve_from_points(points, name): if len(points) < 2: return None return cmds.curve(p=points, degree=1, name=name + "_curve") def convert_cards_to_curves_by_uv(): selected_objs = cmds.ls(selection=True, long=True) if not selected_objs: cmds.warning("Please select one or more polygon hair cards.") return curve_group = cmds.group(empty=True, name="UVHairCardCurves_grp") for obj in selected_objs: points = get_uv_spine_vertices(obj) if not points or len(points) < 2: cmds.warning(f"Could not extract UV spine from: {obj}") continue curve = create_curve_from_points(points, obj) if curve: cmds.parent(curve, curve_group) cmds.select(curve_group) print("✅ Curves created from UV center lines. Ready for XGen guides.") # Run it convert_cards_to_curves_by_uv()
3. Flip Curve Direction (Root to Tip)
Sometimes Maya creates curves in the wrong direction. You need the root at the bottom and the tip up top — not the other way around.
This quick script inverts the order of CVs on any selected curve so your grooming tools (like XGen) behave correctly.
No more upside down points!
import maya.cmds as cmds def invert_curve_points(): # Get the selected curves selected = cmds.ls(selection=True, dag=True, type="nurbsCurve", long=True) if not selected: cmds.warning("No curves selected. Please select at least one NURBS curve.") return result_count = 0 for curve in selected: # Get the curve shape's parent transform transform = cmds.listRelatives(curve, parent=True, fullPath=True)[0] # Get the curve's degree degree = cmds.getAttr(curve + ".degree") # Get the curve's form (open or closed) form = cmds.getAttr(curve + ".form") # Get all CV positions spans = cmds.getAttr(curve + ".spans") num_cvs = spans + degree # If the curve is closed (periodic), adjust the number of CVs if form == 2: # 2 means closed/periodic curve num_cvs = spans cv_positions = [] for i in range(num_cvs): pos = cmds.getAttr(transform + ".cv[" + str(i) + "]")[0] cv_positions.append(pos) # Reverse the CV positions cv_positions.reverse() # Apply the reversed positions to the curve for i in range(num_cvs): cmds.setAttr(transform + ".cv[" + str(i) + "]", cv_positions[i][0], cv_positions[i][1], cv_positions[i][2], type="double3") result_count += 1 print("Successfully inverted point order for {} curves.".format(result_count)) # Execute the function invert_curve_points()
4. Don’t Have a Middle Edgeloop? No Problem.
If your hair cards are missing that center edge (or just aren’t built super clean), this tool will calculate the center of each face and connect them to form a curve.
It’s a lifesaver when working with scanned or imported geometry that doesn’t follow the usual rules.
Note: This will only work with clean square quads cards
import maya.cmds as cmds import maya.api.OpenMaya as om def get_face_centers(mesh_name): sel = om.MSelectionList() sel.add(mesh_name) dagPath = sel.getDagPath(0) meshFn = om.MFnMesh(dagPath) face_centers = [] for i in range(meshFn.numPolygons): verts = meshFn.getPolygonVertices(i) positions = [om.MVector(meshFn.getPoint(v, om.MSpace.kWorld)) for v in verts] avg_pos = sum(positions, om.MVector(0, 0, 0)) / len(positions) face_centers.append((avg_pos.x, avg_pos.y, avg_pos.z)) # Sort by Y or Z depending on how your cards are oriented face_centers.sort(key=lambda p: p[1]) # sort by height (Y) return face_centers def create_curve_from_centers(centers, name="card_center_curve"): if len(centers) < 2: cmds.warning("Not enough points to create a curve.") return None # Check if the curve creation works by using the 'linear' method (degree=1) try: curve = cmds.curve(p=centers, d=1, name=name) # Linear curve (degree=1) print(f"✅ Created linear curve: {curve}") except Exception as e: print(f"❌ Failed to create linear curve: {e}") return None return curve def main(): sel = cmds.ls(selection=True, long=True, type="transform") if not sel: cmds.warning("Select at least one card mesh.") return for obj in sel: centers = get_face_centers(obj) curve = create_curve_from_centers(centers, name=obj + "_centerCurve") if curve: # Check if curve is valid (it should no longer be red) if cmds.objExists(curve): print(f"✅ Successfully created curve: {curve}") else: print(f"❌ Curve creation failed: {curve}") main()
5. Horizontal Curve Offsets (Left & Right)
This one creates two side curves for each guide — one to the left and one to the right — but keeps everything flat in the horizontal (XZ) plane. Super useful if you want to give your hair system some spread or create mirrored offsets.
Just select the curves you want to duplicate with an offset and run the script.
Bonus: all new curves get neatly grouped under OffsetCurves_Horizontal_grp
import maya.cmds as cmds import math def flat_perpendicular(p1, p2, offset): """Compute horizontal (XZ) perpendicular offset vector.""" # Direction vector (flattened to XZ) dir_vec = [p2[0] - p1[0], 0, p2[2] - p1[2]] length = math.sqrt(dir_vec[0]**2 + dir_vec[2]**2) if length == 0: return p1 # Can't offset a zero-length segment # Perpendicular in XZ plane (swap X/Z and invert one) perp = [-dir_vec[2] / length * offset, 0, dir_vec[0] / length * offset] return [p1[0] + perp[0], p1[1], p1[2] + perp[2]] def offset_curve_flat(curve, offset_amount, suffix): """Create a new curve offset left/right in XZ plane.""" cvs = cmds.ls(curve + ".cv[*]", flatten=True) points = [cmds.pointPosition(cv, world=True) for cv in cvs] new_points = [] for i in range(len(points)): if i == len(points) - 1: offset_pt = flat_perpendicular(points[i-1], points[i], offset_amount) else: offset_pt = flat_perpendicular(points[i], points[i+1], offset_amount) new_points.append(offset_pt) return cmds.curve(p=new_points, degree=1, name=curve + suffix) def create_clean_horizontal_offset_curves(offset_distance=0.15): """Offset each selected curve left and right (horizontal plane only).""" curves = cmds.ls(selection=True, long=True) if not curves: cmds.warning("Select one or more NURBS curves.") return group = cmds.group(empty=True, name="OffsetCurves_Horizontal_grp") for curve in curves: shapes = cmds.listRelatives(curve, shapes=True, fullPath=True) or [] if not shapes or cmds.objectType(shapes[0]) != 'nurbsCurve': print(f"Skipping {curve}: Not a NURBS curve.") continue left = offset_curve_flat(curve, offset_distance, "_L") right = offset_curve_flat(curve, -offset_distance, "_R") for c in [left, right]: if c: cmds.parent(c, group) cmds.select(group) print("✅ 2 offset curves (L/R) created per selected curve -- horizontally only.") # Run the tool create_clean_horizontal_offset_curves(offset_distance=0.15)
6. Keep a Percentage of Curves (and Clean the Rest)
Too many curves? No worries. This tool lets you keep a random percentage of selected curves and deletes the rest. Handy if you're looking to reduce density without making things feel too uniform.
A quick dialog lets you type in the exact percentage you want to keep. (full control)
import maya.cmds as cmds import random def delete_percentage_curves(): # Get the selected curves selected = cmds.ls(selection=True, type="transform", long=True) # Filter to keep only curve transforms curves = [] for obj in selected: shapes = cmds.listRelatives(obj, shapes=True, fullPath=True) or [] if any(cmds.objectType(shape, isType="nurbsCurve") for shape in shapes): curves.append(obj) if not curves: cmds.warning("No curves selected. Please select at least one NURBS curve.") return # Prompt user for percentage to keep result = cmds.promptDialog( title='Keep Percentage', message='Enter percentage of curves to keep (1-100):', button=['OK', 'Cancel'], defaultButton='OK', cancelButton='Cancel', dismissString='Cancel' ) if result != 'OK': return # Get the percentage value try: keep_percentage = float(cmds.promptDialog(query=True, text=True)) if keep_percentage < 1 or keep_percentage > 100: cmds.warning("Please enter a value between 1 and 100.") return except ValueError: cmds.warning("Please enter a valid number.") return # Calculate how many curves to keep total_curves = len(curves) keep_count = int(round(total_curves * keep_percentage / 100.0)) # Ensure at least one curve is kept (if any were selected) keep_count = max(1, min(keep_count, total_curves)) # Randomly select curves to keep curves_to_keep = random.sample(curves, keep_count) # Delete the rest curves_to_delete = [curve for curve in curves if curve not in curves_to_keep] if curves_to_delete: cmds.delete(curves_to_delete) # Provide feedback delete_count = len(curves_to_delete) cmds.select(curves_to_keep) print("Deleted {} of {} curves ({}%).".format( delete_count, total_curves, round(delete_count / float(total_curves) * 100, 1) )) print("Kept {} curves ({}%).".format( keep_count, round(keep_count / float(total_curves) * 100, 1) )) # Execute the function delete_percentage_curves()