ambi-recurve-parts.py raw

   1  """
   2  Ambidextrous Recurve Bow — Exploded Parts View
   3  Blender Python script. Run from Blender's Scripting workspace (or Text Editor > Run Script).
   4  
   5  Creates all individual parts as separate objects in the scene.
   6  """
   7  
   8  import bpy
   9  import bmesh
  10  import math
  11  from mathutils import Vector, Matrix
  12  
  13  # ============================================================
  14  # UTILITIES
  15  # ============================================================
  16  
  17  def clear_scene():
  18      bpy.ops.object.select_all(action='SELECT')
  19      bpy.ops.object.delete()
  20      for c in bpy.data.collections:
  21          if c.name != 'Scene Collection':
  22              bpy.data.collections.remove(c)
  23  
  24  def make_material(name, color, metallic=0.0, roughness=0.5):
  25      mat = bpy.data.materials.new(name)
  26      mat.use_nodes = True
  27      bsdf = mat.node_tree.nodes["Principled BSDF"]
  28      bsdf.inputs["Base Color"].default_value = (*color, 1.0)
  29      bsdf.inputs["Metallic"].default_value = metallic
  30      bsdf.inputs["Roughness"].default_value = roughness
  31      return mat
  32  
  33  def assign_material(obj, mat):
  34      obj.data.materials.append(mat)
  35  
  36  def new_collection(name):
  37      col = bpy.data.collections.new(name)
  38      bpy.context.scene.collection.children.link(col)
  39      return col
  40  
  41  def link_to_collection(obj, col):
  42      col.objects.link(obj)
  43      if obj.name in bpy.context.scene.collection.objects:
  44          bpy.context.scene.collection.objects.unlink(obj)
  45  
  46  def set_smooth(obj):
  47      for f in obj.data.polygons:
  48          f.use_smooth = True
  49  
  50  # ============================================================
  51  # MATERIALS
  52  # ============================================================
  53  
  54  WOOD   = None
  55  STEEL  = None
  56  BLACK  = None  # QPQ nitride
  57  WIRE   = None
  58  RUBBER = None
  59  
  60  def setup_materials():
  61      global WOOD, STEEL, BLACK, WIRE, RUBBER
  62      WOOD   = make_material("Mulberry",    (0.55, 0.35, 0.18), metallic=0.0, roughness=0.7)
  63      STEEL  = make_material("Stainless",   (0.7, 0.72, 0.74),  metallic=0.9, roughness=0.3)
  64      BLACK  = make_material("QPQ_Nitride", (0.08, 0.08, 0.08), metallic=0.8, roughness=0.4)
  65      WIRE   = make_material("Wire_Rope",   (0.6, 0.62, 0.64),  metallic=0.9, roughness=0.35)
  66      RUBBER = make_material("Rubber",      (0.1, 0.1, 0.1),    metallic=0.0, roughness=0.9)
  67  
  68  
  69  # ============================================================
  70  # 1. RISER — Mulberry wood, C2 symmetric
  71  # ============================================================
  72  
  73  def make_riser(col):
  74      """
  75      Riser as a lofted shape: define cross-sections at intervals along Y,
  76      skin them together. 280mm long, C2 point symmetry.
  77      """
  78      # We'll use a curve + bevel for the main body, then boolean the groove.
  79      # Simpler approach: mesh with loop cuts for editing.
  80  
  81      # Create riser as a tapered box with smooth profile
  82      bm = bmesh.new()
  83  
  84      length = 0.280  # 280mm in meters (Blender default unit)
  85      sections = 28
  86      dy = length / sections
  87  
  88      for i in range(sections + 1):
  89          y = -length / 2 + i * dy
  90          t = abs(y) / (length / 2)  # 0 at center, 1 at ends
  91  
  92          # Width: 76mm at center, 31mm at ends — smooth bell curve
  93          half_w = (0.038 - (0.038 - 0.0155) * t**1.5)
  94          # Depth: 28mm at center, ~20mm at ends
  95          depth = 0.028 - 0.008 * t**1.5
  96  
  97          # S-curve grip cross-section: offset the front/back faces
  98          # to create the tessellating S-grip feel
  99          grip_offset = 0.003 * math.sin(math.pi * (y / (length / 2))) if abs(y) < 0.05 else 0
 100  
 101          # Four corners of cross-section
 102          verts = [
 103              bm.verts.new((-half_w, y, 0)),
 104              bm.verts.new(( half_w, y, 0)),
 105              bm.verts.new(( half_w, y, depth)),
 106              bm.verts.new((-half_w, y, depth)),
 107          ]
 108  
 109      # Create faces between consecutive sections
 110      bm.verts.ensure_lookup_table()
 111      for i in range(sections):
 112          base = i * 4
 113          for j in range(4):
 114              v0 = bm.verts[base + j]
 115              v1 = bm.verts[base + (j + 1) % 4]
 116              v2 = bm.verts[base + 4 + (j + 1) % 4]
 117              v3 = bm.verts[base + 4 + j]
 118              bm.faces.new([v0, v1, v2, v3])
 119  
 120      # Cap ends
 121      bm.faces.new([bm.verts[0], bm.verts[1], bm.verts[2], bm.verts[3]])
 122      last = sections * 4
 123      bm.faces.new([bm.verts[last+3], bm.verts[last+2], bm.verts[last+1], bm.verts[last]])
 124  
 125      mesh = bpy.data.meshes.new("Riser")
 126      bm.to_mesh(mesh)
 127      bm.free()
 128  
 129      obj = bpy.data.objects.new("Riser", mesh)
 130      link_to_collection(obj, col)
 131      assign_material(obj, WOOD)
 132      set_smooth(obj)
 133  
 134      # Add subdivision surface for smoothness
 135      mod = obj.modifiers.new("Subsurf", 'SUBSURF')
 136      mod.levels = 2
 137      mod.render_levels = 3
 138  
 139      return obj
 140  
 141  
 142  # ============================================================
 143  # 2. LIMB STRIP — 316L SS, tapered, recurved
 144  # ============================================================
 145  
 146  def limb_strip_profile(dist_from_center):
 147      """Return (half_width, thickness) at given distance from center in meters."""
 148      d = abs(dist_from_center) * 1000  # to mm
 149      # Taper points from doc:
 150      #   0mm:   44mm × 3.5mm
 151      # 140mm:   40mm × 3.0mm
 152      # 250mm:   32mm × 2.2mm
 153      # 350mm:   24mm × 1.8mm
 154      # 500mm:   20mm × 1.4mm (tip)
 155      points = [
 156          (0,   44, 3.5),
 157          (140, 40, 3.0),
 158          (250, 32, 2.2),
 159          (350, 24, 1.8),
 160          (500, 20, 1.4),
 161      ]
 162      # Linear interpolation
 163      for j in range(len(points) - 1):
 164          d0, w0, t0 = points[j]
 165          d1, w1, t1 = points[j + 1]
 166          if d0 <= d <= d1:
 167              frac = (d - d0) / (d1 - d0)
 168              w = w0 + frac * (w1 - w0)
 169              t = t0 + frac * (t1 - t0)
 170              return (w / 2 / 1000, t / 1000)
 171      # Beyond last point
 172      return (points[-1][1] / 2 / 1000, points[-1][2] / 1000)
 173  
 174  
 175  def make_limb_strip(col):
 176      """
 177      Single continuous limb strip with smooth taper and recurved tips.
 178      Uses a curve with bevel for smooth result.
 179      """
 180      total_half = 0.500  # 500mm from center to tip
 181      segments = 60
 182      bm = bmesh.new()
 183  
 184      for i in range(segments + 1):
 185          # Distance from center
 186          dist = i * total_half / (segments / 2) - total_half
 187          abs_dist = abs(dist)
 188  
 189          hw, th = limb_strip_profile(abs_dist)
 190  
 191          # Recurve: last 100mm of each limb arm bends forward
 192          recurve_onset = 0.340  # 340mm from center
 193          x_off = 0
 194          if abs_dist > recurve_onset:
 195              arc_param = (abs_dist - recurve_onset) / (total_half - recurve_onset)
 196              angle = arc_param * math.radians(70)
 197              x_off = 0.030 * (1 - math.cos(angle))  # ~30mm bend radius effect
 198  
 199          y = dist
 200          # Adjust y for recurve (arc shortens the projected length)
 201          if abs_dist > recurve_onset:
 202              arc_param = (abs_dist - recurve_onset) / (total_half - recurve_onset)
 203              angle = arc_param * math.radians(70)
 204              y_reduction = 0.030 * math.sin(angle) - 0.030 * arc_param * math.sin(math.radians(70))
 205              # Keep it simple — just offset x, keep y linear
 206  
 207          sign = 1 if dist >= 0 else -1
 208          x_off_signed = x_off * (1 if True else -1)  # recurve bends same direction
 209  
 210          z_base = 0.028  # sits at top of riser groove area
 211  
 212          verts = [
 213              bm.verts.new((-hw + x_off_signed, y, z_base)),
 214              bm.verts.new(( hw + x_off_signed, y, z_base)),
 215              bm.verts.new(( hw + x_off_signed, y, z_base + th)),
 216              bm.verts.new((-hw + x_off_signed, y, z_base + th)),
 217          ]
 218  
 219      bm.verts.ensure_lookup_table()
 220      for i in range(segments):
 221          base = i * 4
 222          for j in range(4):
 223              v0 = bm.verts[base + j]
 224              v1 = bm.verts[base + (j + 1) % 4]
 225              v2 = bm.verts[base + 4 + (j + 1) % 4]
 226              v3 = bm.verts[base + 4 + j]
 227              bm.faces.new([v0, v1, v2, v3])
 228  
 229      # Cap ends
 230      bm.faces.new([bm.verts[0], bm.verts[1], bm.verts[2], bm.verts[3]])
 231      last = segments * 4
 232      bm.faces.new([bm.verts[last+3], bm.verts[last+2], bm.verts[last+1], bm.verts[last]])
 233  
 234      mesh = bpy.data.meshes.new("Limb_Strip")
 235      bm.to_mesh(mesh)
 236      bm.free()
 237  
 238      obj = bpy.data.objects.new("Limb_Strip", mesh)
 239      link_to_collection(obj, col)
 240      assign_material(obj, BLACK)
 241      set_smooth(obj)
 242  
 243      mod = obj.modifiers.new("Subsurf", 'SUBSURF')
 244      mod.levels = 2
 245  
 246      return obj
 247  
 248  
 249  # ============================================================
 250  # 3. M6 COUNTERSUNK BOLT
 251  # ============================================================
 252  
 253  def make_bolt(col, name="M6_Bolt"):
 254      bpy.ops.mesh.primitive_cone_add(
 255          vertices=6, radius1=0.006, radius2=0.003, depth=0.0033,
 256          location=(0, 0, 0))
 257      head = bpy.context.active_object
 258      head.name = name + "_head"
 259  
 260      bpy.ops.mesh.primitive_cylinder_add(
 261          radius=0.003, depth=0.020,
 262          location=(0, 0, -0.0033/2 - 0.010))
 263      shaft = bpy.context.active_object
 264      shaft.name = name + "_shaft"
 265  
 266      # Join
 267      head.select_set(True)
 268      shaft.select_set(True)
 269      bpy.context.view_layer.objects.active = head
 270      bpy.ops.object.join()
 271      obj = bpy.context.active_object
 272      obj.name = name
 273      assign_material(obj, STEEL)
 274      link_to_collection(obj, col)
 275      return obj
 276  
 277  
 278  # ============================================================
 279  # 4. HELI-COIL INSERT
 280  # ============================================================
 281  
 282  def make_helicoil(col, name="Helicoil"):
 283      bpy.ops.mesh.primitive_cylinder_add(radius=0.005, depth=0.010)
 284      obj = bpy.context.active_object
 285      obj.name = name
 286      assign_material(obj, STEEL)
 287  
 288      # Boolean subtract inner bore
 289      bpy.ops.mesh.primitive_cylinder_add(radius=0.0025, depth=0.012)
 290      bore = bpy.context.active_object
 291      bore.name = name + "_bore"
 292  
 293      mod = obj.modifiers.new("Bool", 'BOOLEAN')
 294      mod.operation = 'DIFFERENCE'
 295      mod.object = bore
 296      bpy.context.view_layer.objects.active = obj
 297      bpy.ops.object.modifier_apply(modifier="Bool")
 298      bpy.data.objects.remove(bore)
 299  
 300      link_to_collection(obj, col)
 301      return obj
 302  
 303  
 304  # ============================================================
 305  # 5. BELLEVILLE WASHER STACK — Recoil dampener
 306  # ============================================================
 307  
 308  def make_belleville_stack(col, name="Belleville_Stack"):
 309      objs = []
 310      for i in range(4):
 311          bpy.ops.mesh.primitive_cone_add(
 312              vertices=32, radius1=0.007, radius2=0.006,
 313              depth=0.0012, location=(0, 0, i * 0.001))
 314          w = bpy.context.active_object
 315          w.name = f"{name}_washer{i}"
 316          if i % 2 == 1:
 317              w.rotation_euler[0] = math.pi  # flip alternate
 318          assign_material(w, STEEL)
 319          objs.append(w)
 320  
 321      # Join all
 322      bpy.ops.object.select_all(action='DESELECT')
 323      for o in objs:
 324          o.select_set(True)
 325      bpy.context.view_layer.objects.active = objs[0]
 326      bpy.ops.object.join()
 327      obj = bpy.context.active_object
 328      obj.name = name
 329      link_to_collection(obj, col)
 330      return obj
 331  
 332  
 333  # ============================================================
 334  # 6. BOWSTRING — 1.5mm SS wire rope
 335  # ============================================================
 336  
 337  def make_bowstring(col):
 338      # Bezier curve for the string
 339      curve_data = bpy.data.curves.new("Bowstring_Curve", 'CURVE')
 340      curve_data.dimensions = '3D'
 341      curve_data.bevel_depth = 0.00075  # 1.5mm diameter / 2
 342      curve_data.bevel_resolution = 4
 343  
 344      spline = curve_data.splines.new('BEZIER')
 345      spline.bezier_points.add(1)  # 2 points total
 346  
 347      # String endpoints — approximate positions at limb tips
 348      p0 = spline.bezier_points[0]
 349      p0.co = Vector((0.019, -0.400, 0.030))
 350      p0.handle_left = p0.co + Vector((0, 0.05, 0))
 351      p0.handle_right = p0.co + Vector((0, -0.05, 0))
 352  
 353      p1 = spline.bezier_points[1]
 354      p1.co = Vector((0.019, 0.400, 0.030))
 355      p1.handle_left = p1.co + Vector((0, -0.05, 0))
 356      p1.handle_right = p1.co + Vector((0, 0.05, 0))
 357  
 358      obj = bpy.data.objects.new("Bowstring", curve_data)
 359      link_to_collection(obj, col)
 360      assign_material(obj, WIRE)
 361      return obj
 362  
 363  
 364  # ============================================================
 365  # 7. STRING DAMPER
 366  # ============================================================
 367  
 368  def make_string_damper(col, name="String_Damper"):
 369      bpy.ops.mesh.primitive_torus_add(
 370          major_radius=0.005, minor_radius=0.002,
 371          major_segments=24, minor_segments=12)
 372      obj = bpy.context.active_object
 373      obj.name = name
 374      assign_material(obj, RUBBER)
 375      set_smooth(obj)
 376      link_to_collection(obj, col)
 377      return obj
 378  
 379  
 380  # ============================================================
 381  # 8. NOCKING POINT
 382  # ============================================================
 383  
 384  def make_nocking_point(col):
 385      bpy.ops.mesh.primitive_cylinder_add(radius=0.002, depth=0.003)
 386      obj = bpy.context.active_object
 387      obj.name = "Nocking_Point"
 388      assign_material(obj, STEEL)
 389      link_to_collection(obj, col)
 390      return obj
 391  
 392  
 393  # ============================================================
 394  # 9. ARROW SHAFT
 395  # ============================================================
 396  
 397  def make_arrow_shaft(col):
 398      bpy.ops.mesh.primitive_cylinder_add(
 399          radius=0.00425, depth=0.700)  # 8.5mm dia, 700mm long
 400      obj = bpy.context.active_object
 401      obj.name = "Arrow_Shaft"
 402      obj.rotation_euler[0] = math.pi / 2  # align along Y
 403      assign_material(obj, WOOD)
 404      set_smooth(obj)
 405      link_to_collection(obj, col)
 406      return obj
 407  
 408  
 409  # ============================================================
 410  # 10. ARROW POINT — Machined from M12 bolt
 411  # ============================================================
 412  
 413  def make_arrow_point(col):
 414      bm = bmesh.new()
 415  
 416      # Build as a lathe profile (revolution solid)
 417      # Profile: socket bore -> body cylinder -> tip cone
 418      # We'll just use primitives joined together
 419  
 420      mesh = bpy.data.meshes.new("Arrow_Point")
 421      bm.free()
 422  
 423      # Socket cylinder
 424      bpy.ops.mesh.primitive_cylinder_add(
 425          radius=0.0055, depth=0.025, location=(0, 0, 0))
 426      socket = bpy.context.active_object
 427  
 428      # Body cylinder
 429      bpy.ops.mesh.primitive_cylinder_add(
 430          radius=0.0055, depth=0.035, location=(0, 0, 0.030))
 431      body = bpy.context.active_object
 432  
 433      # Tip cone
 434      bpy.ops.mesh.primitive_cone_add(
 435          radius1=0.0055, radius2=0.0005, depth=0.030,
 436          location=(0, 0, 0.0625))
 437      tip = bpy.context.active_object
 438  
 439      # Join all
 440      bpy.ops.object.select_all(action='DESELECT')
 441      socket.select_set(True)
 442      body.select_set(True)
 443      tip.select_set(True)
 444      bpy.context.view_layer.objects.active = socket
 445      bpy.ops.object.join()
 446  
 447      obj = bpy.context.active_object
 448      obj.name = "Arrow_Point"
 449      assign_material(obj, BLACK)
 450      set_smooth(obj)
 451      link_to_collection(obj, col)
 452      return obj
 453  
 454  
 455  # ============================================================
 456  # 11. CAM-FLIGHT NOCK
 457  # ============================================================
 458  
 459  def make_cam_nock(col):
 460      # Cylinder base
 461      bpy.ops.mesh.primitive_cylinder_add(
 462          radius=0.006, depth=0.014, location=(0, 0, 0))
 463      base = bpy.context.active_object
 464      base.name = "Cam_Nock_base"
 465  
 466      # Wing 1
 467      bpy.ops.mesh.primitive_cube_add(size=1, location=(0.009, 0, -0.010))
 468      w1 = bpy.context.active_object
 469      w1.scale = (0.010, 0.00045, 0.0125)
 470      w1.name = "Cam_Nock_wing1"
 471      bpy.ops.object.transform_apply(scale=True)
 472  
 473      # Wing 2 (180 degrees opposite)
 474      bpy.ops.mesh.primitive_cube_add(size=1, location=(-0.009, 0, -0.010))
 475      w2 = bpy.context.active_object
 476      w2.scale = (0.010, 0.00045, 0.0125)
 477      w2.name = "Cam_Nock_wing2"
 478      bpy.ops.object.transform_apply(scale=True)
 479  
 480      # Join
 481      bpy.ops.object.select_all(action='DESELECT')
 482      base.select_set(True)
 483      w1.select_set(True)
 484      w2.select_set(True)
 485      bpy.context.view_layer.objects.active = base
 486      bpy.ops.object.join()
 487  
 488      obj = bpy.context.active_object
 489      obj.name = "Cam_Flight_Nock"
 490      assign_material(obj, BLACK)
 491      link_to_collection(obj, col)
 492      return obj
 493  
 494  
 495  # ============================================================
 496  # MAIN — Build all parts in exploded layout
 497  # ============================================================
 498  
 499  def main():
 500      clear_scene()
 501      setup_materials()
 502  
 503      # Collections for organization
 504      bow_col   = new_collection("Bow")
 505      hw_col    = new_collection("Hardware")
 506      str_col   = new_collection("String")
 507      arrow_col = new_collection("Arrow")
 508  
 509      E = 0.08  # explode spacing in meters
 510  
 511      # --- BOW ---
 512      riser = make_riser(bow_col)
 513  
 514      limb = make_limb_strip(bow_col)
 515      limb.location.z -= E  # offset below riser for exploded view
 516  
 517      # --- HARDWARE (3 sets) ---
 518      bolt_spacing = 0.030
 519      for i, offset in enumerate([-bolt_spacing, 0, bolt_spacing]):
 520          bolt = make_bolt(hw_col, f"Bolt_{i}")
 521          bolt.location = Vector((-E, offset, 0.028 + 0.01))
 522          bolt.rotation_euler[1] = -math.pi / 2
 523  
 524          insert = make_helicoil(hw_col, f"Helicoil_{i}")
 525          insert.location = Vector((E, offset, 0.014))
 526          insert.rotation_euler[1] = math.pi / 2
 527  
 528          bstack = make_belleville_stack(hw_col, f"Belleville_{i}")
 529          bstack.location = Vector((-E * 0.5, offset, 0.028 + 0.005))
 530  
 531      # --- STRING ---
 532      string = make_bowstring(str_col)
 533      string.location.x += E * 2
 534  
 535      d1 = make_string_damper(str_col, "Damper_Upper")
 536      d1.location = Vector((E * 2 + 0.019, 0.160, 0.030))
 537  
 538      d2 = make_string_damper(str_col, "Damper_Lower")
 539      d2.location = Vector((E * 2 + 0.019, -0.160, 0.030))
 540  
 541      nock_pt = make_nocking_point(str_col)
 542      nock_pt.location = Vector((E * 2 + 0.019, 0, 0.030))
 543  
 544      # --- ARROW (1 representative) ---
 545      shaft = make_arrow_shaft(arrow_col)
 546      shaft.location = Vector((0, -E * 5, 0.06))
 547  
 548      point = make_arrow_point(arrow_col)
 549      point.location = Vector((0, -E * 5 - 0.380, 0.06))
 550      point.rotation_euler[0] = math.pi / 2
 551  
 552      nock = make_cam_nock(arrow_col)
 553      nock.location = Vector((0, -E * 5 + 0.360, 0.06))
 554      nock.rotation_euler[0] = -math.pi / 2
 555  
 556      # Deselect all
 557      bpy.ops.object.select_all(action='DESELECT')
 558  
 559      print("Ambi-recurve bow: all parts created.")
 560      print("Parts are organized in collections: Bow, Hardware, String, Arrow")
 561      print("Select parts individually to inspect or edit meshes.")
 562  
 563  
 564  if __name__ == "__main__":
 565      main()
 566