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
Prompts the user to
SelectSurface.Builds a
VertexInfo(point + vertex normal) for every surface vertex.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”.
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.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 |
|---|---|---|
|
class |
Pairs a surface point with its vertex normal. |
|
class |
Holds a candidate circle’s radius and center point. |
|
function |
Returns the radius and center of the circle through three points, using the circumscribed-circle formula. |
|
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 |
|
function |
Determines whether two normal lines (point + direction) intersect, used to group vertices into planes. |
|
functions |
Tolerance-based equality checks (rounded to |
VertexInfo and CircleInfo are also indexed on the Classes page alongside every other enum/class in the package.
Constants
Constant |
Value |
Meaning |
|---|---|---|
|
2 |
Decimal places used when comparing floats/vectors for equality. |
|
2 |
Decimal places for rounding points. Declared, but not used by the current code. |
|
0.25 |
Tolerance for two circle centers to be considered the same. |
|
0.70 |
Minimum fraction of points that must agree on one circle for the fit to be accepted. |
Dependencies
spinfirescript(assfs) — forSelectSurface.numpy— vector math (cross/dot products, normalization, linear algebra). The script installs NumPy withpipinto SpinFire’s embedded Python environment if it isn’t already available.SpinFire.Scripting.API.Utilities.SelectionUtilities— imported directly, in addition to thesfswrapper, 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.")