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 :slight_smile: .

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 :wink: .)
Secondly, there’s no need to call closePrimitive on a simple GeomPrimitive type such as GeomTriangles.

All in all, nice sample :slight_smile: .

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 :see_no_evil:.

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!