struct_utils 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,755 @@
1
+ """
2
+ Bolt Pattern Force Distribution
3
+ ================================
4
+ Implements the methodology from:
5
+ https://mechanicalc.com/reference/bolt-pattern-force-distribution
6
+
7
+ Given a bolt pattern (positions + areas) and applied loads at arbitrary
8
+ locations, this module computes the axial and shear force on each bolt.
9
+
10
+ Coordinate system
11
+ -----------------
12
+ X, Y - in-plane axes (the plane of the bolt pattern)
13
+ Z - out-of-plane axis (bolt axis direction)
14
+
15
+ Applied load / moment sign convention follows the right-hand rule.
16
+
17
+ Usage example at the bottom of this file.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import math
23
+ from dataclasses import dataclass, field
24
+ from typing import Sequence
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Data structures
28
+ # ---------------------------------------------------------------------------
29
+
30
+ _bolt_counter = 0
31
+
32
+ @dataclass
33
+ class Bolt:
34
+ """A single bolt in the pattern.
35
+
36
+ Parameters
37
+ ----------
38
+ x, y : float
39
+ In-plane position of the bolt.
40
+ area : float
41
+ Tensile stress area of the bolt (default 1.0 for equal-size bolts).
42
+ label : str
43
+ Auto-assigned as B1, B2, ... if not provided.
44
+ """
45
+ x: float
46
+ y: float
47
+ area: float = 1.0
48
+ label: str = ""
49
+
50
+ def __post_init__(self):
51
+ global _bolt_counter
52
+ if not self.label:
53
+ _bolt_counter += 1
54
+ self.label = f"B{_bolt_counter}"
55
+
56
+
57
+ @dataclass
58
+ class AppliedLoad:
59
+ """A force/moment applied at a specific point.
60
+
61
+ Parameters
62
+ ----------
63
+ Fx, Fy, Fz : float
64
+ Force components (Fz is axial / out-of-plane).
65
+ Mx, My, Mz : float
66
+ Moment components about each axis (right-hand rule).
67
+ x, y, z : float
68
+ Location at which the load is applied.
69
+ The moments are transferred to the pattern centroid automatically.
70
+ """
71
+ Fx: float = 0.0
72
+ Fy: float = 0.0
73
+ Fz: float = 0.0
74
+ Mx: float = 0.0
75
+ My: float = 0.0
76
+ Mz: float = 0.0
77
+ x: float = 0.0
78
+ y: float = 0.0
79
+ z: float = 0.0
80
+
81
+
82
+ @dataclass
83
+ class BoltResult:
84
+ """Forces resolved onto a single bolt."""
85
+ bolt: Bolt
86
+ # Axial (Z-direction) components
87
+ Fz_direct: float = 0.0 # due to direct Fz at centroid
88
+ Fz_Mcx: float = 0.0 # due to centroidal moment about X
89
+ Fz_Mcy: float = 0.0 # due to centroidal moment about Y
90
+ # Shear components
91
+ Fxy_direct_x: float = 0.0 # direct Fx distributed by area
92
+ Fxy_direct_y: float = 0.0 # direct Fy distributed by area
93
+ Fxy_Mcz_x: float = 0.0 # torsional moment – X component
94
+ Fxy_Mcz_y: float = 0.0 # torsional moment – Y component
95
+
96
+ @property
97
+ def Fz_total(self) -> float:
98
+ """Total axial force on this bolt."""
99
+ return self.Fz_direct + self.Fz_Mcx + self.Fz_Mcy
100
+
101
+ @property
102
+ def Fx_total(self) -> float:
103
+ return self.Fxy_direct_x + self.Fxy_Mcz_x
104
+
105
+ @property
106
+ def Fy_total(self) -> float:
107
+ return self.Fxy_direct_y + self.Fxy_Mcz_y
108
+
109
+ @property
110
+ def F_shear(self) -> float:
111
+ """Resultant shear magnitude."""
112
+ return math.hypot(self.Fx_total, self.Fy_total)
113
+
114
+ @property
115
+ def F_total(self) -> float:
116
+ """Total resultant force magnitude."""
117
+ return math.sqrt(self.Fx_total**2 + self.Fy_total**2 + self.Fz_total**2)
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # Core computation
122
+ # ---------------------------------------------------------------------------
123
+
124
+ @dataclass
125
+ class BoltPatternAnalysis:
126
+ """Analyse force distribution over a bolt pattern.
127
+
128
+ Parameters
129
+ ----------
130
+ bolts : list[Bolt]
131
+ The bolt pattern.
132
+ loads : list[AppliedLoad]
133
+ All applied loads (can be at arbitrary locations).
134
+ """
135
+ bolts: list[Bolt]
136
+ loads: list[AppliedLoad] = field(default_factory=list)
137
+
138
+ # ---- Pattern properties (computed lazily) ----
139
+
140
+ @property
141
+ def total_area(self) -> float:
142
+ """Sum of bolt areas."""
143
+ return sum(b.area for b in self.bolts)
144
+
145
+ @property
146
+ def centroid(self) -> tuple[float, float]:
147
+ """(xc, yc) – area-weighted centroid of the bolt pattern."""
148
+ A = self.total_area
149
+ xc = sum(b.area * b.x for b in self.bolts) / A
150
+ yc = sum(b.area * b.y for b in self.bolts) / A
151
+ return xc, yc
152
+
153
+ @property
154
+ def Ic_x(self) -> float:
155
+ """Second moment of area about the centroidal X-axis (bending about X)."""
156
+ xc, yc = self.centroid
157
+ return sum(b.area * (b.y - yc)**2 for b in self.bolts)
158
+
159
+ @property
160
+ def Ic_y(self) -> float:
161
+ """Second moment of area about the centroidal Y-axis (bending about Y)."""
162
+ xc, yc = self.centroid
163
+ return sum(b.area * (b.x - xc)**2 for b in self.bolts)
164
+
165
+ @property
166
+ def Ic_p(self) -> float:
167
+ """Polar moment of area about the centroid (torsion about Z)."""
168
+ xc, yc = self.centroid
169
+ return sum(b.area * ((b.x - xc)**2 + (b.y - yc)**2) for b in self.bolts)
170
+
171
+ # ---- Translate all loads to the centroid ----
172
+
173
+ def centroidal_loads(self) -> tuple[float, float, float, float, float, float]:
174
+ """Return (Fc_x, Fc_y, Fc_z, Mc_x, Mc_y, Mc_z) at the pattern centroid.
175
+
176
+ Each applied load is moved to the centroid using the cross-product
177
+ transfer rule: M_new = M_applied + R x F
178
+ where R is the vector from the centroid to the load application point.
179
+ """
180
+ xc, yc = self.centroid
181
+
182
+ Fc_x = Fc_y = Fc_z = 0.0
183
+ Mc_x = Mc_y = Mc_z = 0.0
184
+
185
+ for load in self.loads:
186
+ # Direct force sums
187
+ Fc_x += load.Fx
188
+ Fc_y += load.Fy
189
+ Fc_z += load.Fz
190
+
191
+ # Moment transfer: R = (load.x - xc, load.y - yc, load.z - 0)
192
+ rx = load.x - xc
193
+ ry = load.y - yc
194
+ rz = load.z # centroid is at z=0 by convention
195
+
196
+ # Cross product R × F (right-hand rule)
197
+ cross_x = ry * load.Fz - rz * load.Fy
198
+ cross_y = rz * load.Fx - rx * load.Fz
199
+ cross_z = rx * load.Fy - ry * load.Fx
200
+
201
+ Mc_x += load.Mx + cross_x
202
+ Mc_y += load.My + cross_y
203
+ Mc_z += load.Mz + cross_z
204
+
205
+ return Fc_x, Fc_y, Fc_z, Mc_x, Mc_y, Mc_z
206
+
207
+ # ---- Distribute to individual bolts ----
208
+
209
+ def solve(self) -> list[BoltResult]:
210
+ """Compute and return the force on each bolt."""
211
+ xc, yc = self.centroid
212
+ A = self.total_area
213
+ Ic_x = self.Ic_x
214
+ Ic_y = self.Ic_y
215
+ Ic_p = self.Ic_p
216
+
217
+ Fc_x, Fc_y, Fc_z, Mc_x, Mc_y, Mc_z = self.centroidal_loads()
218
+
219
+ results = []
220
+ for b in self.bolts:
221
+ r = BoltResult(bolt=b)
222
+
223
+ # Distance of bolt from centroid
224
+ dx = b.x - xc # rc_y component (distance in X from centroid)
225
+ dy = b.y - yc # rc_x component (distance in Y from centroid)
226
+
227
+ # ------------------------------------------------------------------
228
+ # Axial forces (Z direction)
229
+ # ------------------------------------------------------------------
230
+ # 1. Direct Fz distributed proportional to bolt area
231
+ r.Fz_direct = (b.area / A) * Fc_z
232
+
233
+ # 2. Moment Mc_x about X-axis → tensile/compressive along Z
234
+ # F = (Mc_x * rc_x,i / Ic_x) * A_i
235
+ # rc_x,i = dy (distance from centroid in Y direction)
236
+ if abs(Ic_x) > 0:
237
+ r.Fz_Mcx = (Mc_x * dy / Ic_x) * b.area
238
+ else:
239
+ r.Fz_Mcx = 0.0
240
+
241
+ # 3. Moment Mc_y about Y-axis → tensile/compressive along Z
242
+ # rc_y,i = dx (distance from centroid in X direction)
243
+ # Note: sign convention – positive Mc_y acts in -Z for bolts at +X
244
+ if abs(Ic_y) > 0:
245
+ r.Fz_Mcy = -(Mc_y * dx / Ic_y) * b.area
246
+ else:
247
+ r.Fz_Mcy = 0.0
248
+
249
+ # ------------------------------------------------------------------
250
+ # Shear forces (X-Y plane)
251
+ # ------------------------------------------------------------------
252
+ # 1. Direct shear distributed proportional to bolt area
253
+ r.Fxy_direct_x = (b.area / A) * Fc_x
254
+ r.Fxy_direct_y = (b.area / A) * Fc_y
255
+
256
+ # 2. Torsional moment Mc_z – shear perpendicular to radius vector
257
+ # F_i = (Mc_z * r_i / Ic_p) * A_i (magnitude)
258
+ # Direction: perpendicular to (dx, dy), i.e. (-dy, dx) normalized
259
+ if abs(Ic_p) > 0:
260
+ scale = (Mc_z / Ic_p) * b.area
261
+ r.Fxy_Mcz_x = -scale * dy
262
+ r.Fxy_Mcz_y = scale * dx
263
+ else:
264
+ r.Fxy_Mcz_x = 0.0
265
+ r.Fxy_Mcz_y = 0.0
266
+
267
+ results.append(r)
268
+
269
+ return results
270
+
271
+
272
+ # ---------------------------------------------------------------------------
273
+ # Convenience function
274
+ # ---------------------------------------------------------------------------
275
+
276
+ def bolt_pattern_force_distribution(
277
+ bolt_positions: Sequence[tuple[float, float]],
278
+ applied_loads: Sequence[dict],
279
+ bolt_areas: Sequence[float] | None = None,
280
+ bolt_labels: Sequence[str] | None = None,
281
+ ) -> list[BoltResult]:
282
+ """High-level helper function.
283
+
284
+ Parameters
285
+ ----------
286
+ bolt_positions : list of (x, y)
287
+ In-plane coordinates of each bolt.
288
+ applied_loads : list of dicts
289
+ Each dict may contain keys: Fx, Fy, Fz, Mx, My, Mz, x, y, z.
290
+ Missing keys default to 0.
291
+ bolt_areas : list of float, optional
292
+ Tensile stress area of each bolt. Defaults to 1.0 for all bolts
293
+ (equal-size bolts, which simplifies area-weighted sums).
294
+ bolt_labels : list of str, optional
295
+ Human-readable names for the bolts.
296
+
297
+ Returns
298
+ -------
299
+ list[BoltResult]
300
+ One entry per bolt with decomposed and total forces.
301
+ """
302
+ n = len(bolt_positions)
303
+ if bolt_areas is None:
304
+ bolt_areas = [1.0] * n
305
+ if bolt_labels is None:
306
+ bolt_labels = [f"Bolt {i+1}" for i in range(n)]
307
+
308
+ bolts = [
309
+ Bolt(x=pos[0], y=pos[1], area=a, label=lbl)
310
+ for pos, a, lbl in zip(bolt_positions, bolt_areas, bolt_labels)
311
+ ]
312
+
313
+ loads = [
314
+ AppliedLoad(
315
+ Fx=ld.get("Fx", 0.0),
316
+ Fy=ld.get("Fy", 0.0),
317
+ Fz=ld.get("Fz", 0.0),
318
+ Mx=ld.get("Mx", 0.0),
319
+ My=ld.get("My", 0.0),
320
+ Mz=ld.get("Mz", 0.0),
321
+ x=ld.get("x", 0.0),
322
+ y=ld.get("y", 0.0),
323
+ z=ld.get("z", 0.0),
324
+ )
325
+ for ld in applied_loads
326
+ ]
327
+
328
+ analysis = BoltPatternAnalysis(bolts=bolts, loads=loads)
329
+ return analysis.solve()
330
+
331
+ # ---------------------------------------------------------------------------
332
+ # Interactive 3-D HTML visualisation (Plotly)
333
+ # ---------------------------------------------------------------------------
334
+
335
+
336
+
337
+ def plot_bolt_pattern_3d(
338
+ analysis: BoltPatternAnalysis,
339
+ results: list[BoltResult],
340
+ title: str = "Bolt Pattern Force Distribution",
341
+ show: bool = False,
342
+ save_dir: str | None = None, # folder to save into; defaults to cwd
343
+ ) -> "plotly.graph_objects.Figure":
344
+ """Render an interactive 3-D visualisation and save as a self-contained HTML file.
345
+
346
+ The HTML filename is derived automatically from *title* by lowercasing,
347
+ replacing spaces and special characters with underscores, and appending
348
+ ".html". For example "XM Bracket load case 3" becomes
349
+ "xm_bracket_load_case_3.html".
350
+
351
+ Arrow colour convention
352
+ -----------------------
353
+ Blue – shear resultant (in-plane)
354
+ Green – axial tension (+Z)
355
+ Red – axial compression (−Z)
356
+ Orange – resultant force vector
357
+ Purple – applied load arrows
358
+
359
+ Parameters
360
+ ----------
361
+ analysis : BoltPatternAnalysis
362
+ results : list[BoltResult] from analysis.solve()
363
+ title : str figure title; also used as the filename stem
364
+ show : bool open the figure in the default browser
365
+ save_dir : str | None folder to write the HTML file into;
366
+ defaults to the current working directory
367
+
368
+ Returns
369
+ -------
370
+ plotly.graph_objects.Figure
371
+ """
372
+ import re, os
373
+ import plotly.graph_objects as go
374
+
375
+ slug = re.sub(r"[^\w\s]", "", title)
376
+ slug = re.sub(r"\s+", "_", slug.strip()).lower() or "bolt_pattern"
377
+ folder = save_dir if save_dir is not None else "."
378
+ os.makedirs(folder, exist_ok=True)
379
+ save_path = os.path.join(folder, slug + ".html")
380
+
381
+ xc, yc = analysis.centroid
382
+ Fc_x, Fc_y, Fc_z, Mc_x, Mc_y, Mc_z = analysis.centroidal_loads()
383
+
384
+ # ── Scale arrows to ~40 % of pattern span ────────────────────────────────
385
+ bolt_xs = [b.bolt.x for b in results]
386
+ bolt_ys = [b.bolt.y for b in results]
387
+ span = max(max(bolt_xs) - min(bolt_xs), max(bolt_ys) - min(bolt_ys), 1e-6)
388
+ max_mag = max((r.F_total for r in results), default=1.0) or 1.0
389
+ arrow_scale = span * 0.40 / max_mag
390
+ cyl_h = span * 0.06 # bolt cylinder half-height
391
+
392
+ # ── Colour scale for bolt markers (total force magnitude) ─────────────────
393
+ ft_vals = [r.F_total for r in results]
394
+
395
+ traces = []
396
+
397
+ # ── Bolt plate (semi-transparent rectangle at Z=0) ────────────────────────
398
+ pad = span * 0.18
399
+ px = [min(bolt_xs)-pad, max(bolt_xs)+pad, max(bolt_xs)+pad, min(bolt_xs)-pad, min(bolt_xs)-pad]
400
+ py = [min(bolt_ys)-pad, min(bolt_ys)-pad, max(bolt_ys)+pad, max(bolt_ys)+pad, min(bolt_ys)-pad]
401
+ traces.append(go.Scatter3d(
402
+ x=px, y=py, z=[0]*5,
403
+ mode="lines",
404
+ line=dict(color="#58a6ff", width=2),
405
+ name="Plate outline",
406
+ showlegend=False,
407
+ ))
408
+ # Filled surface via Mesh3d (two triangles)
409
+ traces.append(go.Mesh3d(
410
+ x=[min(bolt_xs)-pad, max(bolt_xs)+pad, max(bolt_xs)+pad, min(bolt_xs)-pad],
411
+ y=[min(bolt_ys)-pad, min(bolt_ys)-pad, max(bolt_ys)+pad, max(bolt_ys)+pad],
412
+ z=[0, 0, 0, 0],
413
+ i=[0, 0], j=[1, 2], k=[2, 3],
414
+ color="#58a6ff", opacity=0.10,
415
+ name="Plate", showlegend=False, hoverinfo="skip",
416
+ ))
417
+
418
+ # ── Bolt cylinders (markers) ───────────────────────────────────────────────
419
+ traces.append(go.Scatter3d(
420
+ x=[r.bolt.x for r in results],
421
+ y=[r.bolt.y for r in results],
422
+ z=[cyl_h for r in results],
423
+ mode="markers+text",
424
+ marker=dict(
425
+ size=10,
426
+ color=ft_vals,
427
+ colorscale="Plasma",
428
+ cmin=0, cmax=max_mag,
429
+ colorbar=dict(
430
+ title=dict(text="|F_total|", font=dict(color="#e6edf3")),
431
+ thickness=14, len=0.55, x=1.01,
432
+ tickfont=dict(color="#e6edf3"),
433
+ ),
434
+ line=dict(color="#30363d", width=1),
435
+ ),
436
+ text=[r.bolt.label for r in results],
437
+ textposition="top center",
438
+ textfont=dict(color="#e6edf3", size=11),
439
+ name="Bolts",
440
+ customdata=[[r.Fz_total, r.Fx_total, r.Fy_total, r.F_shear, r.F_total]
441
+ for r in results],
442
+ hovertemplate=(
443
+ "<b>%{text}</b><br>"
444
+ "Fz = %{customdata[0]:.2f}<br>"
445
+ "Fx = %{customdata[1]:.2f}<br>"
446
+ "Fy = %{customdata[2]:.2f}<br>"
447
+ "|F_shear| = %{customdata[3]:.2f}<br>"
448
+ "|F_total| = %{customdata[4]:.2f}<extra></extra>"
449
+ ),
450
+ ))
451
+
452
+ # ── Helper: add a cone (arrow) trace ──────────────────────────────────────
453
+ def _arrow(ox, oy, oz, ux, uy, uz, color, name, legendgroup, showlegend=True):
454
+ mag = math.sqrt(ux**2 + uy**2 + uz**2)
455
+ if mag < 1e-9:
456
+ return
457
+ # Stem
458
+ traces.append(go.Scatter3d(
459
+ x=[ox, ox+ux], y=[oy, oy+uy], z=[oz, oz+uz],
460
+ mode="lines",
461
+ line=dict(color=color, width=4),
462
+ name=name, legendgroup=legendgroup,
463
+ showlegend=showlegend,
464
+ hoverinfo="skip",
465
+ ))
466
+ # Arrowhead cone
467
+ traces.append(go.Cone(
468
+ x=[ox+ux], y=[oy+uy], z=[oz+uz],
469
+ u=[ux*0.25], v=[uy*0.25], w=[uz*0.25],
470
+ colorscale=[[0, color], [1, color]],
471
+ showscale=False,
472
+ sizemode="absolute", sizeref=span*0.04,
473
+ name=name, legendgroup=legendgroup,
474
+ showlegend=False,
475
+ hoverinfo="skip",
476
+ ))
477
+
478
+ # ── Per-bolt force arrows ─────────────────────────────────────────────────
479
+ for i, r in enumerate(results):
480
+ bx, by = r.bolt.x, r.bolt.y
481
+ oz = cyl_h
482
+ first = (i == 0) # only first bolt contributes legend entry per group
483
+
484
+ # Shear
485
+ fs = r.F_shear
486
+ if fs > 1e-9:
487
+ _arrow(bx, by, oz,
488
+ r.Fx_total * arrow_scale, r.Fy_total * arrow_scale, 0,
489
+ "#388bfd", "Shear", "shear", showlegend=first)
490
+
491
+ # Axial
492
+ fz = r.Fz_total
493
+ if abs(fz) > 1e-9:
494
+ color = "#3fb950" if fz > 0 else "#f85149"
495
+ name = "Axial +Z (tension)" if fz > 0 else "Axial −Z (compression)"
496
+ group = "axial_pos" if fz > 0 else "axial_neg"
497
+ _arrow(bx, by, oz, 0, 0, fz * arrow_scale,
498
+ color, name, group, showlegend=first)
499
+
500
+ # Resultant
501
+ ft = r.F_total
502
+ if ft > 1e-9:
503
+ _arrow(bx, by, oz,
504
+ r.Fx_total * arrow_scale,
505
+ r.Fy_total * arrow_scale,
506
+ r.Fz_total * arrow_scale,
507
+ "#ffa657", "Resultant", "resultant", showlegend=first)
508
+
509
+ # ── Applied load arrows (from their 3-D application point) ───────────────
510
+ app_mags = [math.sqrt(l.Fx**2 + l.Fy**2 + l.Fz**2) for l in analysis.loads]
511
+ app_max = max(app_mags, default=1.0) or 1.0
512
+ app_scale = span * 0.35 / app_max
513
+ for i, (ld, mag) in enumerate(zip(analysis.loads, app_mags)):
514
+ if mag < 1e-9:
515
+ continue
516
+ _arrow(ld.x, ld.y, ld.z,
517
+ ld.Fx * app_scale, ld.Fy * app_scale, ld.Fz * app_scale,
518
+ "#bc8cff", "Applied load", "applied", showlegend=(i == 0))
519
+
520
+ # ── Applied moment arcs ───────────────────────────────────────────────────
521
+ # Each moment vector M = (Mx, My, Mz) is drawn as a 270° circular arc in
522
+ # the plane whose normal is M̂, centred at the load application point.
523
+ # A cone arrowhead at the arc tip shows the right-hand-rule direction.
524
+ def _moment_arc(ox, oy, oz, mx, my, mz, color, name, legendgroup, showlegend=True):
525
+ mag = math.sqrt(mx**2 + my**2 + mz**2)
526
+ if mag < 1e-9:
527
+ return
528
+
529
+ # Unit moment axis (right-hand rule: curl fingers → rotation direction)
530
+ ax_hat = (mx / mag, my / mag, mz / mag)
531
+
532
+ # Build two orthogonal vectors in the plane perpendicular to ax_hat
533
+ # Use a helper vector that is not parallel to ax_hat
534
+ helper = (0.0, 0.0, 1.0) if abs(ax_hat[2]) < 0.9 else (1.0, 0.0, 0.0)
535
+ # u1 = helper × ax_hat (in-plane basis vector 1)
536
+ u1 = (
537
+ helper[1]*ax_hat[2] - helper[2]*ax_hat[1],
538
+ helper[2]*ax_hat[0] - helper[0]*ax_hat[2],
539
+ helper[0]*ax_hat[1] - helper[1]*ax_hat[0],
540
+ )
541
+ n1 = math.sqrt(u1[0]**2 + u1[1]**2 + u1[2]**2)
542
+ u1 = tuple(c / n1 for c in u1)
543
+ # u2 = ax_hat × u1 (in-plane basis vector 2)
544
+ u2 = (
545
+ ax_hat[1]*u1[2] - ax_hat[2]*u1[1],
546
+ ax_hat[2]*u1[0] - ax_hat[0]*u1[2],
547
+ ax_hat[0]*u1[1] - ax_hat[1]*u1[0],
548
+ )
549
+
550
+ # Arc radius proportional to pattern span; 270° sweep (leave gap to read direction)
551
+ r_arc = span * 0.22
552
+ n_pts = 48
553
+ sweep = 1.5 * math.pi # 270°
554
+ angles = [sweep * k / (n_pts - 1) for k in range(n_pts)]
555
+
556
+ arc_x = [ox + r_arc * (math.cos(a)*u1[0] + math.sin(a)*u2[0]) for a in angles]
557
+ arc_y = [oy + r_arc * (math.cos(a)*u1[1] + math.sin(a)*u2[1]) for a in angles]
558
+ arc_z = [oz + r_arc * (math.cos(a)*u1[2] + math.sin(a)*u2[2]) for a in angles]
559
+
560
+ traces.append(go.Scatter3d(
561
+ x=arc_x, y=arc_y, z=arc_z,
562
+ mode="lines",
563
+ line=dict(color=color, width=4),
564
+ name=name, legendgroup=legendgroup,
565
+ showlegend=showlegend,
566
+ hovertemplate=f"M=({mx:.2f}, {my:.2f}, {mz:.2f})<extra>{name}</extra>",
567
+ ))
568
+
569
+ # Arrowhead cone tangent to the arc at its tip
570
+ # Tangent direction = d/da [cos(a)*u1 + sin(a)*u2] at a=sweep
571
+ # = -sin(sweep)*u1 + cos(sweep)*u2
572
+ tx = -math.sin(sweep)*u1[0] + math.cos(sweep)*u2[0]
573
+ ty = -math.sin(sweep)*u1[1] + math.cos(sweep)*u2[1]
574
+ tz = -math.sin(sweep)*u1[2] + math.cos(sweep)*u2[2]
575
+ cone_size = span * 0.06
576
+ traces.append(go.Cone(
577
+ x=[arc_x[-1]], y=[arc_y[-1]], z=[arc_z[-1]],
578
+ u=[tx * cone_size], v=[ty * cone_size], w=[tz * cone_size],
579
+ colorscale=[[0, color], [1, color]],
580
+ showscale=False,
581
+ sizemode="absolute", sizeref=cone_size,
582
+ name=name, legendgroup=legendgroup,
583
+ showlegend=False,
584
+ hoverinfo="skip",
585
+ ))
586
+
587
+ # Centre dot at application point
588
+ traces.append(go.Scatter3d(
589
+ x=[ox], y=[oy], z=[oz],
590
+ mode="markers",
591
+ marker=dict(symbol="circle", size=5, color=color),
592
+ name=name, legendgroup=legendgroup,
593
+ showlegend=False, hoverinfo="skip",
594
+ ))
595
+
596
+ mom_mags = [math.sqrt(l.Mx**2 + l.My**2 + l.Mz**2) for l in analysis.loads]
597
+ first_moment = True
598
+ for ld, mmag in zip(analysis.loads, mom_mags):
599
+ if mmag < 1e-9:
600
+ continue
601
+ _moment_arc(ld.x, ld.y, ld.z,
602
+ ld.Mx, ld.My, ld.Mz,
603
+ "#ff9f43", "Applied moment", "applied_m",
604
+ showlegend=first_moment)
605
+ first_moment = False
606
+
607
+ # ── Centroid marker ───────────────────────────────────────────────────────
608
+ traces.append(go.Scatter3d(
609
+ x=[xc], y=[yc], z=[0],
610
+ mode="markers+text",
611
+ marker=dict(symbol="cross", size=8, color="#d2a8ff",
612
+ line=dict(color="#d2a8ff", width=2)),
613
+ text=["centroid"], textposition="top center",
614
+ textfont=dict(color="#d2a8ff", size=9),
615
+ name="Centroid", showlegend=True,
616
+ hovertemplate=f"Centroid ({xc:.3f}, {yc:.3f})<extra></extra>",
617
+ ))
618
+
619
+ # ── Layout ────────────────────────────────────────────────────────────────
620
+ # Build subtitle with centroidal loads
621
+ subtitle = (f"Centroid ({xc:.3f}, {yc:.3f}) | "
622
+ f"Fc=({Fc_x:.1f}, {Fc_y:.1f}, {Fc_z:.1f}) | "
623
+ f"Mc=({Mc_x:.1f}, {Mc_y:.1f}, {Mc_z:.1f})")
624
+
625
+ fig = go.Figure(data=traces)
626
+ fig.update_layout(
627
+ title=dict(
628
+ text=f"<b>{title}</b><br><sup>{subtitle}</sup>",
629
+ font=dict(color="#e6edf3", size=14),
630
+ x=0.5, xanchor="center",
631
+ ),
632
+ scene=dict(
633
+ xaxis=dict(title="X", backgroundcolor="#161b22",
634
+ gridcolor="#21262d", showbackground=True,
635
+ tickfont=dict(color="#8b949e")),
636
+ yaxis=dict(title="Y", backgroundcolor="#161b22",
637
+ gridcolor="#21262d", showbackground=True,
638
+ tickfont=dict(color="#8b949e")),
639
+ zaxis=dict(title="Z (axial)", backgroundcolor="#161b22",
640
+ gridcolor="#21262d", showbackground=True,
641
+ tickfont=dict(color="#8b949e")),
642
+ bgcolor="#0d1117",
643
+ camera=dict(eye=dict(x=1.4, y=-1.6, z=1.0)),
644
+ aspectmode="data",
645
+ ),
646
+ paper_bgcolor="#0d1117",
647
+ plot_bgcolor="#0d1117",
648
+ font=dict(color="#e6edf3"),
649
+ legend=dict(
650
+ bgcolor="#161b22", bordercolor="#30363d", borderwidth=1,
651
+ font=dict(color="#e6edf3", size=11),
652
+ x=0.01, y=0.99, xanchor="left", yanchor="top",
653
+ ),
654
+ margin=dict(l=0, r=0, t=80, b=0),
655
+ )
656
+
657
+ fig.write_html(save_path, include_plotlyjs="cdn", full_html=True)
658
+ print(f" [bolt_pattern] HTML saved → {os.path.abspath(save_path)}")
659
+
660
+ if show:
661
+ fig.show()
662
+
663
+ return fig
664
+
665
+
666
+ # ---------------------------------------------------------------------------
667
+ # Console results printer
668
+ # ---------------------------------------------------------------------------
669
+
670
+ def print_results(results: list[BoltResult], analysis: BoltPatternAnalysis) -> None:
671
+ xc, yc = analysis.centroid
672
+ Fc_x, Fc_y, Fc_z, Mc_x, Mc_y, Mc_z = analysis.centroidal_loads()
673
+ max_tension = max(results_1, key=lambda r: r.Fz_total)
674
+ min_tension = min(results_1, key=lambda r: r.Fz_total)
675
+ max_shear = max(results_1, key=lambda r: r.F_shear)
676
+ min_shear = min(results_1, key=lambda r: r.F_shear)
677
+
678
+
679
+ print("=" * 60)
680
+ print("BOLT PATTERN PROPERTIES")
681
+ print(f" Total area A = {analysis.total_area:.4f}")
682
+ print(f" Centroid = ({xc:.4f}, {yc:.4f})")
683
+ print(f" Ic_x = {analysis.Ic_x:.4f}")
684
+ print(f" Ic_y = {analysis.Ic_y:.4f}")
685
+ print(f" Ic_p (polar) = {analysis.Ic_p:.4f}")
686
+ print()
687
+ print("CENTROIDAL LOADS")
688
+ print(f" Fc_x={Fc_x:.3f} Fc_y={Fc_y:.3f} Fc_z={Fc_z:.3f}")
689
+ print(f" Mc_x={Mc_x:.3f} Mc_y={Mc_y:.3f} Mc_z={Mc_z:.3f}")
690
+ print()
691
+ print("SUMMARY")
692
+ print(f" {'Max fastener tensile load':<28} {max_tension.bolt.label:>6} Fz = {max_tension.Fz_total:>+8.2f}")
693
+ print(f" {'Min fastener tensile load':<28} {min_tension.bolt.label:>6} Fz = {min_tension.Fz_total:>+8.2f}")
694
+ print(f" {'Max fastener shear load':<28} {max_shear.bolt.label:>6} Fs = {max_shear.F_shear:>8.2f}")
695
+ print(f" {'Min fastener shear load':<28} {min_shear.bolt.label:>6} Fs = {min_shear.F_shear:>8.2f}")
696
+ print()
697
+ print("BOLT FORCES")
698
+ hdr = (f"{'Bolt':<10} {'Fz_total':>10} {'Fx_total':>10} "
699
+ f"{'Fy_total':>10} {'F_shear':>10} {'F_total':>10}")
700
+ print(hdr)
701
+ print("-" * 60)
702
+ for r in results:
703
+ label = r.bolt.label or f"({r.bolt.x},{r.bolt.y})"
704
+ print(f"{label:<10} {r.Fz_total:>10.4f} {r.Fx_total:>10.4f} "
705
+ f"{r.Fy_total:>10.4f} {r.F_shear:>10.4f} {r.F_total:>10.4f}")
706
+ print("=" * 60)
707
+
708
+
709
+ # ---------------------------------------------------------------------------
710
+ # Demo / self-test
711
+ # ---------------------------------------------------------------------------
712
+
713
+
714
+ if __name__ == "__main__":
715
+ import os
716
+ HERE = os.path.dirname(os.path.abspath(__file__))
717
+
718
+ positions_1 = [(-3.5, 15), (3.5, 15), (-3.5, -15), (3.5, -15)
719
+ , (10, -15), (18.82, -12.14), (24.27, -4.635)
720
+ , (24.27, 4.635), (18.82, 12.14), (10, 15)
721
+ , (-10, 15), (-18.82, 12.14), (-24.27, 4.635)
722
+ , (-24.27, -4.635), (-18.82, -12.14), (-10, -15)] #[x,y]
723
+
724
+ # ------------------------------------------------------------------
725
+ # Example 1: 4-bolt rectangular pattern, in-plane eccentric shear
726
+ # A 1000 N force applied in X at (y=5) – creates torsion about Z
727
+ # ------------------------------------------------------------------
728
+ print("\nEXAMPLE 1 – General 3-D loading")
729
+ title="bolt_pattern_ex1"
730
+ analysis_1 = BoltPatternAnalysis(
731
+ bolts=[Bolt(x, y) for (x, y) in positions_1],
732
+ loads=[AppliedLoad(Fx=1500.0, Fy=500.0, Fz=5000.0, z=15.0),
733
+ AppliedLoad(Mz=1000.0)],
734
+ )
735
+ save_dir=os.path.join(HERE)
736
+ results_1 = analysis_1.solve()
737
+ print_results(results_1, analysis_1)
738
+ plot_bolt_pattern_3d(
739
+ analysis_1, results_1,
740
+ title=title,
741
+ show=False,
742
+ save_dir=save_dir,
743
+ )
744
+
745
+
746
+
747
+
748
+
749
+
750
+
751
+
752
+
753
+
754
+
755
+