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