# Procedural Tube

So, here’s my ProceduralTube class. I use it to make trees and such. There’s a rope in Panda3d which is likely more performant. However, this one can do a variable radius at each join. Also, it’s useful when you have known, fixed, points.

You can run it as main. You need to replace the ‘wood.png’ at the bottom with your own path. You might need to scale the UV as well, a few lines below ‘wood.png’.

You pass ProceduralTube.makeTube() a list of points (`pts`) and a list an equal number of radii (`radii`), one for the radius at each point. Alternatively, you can pass a constant for a uniform radius along the whole tube. The `radialSlices` parameter controls how many slices run from one end to the other e.g. 4 makes an extruded square, and 8 makes an extruded stop sign. Of course the opposite cuts are cross-section at each point passed in the first parameter. The `tangent` parameter keeps each of the radial slices lined up from joint to joint. If you don’t pass this, it will be generated. The `stitch` parameter makes each join merge with the previous joint. I don’t know why you wouldn’t want this on, but you can have gaps if you want gaps.

``````from panda3d.core import GeomVertexFormat, GeomVertexData
from panda3d.core import Geom, GeomTriangles, GeomVertexWriter, GeomVertexRewriter
from panda3d.core import LVector3, Quat, Mat3
class ProceduralTube(object):
triangleCount = 0
vertexCount = 0

@classmethod
def makeTube(cls, pts, radii, radialSlices=4, tangent=None, stitch=True):
"""
:param pts: Iter of 3D points the tube passes through
:param radii: Iter of radius at each point.
:param radialSlices: Slices along major axis.
:param tangent: Normal to major axis, if given.
:param stitch: Flag to stitch each segment together or not.
"""

# format = GeomVertexFormat.getV3n3cpt2()
format = GeomVertexFormat.getV3n3t2()
vdata = GeomVertexData('tube', format, Geom.UHDynamic)

vertex = GeomVertexWriter(vdata, 'vertex')
normalWrtr = GeomVertexWriter(vdata, 'normal')
texcoord = GeomVertexWriter(vdata, 'texcoord')
axis = pts[1] - pts[0]
axis.normalize()
if tangent is None:  # the tangent keeps everything aligned through out!!!!!!!!!
# make a shallow attempt to create a tangent through the y axis
# we can't pick y directly (the tube isn't likely orthogonal), so try to cross off of x
if axis.y != 0.0 or 0.0 != axis.z:
tangent = LVector3.unitX()
elif axis.y != 0.0 or 0.0 != axis.x:
tangent = LVector3.unitZ()
else:
tangent = LVector3.unitY()
tangent = axis.cross(tangent)

if tangent.normalize() and tangent == LVector3.zero():
raise ValueError("Tangent cannot be zero vector.")

tube = Geom(vdata)
stitchLoop = []
triCount = vertexCount = 0
faceLoopCount = 0
rotMat = Mat3()
lastTotalLen = [0.0, ]*(radialSlices + 1)
lastAxis = LVector3(0.0)
for edgeLoop in range(0, len(pts) - 1):
if pts[edgeLoop + 1] == pts[edgeLoop] and not __debug__:
continue
elif pts[edgeLoop + 1] == pts[edgeLoop] and __debug__:
raise ValueError("Cannot have consecutive points with same value. {} & {}".format(pts[edgeLoop + 1],
pts[edgeLoop]))
else:
axis = pts[edgeLoop + 1] - pts[edgeLoop]
# print('axis {}'.format(axis))
segLength = axis.length()  # root calculation
axis = axis / segLength  # don't do a 2nd root
# reorient the tangent with each segment using the original tangent
# 1st intermediate vector (orthogonal to our target)
# the old tangent, the new and the current axis will be on the same plane
# but the new will be 90 from the current axis
intermediate = (axis).cross(tangent)
segTangent = intermediate.cross(axis)
segTangent.normalize()  # this may not be necessary. (axis).cross(tangent) is two unit vectors\

# degrees per radial slice
rotationStep = 360.0 / radialSlices
lastTexRadial = 0.0 - (rotationStep / 360.0)
newStitchLoop = []
# print('start: {}'.format(pts[edgeLoop]))
# print('end: {}'.format(pts[edgeLoop + 1]))
for radialCut in range(0, radialSlices + 1):
texStart = lastTotalLen[radialCut]
texEnd = lastTotalLen[radialCut] + segLength
texRadial = (lastTexRadial + rotationStep / 360.0)
lastTexRadial = texRadial
lastTotalLen[radialCut] += segLength
rotMat.setRotateMat(radialCut * rotationStep % 360.0, axis)
normToSave = rotMat.xformVec(segTangent)
normToSave.normalize()  # this will be the norm for both vertices in this radial slice

if not stitch:
try:
next0 = (rotMat.xformVec(segTangent) * radii[edgeLoop]) + pts[edgeLoop]
next1 = (rotMat.xformVec(segTangent) * radii[edgeLoop + 1]) + pts[edgeLoop + 1]
except TypeError:
next0 = (rotMat.xformVec(segTangent) * radii) + pts[edgeLoop]
next1 = (rotMat.xformVec(segTangent) * radii) + pts[edgeLoop + 1]
else:
try:
# if we're stitching loops, put the far edge in newStitchLoop
# and read from the old stitchLoop on the near edge
# except for the first loop
if edgeLoop == 0:
next0 = (rotMat.xformVec(segTangent) * radii[edgeLoop]) + pts[edgeLoop]
next1 = (rotMat.xformVec(segTangent) * radii[edgeLoop + 1]) + pts[edgeLoop + 1]
newStitchLoop.append(next1)  # prepare for the 1st repeated edge
else:
next0 = stitchLoop[radialCut]  # read from the last repeated edge
next1 = (rotMat.xformVec(segTangent) * radii[edgeLoop + 1]) + pts[edgeLoop + 1]
newStitchLoop.append(next1)  # prepare for the next repeated edge
except TypeError:
if edgeLoop == 0:
next0 = (rotMat.xformVec(segTangent) * radii) + pts[edgeLoop]
next1 = (rotMat.xformVec(segTangent) * radii) + pts[edgeLoop + 1]
newStitchLoop.append(next1)  # prepare for the 1st repeated edge
else:
next0 = stitchLoop[radialCut]  # read from the last repeated edge
next1 = (rotMat.xformVec(segTangent) * radii) + pts[edgeLoop + 1]
newStitchLoop.append(next1)  # prepare for the next repeated edge

# print('cut: {}'.format(radialCut))
# print('0: {}'.format(next0))
# print('1: {}'.format(next1))

vertex.addData3(next0.x, next0.y, next0.z)
vertex.addData3(next1.x, next1.y, next1.z)
normalWrtr.addData3(normToSave.x, normToSave.y, normToSave.z)
normalWrtr.addData3(normToSave.x, normToSave.y, normToSave.z)
# color.addData4f(0.5, 0.5, 0.5, 1.0)
# color.addData4f(0.5, 0.5, 0.5, 1.0)
texcoord.addData2f(texStart, texRadial)
texcoord.addData2f(texEnd,   texRadial)

vertexCount += 2
faceLoopCount += 1
stitchLoop = newStitchLoop

# the first index of the face loop we just completed equals
# the number of verts - the number of verts in the face loop
offset = vertexCount - (radialSlices + 1) * 2
tris = GeomTriangles(Geom.UHDynamic)
for t in range(0, 2 * (radialSlices + 1) - 2):  # 2 triangles per slice
idx = t + offset
if t % 2 == 0:
triple = (idx, idx + 2, idx + 1)
else:
triple = (idx, idx + 1, idx + 2)

# print(triple)
tris.addVertices(triple[0], triple[1], triple[2])
# tie in the first two vertices of the face loop with the last two
tris.addVertices(vertexCount - 2, offset, vertexCount - 1)
tris.addVertices(vertexCount - 1, offset, offset + 1)
triCount += 2
tris.closePrimitive()
tube.addPrimitive(tris)
# print('##############   END MAIN LOOP   #################')
cls.triangleCount = triCount
cls.vertexCount = vertexCount
return tube, tangent

