Created
February 10, 2026 05:10
-
-
Save RH2/b2800457a3c753b6c2b82b53176a87d0 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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