In case it’s useful to anyone, here’s what I ended up with. It’s originally based on the “curve to tube” tool in bonus tools, though not much is left (stripped almost everything I don’t need away). “pointOnCurveInfo” is where the actual normals wrangling begins (which takes as much code as creating the tube in the first place).
import pymel.core as pm
def go(curve):
# Create the profile curve.
profile_curve = pm.circle(
normal=(0,1,0), radius=1, degree=3,
constructionHistory=True,
sections=8,
name='profileCurve#')[0]
profile_curve.attr('visibility').set(False)
# Extrude the curve.
extruded_surface, extrude_node = pm.extrude(profile_curve, curve,
constructionHistory=True,
range=0, polygon=1, extrudeType=2,
useComponentPivot=1, # component pivot
fixedPath=True, useProfileNormal=True,
reverseSurfaceIfPathReversed=True,
name='tube#')
extruded_surface = extruded_surface.getShape()
# Configure the nurbsTesselate node.
nurbs_tessellate = pm.listConnections(extrude_node)[0]
nurbs_tessellate.attr('format').set(2)
nurbs_tessellate.attr('uType').set(1) # uniform
nurbs_tessellate.attr('polygonType').set(1)
extruded_surface.addAttr('widthDivisions', min=4, at='long', defaultValue=5)
extruded_surface.setAttr('widthDivisions',e=1, k=1)
extruded_surface.attr('widthDivisions').connect(nurbs_tessellate.attr('uNumber'))
extruded_surface.addAttr('lengthDivisions', min=1, at='long', defaultValue=7)
extruded_surface.setAttr('lengthDivisions', e=1, k=1)
extruded_surface.attr('lengthDivisions').connect(nurbs_tessellate.attr('vNumber'))
extruded_surface.addAttr('lengthDivisionSpacing', at='enum', enumName='uniform=1:non-uniform=2', dv=1)
extruded_surface.setAttr('lengthDivisionSpacing', e=1, k=1)
extruded_surface.attr('lengthDivisionSpacing').connect(nurbs_tessellate.attr('vType'))
# The extrude node often creates flipped normals, and it's not clear when this
# happens. Work around this by looking at the normal at the mesh at the start of
# the curve, and enabling a polyNormal node only if it appears to be flipped.
#
# Find the position at the start of the curve.
point_info = pm.createNode('pointOnCurveInfo')
point_info.attr('parameter').set(0)
curve.getShape().attr('worldSpace[0]').connect(point_info.attr('inputCurve'))
# Find the closest point on the mesh we just generated to that point. Connect
# directly to the nurbsTesselate output, not to the output mesh.
point_on_mesh = pm.createNode('closestPointOnMesh')
point_info.attr('position').connect(point_on_mesh.attr('inPosition'))
nurbs_tessellate.attr('outputPolygon').connect(point_on_mesh.attr('inMesh'))
# Subtract (curve point) - (closest mesh point) to get a vector from the mesh
# towards the center.
vector_towards_center = pm.createNode('plusMinusAverage', n='vectorTowardsCenter')
vector_towards_center.attr('operation').set(2) # subtract
point_on_mesh.attr('inPosition').connect(vector_towards_center.attr('input3D[0]')) # curve point
point_on_mesh.attr('position').connect(vector_towards_center.attr('input3D[1]')) # mesh point
# Find the angle between the vectors (mesh -> center point) and (mesh -> normal).
angle_between = pm.createNode('angleBetween', name='normalAngle')
vector_towards_center.attr('output3D').connect(angle_between.attr('vector1'))
point_on_mesh.attr('result').attr('normal').connect(angle_between.attr('vector2'))
# If the normals are correct, the angle should be near 180. If they're reversed, it'll be
# near 0 instead. Create a condition node to check this.
condition = pm.createNode('condition', name='checkNormalAngle')
angle_between.attr('angle').connect(condition.attr('firstTerm'))
condition.attr('secondTerm').set(90)
condition.attr('operation').set(2)
# Create a polyNormal node to reverse the normals.
reverse_normals = pm.polyNormal(extruded_surface)[0]
output_mesh = reverse_normals.attr('output').listConnections(p=True)[0].node()
# We only want to reverse the normals if they're flipped. Connect the condition node to
# the polyNormal node and only enable it if needed.
condition.attr('colorIfTrue').set((1,0,0))
condition.attr('colorIfFalse').set((0,0,0))
condition.attr('outColorR').connect(reverse_normals.attr('nodeState'))
return output_mesh