Skip to content

Instantly share code, notes, and snippets.

@RH2
Created February 10, 2026 05:10
Show Gist options
  • Select an option

  • Save RH2/b2800457a3c753b6c2b82b53176a87d0 to your computer and use it in GitHub Desktop.

Select an option

Save RH2/b2800457a3c753b6c2b82b53176a87d0 to your computer and use it in GitHub Desktop.
import bpy
import mathutils
from mathutils import Vector
import math
bl_info = {
"name": "Spiral Along Curve",
"author": "?",
"version": (1, 0, 0),
"blender": (3, 0, 0),
"location": "View3D > Sidebar > Spiral",
"description": "Create a spiral curve wrapping around a selected curve",
"category": "Add Curve",
}
PRESETS = {
"Default": dict(turns=10, radius=0.5, sample_count=500, taper_start=0.05, taper_end=0.05, tightness=1.0),
"Tight Coil": dict(turns=30, radius=0.2, sample_count=800, taper_start=0.0, taper_end=0.0, tightness=1.0),
"Loose Wrap": dict(turns=4, radius=1.0, sample_count=300, taper_start=0.0, taper_end=0.0, tightness=1.0),
"DNA Helix": dict(turns=20, radius=0.3, sample_count=600, taper_start=0.0, taper_end=0.0, tightness=1.0),
"Tapered Ends": dict(turns=12, radius=0.6, sample_count=500, taper_start=0.15, taper_end=0.15, tightness=1.0),
"Vine Tendril": dict(turns=8, radius=0.4, sample_count=400, taper_start=0.0, taper_end=0.3, tightness=2.5),
"Spring Bounce": dict(turns=15, radius=0.5, sample_count=600, taper_start=0.1, taper_end=0.1, tightness=3.0),
"Barley Twist": dict(turns=6, radius=0.8, sample_count=400, taper_start=0.0, taper_end=0.0, tightness=1.0),
"Fine Thread": dict(turns=50, radius=0.1, sample_count=1000, taper_start=0.02, taper_end=0.02, tightness=1.0),
"Bunched Ends": dict(turns=14, radius=0.5, sample_count=600, taper_start=0.05, taper_end=0.05, tightness=4.0),
}
def get_spline_points(curve_obj):
depsgraph = bpy.context.evaluated_depsgraph_get()
eval_obj = curve_obj.evaluated_get(depsgraph)
mesh = eval_obj.to_mesh()
if not mesh or len(mesh.vertices) < 2:
eval_obj.to_mesh_clear()
return []
mtx = curve_obj.matrix_world
verts = [mtx @ v.co for v in mesh.vertices]
adj = {i: [] for i in range(len(verts))}
for e in mesh.edges:
adj[e.vertices[0]].append(e.vertices[1])
adj[e.vertices[1]].append(e.vertices[0])
visited = set()
chains = []
for start in range(len(verts)):
if start in visited:
continue
end = start
prev = -1
while True:
neighbors = [n for n in adj[end] if n != prev]
if len(neighbors) == 1 and end not in visited:
prev = end
end = neighbors[0]
else:
break
chain = [end]
visited.add(end)
prev = -1
cur = end
while True:
neighbors = [n for n in adj[cur] if n != prev and n not in visited]
if len(neighbors) == 0:
break
prev = cur
cur = neighbors[0]
chain.append(cur)
visited.add(cur)
if len(chain) >= 2:
chains.append([verts[i] for i in chain])
eval_obj.to_mesh_clear()
return chains
def resample_points(points, count):
if len(points) < 2:
return points
lengths = [0.0]
for i in range(1, len(points)):
lengths.append(lengths[-1] + (points[i] - points[i - 1]).length)
total_length = lengths[-1]
if total_length < 1e-8:
return points
resampled = []
for i in range(count):
t = i / (count - 1) if count > 1 else 0.0
target = t * total_length
for j in range(1, len(lengths)):
if lengths[j] >= target:
seg = lengths[j] - lengths[j - 1]
frac = (target - lengths[j - 1]) / seg if seg > 1e-8 else 0.0
resampled.append(points[j - 1].lerp(points[j], frac))
break
else:
resampled.append(points[-1].copy())
return resampled
def compute_frames(points):
n = len(points)
if n < 2:
return [], [], []
tangents = []
for i in range(n - 1):
tangents.append((points[i + 1] - points[i]).normalized())
tangents.append(tangents[-1].copy())
t0 = tangents[0]
abs_t = (abs(t0.x), abs(t0.y), abs(t0.z))
if abs_t[0] <= abs_t[1] and abs_t[0] <= abs_t[2]:
ref = Vector((1, 0, 0))
elif abs_t[1] <= abs_t[2]:
ref = Vector((0, 1, 0))
else:
ref = Vector((0, 0, 1))
normal = t0.cross(ref).normalized()
binormal = t0.cross(normal).normalized()
normals = [normal]
binormals = [binormal]
for i in range(n - 1):
v1 = points[i + 1] - points[i]
c1 = v1.dot(v1)
if c1 < 1e-12:
normals.append(normals[-1].copy())
binormals.append(binormals[-1].copy())
continue
n_ref = normals[i] - (2.0 / c1) * v1.dot(normals[i]) * v1
t_ref = tangents[i] - (2.0 / c1) * v1.dot(tangents[i]) * v1
v2 = tangents[i + 1] - t_ref
c2 = v2.dot(v2)
if c2 < 1e-12:
n_next = n_ref.normalized()
else:
n_next = (n_ref - (2.0 / c2) * v2.dot(n_ref) * v2).normalized()
b_next = tangents[i + 1].cross(n_next).normalized()
normals.append(n_next)
binormals.append(b_next)
return tangents, normals, binormals
def remap_tightness(t, tightness):
if t <= 0.5:
u = t * 2.0
remapped = 1.0 - (1.0 - u) ** tightness
return remapped * 0.5
else:
u = (t - 0.5) * 2.0
remapped = u ** tightness
return 0.5 + remapped * 0.5
def create_spiral_along_curve(turns, radius, sample_count, taper_start,
taper_end, tightness):
obj = bpy.context.active_object
if obj is None or obj.type != 'CURVE':
return None, "Select a curve object first."
chains = get_spline_points(obj)
if not chains:
return None, "Could not sample points. Increase curve preview resolution."
curve_data = bpy.data.curves.new(name="SpiralCurve", type='CURVE')
curve_data.dimensions = '3D'
curve_data.resolution_u = 12
for chain in chains:
if len(chain) < 2:
continue
points = resample_points(chain, sample_count)
tangents, normals, binormals = compute_frames(points)
spiral_points = []
for i in range(len(points)):
t = i / (len(points) - 1) if len(points) > 1 else 0.0
angle_t = remap_tightness(t, tightness)
angle = angle_t * turns * 2.0 * math.pi
r = radius
if taper_start > 0.0 and t < taper_start:
r *= t / taper_start
if taper_end > 0.0 and t > (1.0 - taper_end):
r *= (1.0 - t) / taper_end
offset = (normals[i] * math.cos(angle) + binormals[i] * math.sin(angle)) * r
spiral_points.append(points[i] + offset)
spline = curve_data.splines.new('BEZIER')
spline.bezier_points.add(len(spiral_points) - 1)
for i, pt in enumerate(spiral_points):
bp = spline.bezier_points[i]
bp.co = (pt.x, pt.y, pt.z)
bp.handle_left_type = 'AUTO'
bp.handle_right_type = 'AUTO'
spiral_obj = bpy.data.objects.new("Spiral", curve_data)
bpy.context.collection.objects.link(spiral_obj)
bpy.ops.object.select_all(action='DESELECT')
spiral_obj.select_set(True)
bpy.context.view_layer.objects.active = spiral_obj
return spiral_obj, None
class SpiralCurveProperties(bpy.types.PropertyGroup):
turns: bpy.props.IntProperty(
name="Turns", default=10, min=1, max=200,
description="Number of full spiral rotations",
)
radius: bpy.props.FloatProperty(
name="Radius", default=0.5, min=0.001, max=100.0,
description="Distance from the base curve to the spiral",
)
sample_count: bpy.props.IntProperty(
name="Samples", default=500, min=10, max=5000,
description="Point count per spline (smoothness)",
)
taper_start: bpy.props.FloatProperty(
name="Taper Start", default=0.05, min=0.0, max=0.5,
description="Fraction of curve over which radius grows from 0",
)
taper_end: bpy.props.FloatProperty(
name="Taper End", default=0.05, min=0.0, max=0.5,
description="Fraction of curve over which radius shrinks to 0",
)
tightness: bpy.props.FloatProperty(
name="Tightness", default=1.0, min=0.1, max=10.0,
description=">1 = tight at ends / loose in middle. 1 = uniform",
)
preset: bpy.props.EnumProperty(
name="Preset",
items=[(k, k, "") for k in PRESETS.keys()],
default="Default",
description="Load a preset configuration",
)
class CURVE_OT_apply_spiral_preset(bpy.types.Operator):
bl_idname = "curve.apply_spiral_preset"
bl_label = "Apply Preset"
bl_description = "Load the selected preset values"
def execute(self, context):
props = context.scene.spiral_curve_props
values = PRESETS.get(props.preset)
if values:
for k, v in values.items():
setattr(props, k, v)
return {'FINISHED'}
class CURVE_OT_create_spiral(bpy.types.Operator):
bl_idname = "curve.create_spiral_along_curve"
bl_label = "Create Spiral"
bl_description = "Generate a spiral curve along the active curve"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
return (context.active_object is not None
and context.active_object.type == 'CURVE')
def execute(self, context):
props = context.scene.spiral_curve_props
result, err = create_spiral_along_curve(
turns=props.turns,
radius=props.radius,
sample_count=props.sample_count,
taper_start=props.taper_start,
taper_end=props.taper_end,
tightness=props.tightness,
)
if err:
self.report({'ERROR'}, err)
return {'CANCELLED'}
self.report({'INFO'}, "Spiral created with %d turns" % props.turns)
return {'FINISHED'}
class VIEW3D_PT_spiral_curve(bpy.types.Panel):
bl_label = "Spiral Along Curve"
bl_idname = "VIEW3D_PT_spiral_curve"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = "Spiral"
def draw(self, context):
layout = self.layout
props = context.scene.spiral_curve_props
box = layout.box()
box.label(text="Presets")
row = box.row(align=True)
row.prop(props, "preset", text="")
row.operator("curve.apply_spiral_preset", text="Load")
box = layout.box()
box.label(text="Parameters")
col = box.column(align=True)
col.prop(props, "turns")
col.prop(props, "radius")
col.prop(props, "sample_count")
col.separator()
col.prop(props, "taper_start")
col.prop(props, "taper_end")
col.separator()
col.prop(props, "tightness")
layout.separator()
row = layout.row(align=True)
row.scale_y = 1.5
row.operator("curve.create_spiral_along_curve", icon='FORCE_VORTEX')
classes = (
SpiralCurveProperties,
CURVE_OT_apply_spiral_preset,
CURVE_OT_create_spiral,
VIEW3D_PT_spiral_curve,
)
def register():
for cls in classes:
bpy.utils.register_class(cls)
bpy.types.Scene.spiral_curve_props = bpy.props.PointerProperty(
type=SpiralCurveProperties)
def unregister():
del bpy.types.Scene.spiral_curve_props
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
if __name__ == "__main__":
register()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment