Sample Script — Calculate Cylindrical Radius

This is the built-in SpinFire sample script. It shows how to combine spinfirescript selection with NumPy-based geometry math to detect and measure planar circles, cylinders and cones from a selected surface’s vertex normals.

What It Does

  1. Prompts the user to SelectSurface.

  2. Builds a VertexInfo (point + vertex normal) for every surface vertex.

  3. Classifies the surface by comparing vertex normals:

    • Flat planar surface — all normals point the same direction. All points are fitted to a single circle.

    • Bent surface — vertices are grouped into planes by testing each vertex’s normal line for an intersection with the normal line of the first vertex in each existing group. A vertex whose normal is parallel to a group’s first normal is skipped. Depending on how many planes are found, the script reports a cylinder, a cone, or “not cylindrical/circular”.

  4. For each plane, candidate circles are fitted through the first two points and each remaining point in turn (calculateCircle, based on the circumscribed-circle formula), then the most common matching group (by radius/center within tolerance) is averaged to get a final radius and center. A plane with fewer than 6 points can’t be fitted.

  5. Prints the radius and center point(s). It states whether the surface is circular planar, cylindrical, or conical, except when a bent surface yields a single plane, where only the radius and center are printed.

Key Building Blocks

Name

Kind

Purpose

VertexInfo

class

Pairs a surface point with its vertex normal.

CircleInfo

class

Holds a candidate circle’s radius and center point.

calculateCircle(A, B, C)

function

Returns the radius and center of the circle through three points, using the circumscribed-circle formula.

calculateValues(points)

function

Fits a circle through the first two points and each remaining point in turn, groups near-equal circles, and returns the averaged radius/center of the largest matching group if it covers at least MATCHING_CIRCLES_PERCENT (70%) of the points. Returns failure for fewer than 6 points.

hasIntersection(...)

function

Determines whether two normal lines (point + direction) intersect, used to group vertices into planes.

areEqualVectors, areEqualRadii, areEqualCenterPoints

functions

Tolerance-based equality checks (rounded to ROUND_DIGITS / bounded by CENTER_ZERO_POINT).

VertexInfo and CircleInfo are also indexed on the Classes page alongside every other enum/class in the package.

Constants

Constant

Value

Meaning

ROUND_DIGITS

2

Decimal places used when comparing floats/vectors for equality.

POINTS_ROUND_DIGITS

2

Decimal places for rounding points. Declared, but not used by the current code.

CENTER_ZERO_POINT

0.25

Tolerance for two circle centers to be considered the same.

MATCHING_CIRCLES_PERCENT

0.70

Minimum fraction of points that must agree on one circle for the fit to be accepted.

Dependencies

  • spinfirescript (as sfs) — for SelectSurface.

  • numpy — vector math (cross/dot products, normalization, linear algebra). The script installs NumPy with pip into SpinFire’s embedded Python environment if it isn’t already available.

  • SpinFire.Scripting.API.Utilities.SelectionUtilities — imported directly, in addition to the sfs wrapper, but not used.

Warning

Known issue: the NumPy install block calls os.getcwd() and os.chdir() without importing os. It only works if os happens to be imported already, so the first run on a machine without NumPy can fail. To fix it, add import os next to import sys in the except ModuleNotFoundError branch. Getting Started shows the corrected block.

Control Flow Summary

SelectSurface()
  -> None: print "No surface was selected."
  -> surface:
       build VertexInfo list
       if all normals equal (flat surface):
           calculateValues(surface.Points) -> circular planar report, or "non-circular / multiple circles"
       else (bent surface):
           group vertices into planes via hasIntersection()
           > 2 planes  -> "not cylindrical or circular planar"
           == 1 plane  -> single radius/center report
           == 2 planes -> compare radii:
                equal radii -> "cylinder"
                different   -> "cone"
                report radius/center for both planes

Full Source