if __name__ == '__main__':
from direct.showbase.ShowBase import ShowBase
from panda3d.core import PandaNode, Point4, Point3, TextureStage, Texture, GeomNode
class App(ShowBase):  # TODO render to picture and run tests against the rendering i.e. compare pixel to pixel
def __init__(self):
ShowBase.__init__(self)

origin = PandaNode('Origin')
originNp = self.render.attachNewNode(origin)
originNp.setHpr(0.0)
dummy = PandaNode('Dummy')
dummyNp = originNp.attachNewNode(dummy)
currentX = 0.025
currentW = currentX * 0.20
currentStep = 1.0
golden = 1.61803398875
stepsPerRound = 8.0
goldenStep = golden / stepsPerRound
spiralCnt = 10.0
ptCount = spiralCnt * stepsPerRound

pList = []
p1 = Point4(currentX, 0.0, 0.0, currentW)
dummyNp.setPos(p1.x, p1.y, p1.z)
# generate points in a spiral
for i in range(0, int(ptCount)):
tmp = dummyNp.getPos(self.render)
pList.append(Point4(tmp.x, tmp.y, tmp.z, currentW))

originNp.setR(
((360.0/stepsPerRound) + originNp.getR()) % 360.0 )
dummyNp.setPos(currentX, 0.0, 0.0)

currentStep = ((currentStep + 1.0) % stepsPerRound) + 1.0
currentX = currentX + goldenStep * currentX
currentW = currentW + goldenStep * currentW

pts = [Point3(p.x, p.y, p.z) for p in pList]
rads = [p.w for p in pList]
# lines = LineSegs('lines')
# for p in pList:
#     lines.drawTo(p.x, p.y - 3, p.z)
# self.render.attachNewNode(lines.create())
# self.disableMouse()
# self.camera.setPos(0, -10, 0)
# pts = (
#     LVector3(0.0, 0.0, 0.0),
#     LVector3(0.0, 0.0, 1.0),
#     LVector3(2.0, 0.0, 2.0),
# )
# rads = (
#     1.0,
#     1.0,
#     1.0
# )
tube = ProceduralTube.makeTube(pts, rads, radialSlices=64, stitch=True)
snode = GeomNode('tube')  # segments would go here
snode.addGeom(tube[0])
print('tube:\n\ttriangles: {}\n\tvertices: {}'.format(ProceduralTube.triangleCount, ProceduralTube.vertexCount))
# wp = WindowProperties()
# wp.setSize(1000, 800)
# self.win.requestProperties(wp)

# self.axis = loader.loadModel("zup-axis")
# self.axis.reparentTo(self.render)
# self.axis.setScale(1.0)
# self.axis.setPos(0.0)

np = self.render.attachNewNode(snode)
# np.hide()
# np.setRenderModeFilledWireframe(Point3(0.1, 1.0, 0.1))
texture = loader.loadTexture('wood.png')
texture.setWrapU(Texture.WM_repeat)
np.setTexture(texture)
np.setTexScale(TextureStage.getDefault(), 10.0, 1.0)  # U, V and U = along major axis

base = App()
base.run()``````
1 Like

This looks quite cool .

Would it be possible to make the tube smooth across the joints as well (not just along each cross-section), by defining different normals for each of the two vertices in a radial slice?

Looking at the code, there are a couple of things I’d like to address, if you don’t mind.
Firstly, I see you calling `addData4f` and `addData2f`; its better not to do that as it will make your application crash if Panda3D has been built with double precision. If you call e.g. `addData4` instead of `addData4f`, Panda will always just do the right thing. (Of course, once you start using memoryviews, this point is moot .)
Secondly, there’s no need to call `closePrimitive` on a simple GeomPrimitive type such as GeomTriangles.

All in all, nice sample .

Thanks for the feedback @Epihaius I’m glad you pointed that out. It was my intent to create smooth normals, but I may have tested in pure ambient light, and thus would have never seen the effects of the normals .

I was unaware of the `addData4f` vs `addData4` behavior, but it makes sense now that you point it out. I’ll have to revise it when I get a chance, not to mention re-visit memoryviews.

Thanks!