The script ships with SpinFire as Sample Scripts/Sample - Calculate Cylindrical Radius.py. See Files for where it sits in the scripting folder. To try it on the same model as the other samples, see Script Samples for Filter.CATPart. Sample 08 — Show Vertex Normals draws the same vertex normals this script uses, which helps explain why it classifies a surface the way it does.

  1"""The SpinFire sample script for calculating cylindrical radius."""
  2
  3import spinfirescript as sfs
  4
  5try:
  6    import numpy as np
  7except ModuleNotFoundError:
  8    import sys
  9    import pip
 10
 11    currentPath = os.getcwd()
 12    newPath = os.path.join(os.path.dirname(sys.executable), "CPython")
 13
 14    try:
 15        os.chdir(newPath)
 16        print(pip.main(['install', 'numpy']))
 17        import numpy as np
 18    finally:
 19        os.chdir(currentPath)
 20
 21from SpinFire.Scripting.API.Utilities import SelectionUtilities
 22
 23ROUND_DIGITS = 2
 24POINTS_ROUND_DIGITS = 2
 25ZERO_VECTOR = np.zeros(3)
 26CENTER_ZERO_POINT = np.array([0.25])
 27MATCHING_CIRCLES_PERCENT = 0.70
 28
 29class VertexInfo:
 30    def __init__(self, point, vertexNormal):
 31        self.Point = point
 32        self.VertexNormal = vertexNormal
 33
 34    def __repr__(self):
 35        return "{} - {}".format(self.Point, self.VertexNormal)
 36
 37class CircleInfo:
 38    def __init__(self, radius, point):
 39        self.Radius = radius
 40        self.Point = point
 41
 42    def __repr__(self):
 43        return "{} - {}".format(self.Radius, self.Point)
 44
 45def getArray(vector):
 46    return np.array([vector.X, vector.Y, vector.Z])
 47
 48def showArray(value):
 49    return np.array2string(value, formatter={'float_kind':lambda x: "%.2f" % x}, separator=', ')
 50
 51def showValue(value):
 52    return "{:.2f}".format(value)
 53
 54def normalize(v):
 55    norm = np.linalg.norm(v)
 56    if norm == 0:
 57        return v
 58    return v / norm
 59
 60def isZeroValue(value):
 61    return (round(value, ROUND_DIGITS) == 0).all()
 62
 63def isZeroVector(vector1):
 64    return areEqualVectors(vector1, ZERO_VECTOR);
 65
 66def areEqualRadii(radius1, radius2):
 67    return isZeroValue(radius1 - radius2)
 68
 69def areEqualVectors(vector1, vector2):
 70    return (np.round(vector1 - vector2, ROUND_DIGITS) == ZERO_VECTOR).all()
 71
 72def areEqualCenterPoints(point1, point2):
 73    return (np.less_equal(np.absolute(point1 - point2), CENTER_ZERO_POINT).all())
 74
 75def areCollinearVectors(vector1, vector2):
 76    dotProduct = np.round(np.dot(vector1, vector2), ROUND_DIGITS)
 77    return dotProduct == 1 or dotProduct == -1
 78
 79def hasIntersection(point1, vector1, point2, vector2):
 80    if (areCollinearVectors(vector1, vector2)):
 81        return None
 82
 83    intersectionMatrix = np.append([[point2.X - point1.X, point2.Y - point1.Y, point2.Z - point1.Z]], [vector1, vector2], axis = 0)
 84    return abs(np.linalg.det(intersectionMatrix)) <= 0.01
 85
 86def createVertexInfos(surface):
 87    vertexInfos = []
 88    for i, _vertexNormal in enumerate(surface.VertexNormals):
 89        vertexInfos.append(VertexInfo(surface.Points[i], getArray(_vertexNormal)))
 90    return vertexInfos
 91
 92# Calculates circle properties by three points.
 93def calculateCircle(A, B, C):
 94    a = np.linalg.norm(C - B)
 95    b = np.linalg.norm(C - A)
 96    c = np.linalg.norm(B - A)
 97    s = (a + b + c) / 2
 98    R = a * b * c / (4 * np.sqrt(s * (s - a) * (s - b) * (s - c)))
 99    b1 = a*a * (b*b + c*c - a*a)
100    b2 = b*b * (a*a + c*c - b*b)
101    b3 = c*c * (a*a + b*b - c*c)
102    P = np.column_stack((A, B, C)).dot(np.hstack((b1, b2, b3)))
103    P /= b1 + b2 + b3
104    return R, P
105
106def calculateValues(points):
107    if (len(points)) < 6:
108        return False, 0, None
109
110    allCircles = []
111    A = getArray(points[0])
112    B = getArray(points[1])
113
114    for i in range(2, len(points)):
115        # Calculate circle info.
116        R, P = calculateCircle(A, B, getArray(points[i]))
117        circleInfo = CircleInfo(R, P)
118
119        # Try to find similar infos.
120        hasCircle = False
121        for circleInfos in allCircles:
122            baseInfo = circleInfos[0]
123            if (areEqualRadii(baseInfo.Radius, circleInfo.Radius) and areEqualCenterPoints(baseInfo.Point, circleInfo.Point)):
124                circleInfos.append(circleInfo)
125                hasCircle = True
126
127        # If no similar infos group is found, create a new one.
128        if (hasCircle == False):
129            newCircles = [circleInfo]
130            allCircles.append(newCircles)
131
132    # Use biggest group.
133    bestCircleInfos = max(allCircles, key = len)
134    bestCircleInfosLength = len(bestCircleInfos)
135    percent = bestCircleInfosLength / len(points)
136    if (percent < MATCHING_CIRCLES_PERCENT):
137        return False, 0, None
138
139    # Calculate average values.
140    R = sum([info.Radius for info in bestCircleInfos]) / bestCircleInfosLength
141    P = np.array([info.Point for info in bestCircleInfos]).mean(axis = 0)
142
143    return True, R, P
144
145def calculateValuesForAllPoints(allPoints, number):
146    planeVertexInfos = allPoints[number]
147    points = [info.Point for info in planeVertexInfos]
148    return calculateValues(points)
149
150surface = sfs.SelectSurface('Select a surface.')
151
152if (surface == None):
153    print('No surface was selected.')
154else:
155    vertexInfos = createVertexInfos(surface)
156
157    baseVertexNormal = vertexInfos[0].VertexNormal
158    baseSecondVertexNormal = ZERO_VECTOR
159    baseVertexNormalizedNormal = ZERO_VECTOR
160    basePlanarNormal = ZERO_VECTOR
161
162    for vertexInfo in vertexInfos:
163        vertexNormal = vertexInfo.VertexNormal
164        point = vertexInfo.Point
165
166        # If vector is codirectional with a base one, do not use it in calculations.
167        if (areEqualVectors(vertexNormal, baseVertexNormal)):
168            continue
169
170        # Calculate second base vector.
171        if (isZeroVector(baseSecondVertexNormal)):
172            baseSecondVertexNormal = vertexNormal
173            baseVertexNormalizedNormal = normalize(baseSecondVertexNormal - baseVertexNormal)
174            continue
175
176        # If vector is codirectional with the second base one, do not use it in calculations.
177        if (areEqualVectors(vertexNormal, baseSecondVertexNormal)):
178            continue
179
180        vertexNormalizedNormal = normalize(vertexNormal - baseVertexNormal)
181        normalCross = normalize(np.cross(baseVertexNormalizedNormal, vertexNormalizedNormal))
182
183        # If base normal has not yet been set.
184        if (isZeroVector(basePlanarNormal)):
185            basePlanarNormal = normalCross
186            continue
187
188    # A flat planar surface.
189    if (isZeroVector(baseSecondVertexNormal)):
190        points = []
191        points.extend(surface.Points)
192
193        result, R, P = calculateValues(points)
194        if (result):
195            print("The selected surface is a circular planar one.")
196            print("Radius: {0}, center: {1}".format(showValue(R), showArray(P)))
197        else:
198            print("The selected flat surface is either non-circular or has more than one circle/arc.")
199
200    # A bent planar surface.
201    else:
202        allPoints = []
203
204        # Put points to planes.
205        for vertexInfo in vertexInfos:
206            hasPlane = False
207
208            # Try to find a plane for this point.
209            for points in allPoints:
210                baseInfo = points[0]
211
212                intersection = hasIntersection(baseInfo.Point, baseInfo.VertexNormal, vertexInfo.Point, vertexInfo.VertexNormal)
213                if (intersection):
214                    points.append(vertexInfo)
215                    hasPlane = True
216                    break
217                elif (intersection == None):
218                    hasPlane = True
219                    break
220
221            # If point does not belong to any plane, create a new one.
222            if (hasPlane == False):
223                newPoints = [vertexInfo]
224                allPoints.append(newPoints)
225
226        # Too many planes.
227        if (len(allPoints) > 2):
228            print("The selected surface is not a cylindrical or circular planar one.")
229
230        elif (len(allPoints) == 1):
231            result, R, P = calculateValuesForAllPoints(allPoints, 0)
232            if (result):
233                print("Radius: {0}, center: {1}".format(showValue(R), showArray(P)))
234            else:
235                print("Failed to calculate cylindrical properties for selected surface.")
236
237        else:
238            # Calculate for all planes.
239            result1, R1, P1 = calculateValuesForAllPoints(allPoints, 0)
240            result2, R2, P2 = calculateValuesForAllPoints(allPoints, 1)
241
242            if (result1 and result2):
243                if (areEqualRadii(R1, R2)):
244                    print("The selected surface is a cylinder.")
245                else:
246                    print("The selected surface is a cone.")
247
248                print("Radius 1: {0}, center 1: {1}".format(showValue(R1), showArray(P1)))
249                print("Radius 2: {0}, center 2: {1}".format(showValue(R2), showArray(P2)))
250            else:
251                print("Failed to calculate cylindrical properties for selected surface.")