loadpath 0.1.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.
Files changed (220) hide show
  1. loadpath/README.md +294 -0
  2. loadpath/SKILL.md +584 -0
  3. loadpath/__init__.py +17 -0
  4. loadpath/__main__.py +1202 -0
  5. loadpath/am.py +316 -0
  6. loadpath/amgfast.py +326 -0
  7. loadpath/assembly.py +985 -0
  8. loadpath/benchmarks/__init__.py +0 -0
  9. loadpath/benchmarks/beams.py +266 -0
  10. loadpath/benchmarks/cases/ge-bracket.json +380 -0
  11. loadpath/benchmarks/evaluate.py +172 -0
  12. loadpath/benchmarks/mechanisms.py +416 -0
  13. loadpath/benchmarks/problems.py +140 -0
  14. loadpath/benchmarks/reference/bridge-3d.top3d.npz +0 -0
  15. loadpath/benchmarks/reference/cantilever-3d.top3d.npz +0 -0
  16. loadpath/benchmarks/reference/lbracket-2d.top88-ft2.npz +0 -0
  17. loadpath/benchmarks/reference/mbb-2d.top88-ft1.npz +0 -0
  18. loadpath/benchmarks/reference/mbb-2d.top88-ft2.npz +0 -0
  19. loadpath/benchmarks/reference.py +138 -0
  20. loadpath/benchmarks/run.py +301 -0
  21. loadpath/brep.py +1470 -0
  22. loadpath/bucklingopt.py +300 -0
  23. loadpath/buildsim.py +1490 -0
  24. loadpath/cadimport.py +904 -0
  25. loadpath/calibration.py +885 -0
  26. loadpath/cavities.py +211 -0
  27. loadpath/checks/__init__.py +0 -0
  28. loadpath/checks/dfm.py +354 -0
  29. loadpath/checks/exchanger.py +7 -0
  30. loadpath/checks/fea.py +212 -0
  31. loadpath/checks/fit.py +475 -0
  32. loadpath/checks/networks.py +260 -0
  33. loadpath/checks/opening.py +67 -0
  34. loadpath/checks/sdf_kit.py +7 -0
  35. loadpath/conjugate.py +267 -0
  36. loadpath/constrained.py +329 -0
  37. loadpath/contact.py +691 -0
  38. loadpath/coopt.py +342 -0
  39. loadpath/cores.py +163 -0
  40. loadpath/crease.py +1138 -0
  41. loadpath/critic.py +212 -0
  42. loadpath/crosscheck.py +1107 -0
  43. loadpath/cutcell.py +510 -0
  44. loadpath/data/lattice_buckling.json +439 -0
  45. loadpath/data/lattice_tensors.json +27449 -0
  46. loadpath/data/published_experiments.json +71 -0
  47. loadpath/decimate.py +177 -0
  48. loadpath/delivery.py +190 -0
  49. loadpath/directions.py +262 -0
  50. loadpath/domain.py +630 -0
  51. loadpath/drains.py +615 -0
  52. loadpath/durability.py +413 -0
  53. loadpath/editor.html +3135 -0
  54. loadpath/editor.py +610 -0
  55. loadpath/escape.py +354 -0
  56. loadpath/evals/__init__.py +0 -0
  57. loadpath/evals/baselines/coarse.json +1543 -0
  58. loadpath/evals/baselines/full.json +1497 -0
  59. loadpath/evals/brief_kinds/__init__.py +1 -0
  60. loadpath/evals/brief_kinds/heatx.py +387 -0
  61. loadpath/evals/brief_kinds/lattice.py +571 -0
  62. loadpath/evals/brief_kinds/multimat.py +593 -0
  63. loadpath/evals/briefs/assets/bracket.step +1201 -0
  64. loadpath/evals/briefs/assets/desk_arm.urdf +22 -0
  65. loadpath/evals/briefs/assets/elbow-gearbox.stl +0 -0
  66. loadpath/evals/briefs/assets/motor-body.stl +0 -0
  67. loadpath/evals/briefs/assets/sketch.png +0 -0
  68. loadpath/evals/briefs/base-adapter-fdm.json +492 -0
  69. loadpath/evals/briefs/bell-crank-lpbf.json +180 -0
  70. loadpath/evals/briefs/belt-tensioner-fdm.json +151 -0
  71. loadpath/evals/briefs/camera-boom-mjf.json +339 -0
  72. loadpath/evals/briefs/camera-mount-pla.json +203 -0
  73. loadpath/evals/briefs/coolant-isolator-petg.json +100 -0
  74. loadpath/evals/briefs/elbow-bearing-bracket-sls.json +300 -0
  75. loadpath/evals/briefs/elbow-gearbox-forearm-mjf.json +558 -0
  76. loadpath/evals/briefs/elbow-link-fatigue-fdm.json +251 -0
  77. loadpath/evals/briefs/elbow-stop-pad-tpu.json +39 -0
  78. loadpath/evals/briefs/forearm-fdm.json +146 -0
  79. loadpath/evals/briefs/forearm-servo-link-fdm.json +327 -0
  80. loadpath/evals/briefs/grip-pad-finger-mm.json +354 -0
  81. loadpath/evals/briefs/gripper-bracket-creep-fdm.json +340 -0
  82. loadpath/evals/briefs/gripper-finger-fdm.json +155 -0
  83. loadpath/evals/briefs/gripper-jaw-fatigue-mjf.json +328 -0
  84. loadpath/evals/briefs/gripper-palm-fdm.json +715 -0
  85. loadpath/evals/briefs/hot-stepper-mount-asa-fdm.json +471 -0
  86. loadpath/evals/briefs/hpu-oil-cooler-lpbf.json +121 -0
  87. loadpath/evals/briefs/isolated-camera-mount-mm.json +675 -0
  88. loadpath/evals/briefs/kart-charge-cooler-lpbf.json +93 -0
  89. loadpath/evals/briefs/lighten-step-bracket.json +220 -0
  90. loadpath/evals/briefs/nema17-mount-stl.json +429 -0
  91. loadpath/evals/briefs/pillow-block-sls.json +220 -0
  92. loadpath/evals/briefs/polyjet-fingertip-mm.json +305 -0
  93. loadpath/evals/briefs/rod-hook-pla.json +154 -0
  94. loadpath/evals/briefs/sensor-arm-resin.json +150 -0
  95. loadpath/evals/briefs/shelf-bracket-pla.json +158 -0
  96. loadpath/evals/briefs/shoulder-riser-mjf.json +487 -0
  97. loadpath/evals/briefs/sketch-clevis.json +229 -0
  98. loadpath/evals/briefs/spring-bracket-pacf.json +294 -0
  99. loadpath/evals/briefs/tool-flange-ti64.json +280 -0
  100. loadpath/evals/briefs/tray-arm-lattice-fdm.json +260 -0
  101. loadpath/evals/briefs/turret-fdm.json +281 -0
  102. loadpath/evals/briefs/upper-arm-sls.json +179 -0
  103. loadpath/evals/briefs/urdf-forearm.json +233 -0
  104. loadpath/evals/briefs/wrist-link-mjf.json +278 -0
  105. loadpath/evals/briefs/wrist-yoke-mjf.json +245 -0
  106. loadpath/evals/briefs.py +393 -0
  107. loadpath/evals/cases/arm-link-cast.json +157 -0
  108. loadpath/evals/cases/arm-link-driven.json +171 -0
  109. loadpath/evals/cases/arm-link-sandwich.json +315 -0
  110. loadpath/evals/cases/arm-link.json +111 -0
  111. loadpath/evals/cases/bell-crank.json +116 -0
  112. loadpath/evals/cases/grip-turret.json +479 -0
  113. loadpath/evals/cases/grip-upper-arm.json +253 -0
  114. loadpath/evals/cases/grip-wrist-link-mjf.json +929 -0
  115. loadpath/evals/cases/grip-wrist-yoke.json +831 -0
  116. loadpath/evals/cases/lpbf-intercooler-core.json +59 -0
  117. loadpath/evals/cases/mbb-beam.json +61 -0
  118. loadpath/evals/cases/motor-body.stl +0 -0
  119. loadpath/evals/cases/motor-mount.json +265 -0
  120. loadpath/evals/cases/printed-hook.json +77 -0
  121. loadpath/evals/cases/wall-bracket-fasteners.json +125 -0
  122. loadpath/evals/cases/wall-bracket.json +126 -0
  123. loadpath/evals/corpus.py +179 -0
  124. loadpath/evals/pareto.py +90 -0
  125. loadpath/evals/run.py +860 -0
  126. loadpath/examples/arm-link.json +25 -0
  127. loadpath/examples/bumpered-bracket-compression.json +225 -0
  128. loadpath/examples/bumpered-bracket-lattice.json +204 -0
  129. loadpath/examples/bumpered-bracket.json +34 -0
  130. loadpath/examples/gripper-finger-mm.json +185 -0
  131. loadpath/examples/heat-exchanger-counterflow.json +21 -0
  132. loadpath/examples/heat-exchanger-crossflow-tuned.json +48 -0
  133. loadpath/examples/heat-exchanger-crossflow.json +20 -0
  134. loadpath/examples/pedal-lattice.json +23 -0
  135. loadpath/examples/pressure-plenum.json +12 -0
  136. loadpath/examples/shelf-bracket.json +27 -0
  137. loadpath/exchanger.py +831 -0
  138. loadpath/fasteners.py +199 -0
  139. loadpath/fem.py +668 -0
  140. loadpath/femops.py +70 -0
  141. loadpath/fieldjit.py +981 -0
  142. loadpath/fieldops.py +119 -0
  143. loadpath/fields.py +3697 -0
  144. loadpath/fieldtorch.py +464 -0
  145. loadpath/flow.py +984 -0
  146. loadpath/flowtopo.py +300 -0
  147. loadpath/gpu.py +521 -0
  148. loadpath/heatx.py +1067 -0
  149. loadpath/heatx_thermal.py +432 -0
  150. loadpath/homogenized.py +886 -0
  151. loadpath/ibrep.py +552 -0
  152. loadpath/impact.py +693 -0
  153. loadpath/infill.py +376 -0
  154. loadpath/interface_library.py +340 -0
  155. loadpath/interlock.py +186 -0
  156. loadpath/isolator.py +149 -0
  157. loadpath/knn.py +204 -0
  158. loadpath/lattice.py +1065 -0
  159. loadpath/lattice3mf.py +760 -0
  160. loadpath/lattice_buckling.py +245 -0
  161. loadpath/lattice_grade.py +325 -0
  162. loadpath/lattice_orient.py +595 -0
  163. loadpath/lattice_print.py +159 -0
  164. loadpath/lattice_tensors.py +558 -0
  165. loadpath/latticestep.py +1210 -0
  166. loadpath/manufacture.py +446 -0
  167. loadpath/material_library.py +489 -0
  168. loadpath/materials.py +144 -0
  169. loadpath/mcp_server.py +786 -0
  170. loadpath/mechanism.py +218 -0
  171. loadpath/mechanism_nl.py +811 -0
  172. loadpath/meshops.py +452 -0
  173. loadpath/mg.py +853 -0
  174. loadpath/mma.py +214 -0
  175. loadpath/modal.py +210 -0
  176. loadpath/multimat.py +1788 -0
  177. loadpath/narrowband.py +418 -0
  178. loadpath/nlkernel.py +241 -0
  179. loadpath/nonlinear.py +1095 -0
  180. loadpath/orient.py +334 -0
  181. loadpath/porous.py +994 -0
  182. loadpath/pressure.py +171 -0
  183. loadpath/preview.py +335 -0
  184. loadpath/printcheck.py +263 -0
  185. loadpath/printsetup.py +207 -0
  186. loadpath/processcheck.py +376 -0
  187. loadpath/quick.py +113 -0
  188. loadpath/recall.py +406 -0
  189. loadpath/reconstruct.py +797 -0
  190. loadpath/render.py +436 -0
  191. loadpath/resolved.py +189 -0
  192. loadpath/schema.py +1701 -0
  193. loadpath/sdf_kit.py +843 -0
  194. loadpath/selfx.py +292 -0
  195. loadpath/shapes.py +584 -0
  196. loadpath/sharp.py +717 -0
  197. loadpath/simp.py +1170 -0
  198. loadpath/sizing.py +312 -0
  199. loadpath/slicer.py +321 -0
  200. loadpath/spec.py +1072 -0
  201. loadpath/step.py +806 -0
  202. loadpath/struts.py +639 -0
  203. loadpath/study.py +1937 -0
  204. loadpath/tasks.py +822 -0
  205. loadpath/thermal.py +1691 -0
  206. loadpath/threemf.py +487 -0
  207. loadpath/tune.py +437 -0
  208. loadpath/validation.py +145 -0
  209. loadpath/verify.py +1215 -0
  210. loadpath/viewer.html +694 -0
  211. loadpath/viewer.py +446 -0
  212. loadpath/viscoelastic.py +106 -0
  213. loadpath/voxelprint.py +582 -0
  214. loadpath/voxels.py +402 -0
  215. loadpath-0.1.1.dist-info/METADATA +319 -0
  216. loadpath-0.1.1.dist-info/RECORD +220 -0
  217. loadpath-0.1.1.dist-info/WHEEL +5 -0
  218. loadpath-0.1.1.dist-info/entry_points.txt +2 -0
  219. loadpath-0.1.1.dist-info/licenses/LICENSE +21 -0
  220. loadpath-0.1.1.dist-info/top_level.txt +1 -0
loadpath/cadimport.py ADDED
@@ -0,0 +1,904 @@
1
+ """
2
+ CAD in: a STEP (or STL/3MF/OBJ) part as a study's design space, with its
3
+ interfaces found for you.
4
+
5
+ Fusion's generative workspace asks you to click the faces a part bolts on
6
+ by, the bores its shafts run in and the faces it must not grow over. Most of
7
+ that is in the geometry already. `import_part` reads the part, heals it
8
+ (real exports are often open shells: five of GRIP's link STEPs are), and
9
+ finds:
10
+
11
+ - bores: concave cylinders, merged across split faces and stacked with
12
+ their coaxial steps (a counterbore is one bore with two segments), with
13
+ their axis, extent, through or blind, and "bolt" (through radius up to
14
+ M6 clearance) or "bore" (a bearing seat, a shaft, a socket);
15
+ - flat faces the part can sit on: planar, exposed (nothing of the part in
16
+ front of them), with the bolt holes that open onto each;
17
+ - interfaces: a face with its bolt pattern, a bolt pattern on its own, or
18
+ a bore. These are where a part meets the rest of the machine.
19
+
20
+ `draft_study` turns them into a study an agent finishes: the part (or its
21
+ hull or box) as the design space, each interface's material preserved (a
22
+ ring round every bore and a pad under every bolted face, cut to the part),
23
+ every bore a keep-out, mating faces cleared when the space grows past the
24
+ part, and a suggested support and load: the strongest bolted interface
25
+ holds the part, the interface farthest from it takes a placeholder force
26
+ across the lever. The force is a placeholder: features.json lists every
27
+ interface so the real loads go on the right ones.
28
+
29
+ From a STEP file the bores and faces are exact (OpenCascade surface types);
30
+ from a mesh they are fitted (circles to smooth patches, coplanar facets).
31
+ STEP needs `pip install "loadpath[step]"` (cadquery-ocp, shared with
32
+ step.py's export).
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import json
37
+ import math
38
+ import os
39
+ from dataclasses import dataclass, field
40
+
41
+ import numpy as np
42
+
43
+ __all__ = ["Part", "load_part", "detect_features", "suggest_setup", "summarize", "draft_study", "import_part",
44
+ "heal_mesh"]
45
+
46
+ STEP_EXT = (".step", ".stp")
47
+ # mm chord error of the STEP tessellation: a tenth of the finest grid the
48
+ # optimizer runs on, and coarse enough to tessellate a GRIP link in a second.
49
+ DEFAULT_TOLERANCE = 0.05
50
+ # mm: through radius up to an M6 clearance hole (3.2) counts as a bolt
51
+ BOLT_MAX_R = 3.6
52
+ # mm: a plain bore narrower than this (a cable pass, a pilot hole) is kept
53
+ # open but not ringed with preserved material unless it is loaded or holds:
54
+ # a ring there forces a strut out to it (GRIP's yoke grew one to a 4 mm pass)
55
+ SEAT_MIN_R = 5.0
56
+ # rad: a cylinder whose faces together cover less than this is a fillet or a
57
+ # slot end, not a hole
58
+ FULL_SPAN = 1.8 * math.pi
59
+ # a gap wider than this (mm) was a missing face, not a crack: say so
60
+ HEAL_WARN = 5.0
61
+
62
+
63
+ @dataclass
64
+ class Part:
65
+ mesh: object # watertight trimesh
66
+ path: str | None = None
67
+ brep: object = None # OpenCascade shape (STEP only)
68
+ face_of: np.ndarray | None = None # B-rep face index of each triangle (STEP only)
69
+ healed: dict = field(default_factory=dict)
70
+
71
+
72
+ # ---- reading and healing -------------------------------------------------------------
73
+
74
+ def heal_mesh(mesh):
75
+ """Close a nearly closed mesh: every boundary loop is fanned from its
76
+ centroid. Cracks along unsewn edges and small missing faces close with a
77
+ negligible change in volume; returns (mesh, report)."""
78
+ import trimesh
79
+ from collections import defaultdict
80
+
81
+ if mesh.is_watertight:
82
+ return mesh, {"loops": 0}
83
+ e = mesh.edges
84
+ es = np.sort(e, axis=1)
85
+ _, inv, cnt = np.unique(es, axis=0, return_inverse=True, return_counts=True)
86
+ b = e[cnt[inv.ravel()] == 1]
87
+ nxt = defaultdict(list)
88
+ for a, c in b:
89
+ nxt[int(a)].append(int(c))
90
+ used, loops = set(), []
91
+ for a, c in b:
92
+ a, c = int(a), int(c)
93
+ if (a, c) in used:
94
+ continue
95
+ loop, cur = [a], (a, c)
96
+ while cur not in used:
97
+ used.add(cur)
98
+ v = cur[1]
99
+ if v == loop[0]:
100
+ break
101
+ loop.append(v)
102
+ nx = [w for w in nxt[v] if (v, w) not in used]
103
+ if not nx:
104
+ loop = None
105
+ break
106
+ cur = (v, nx[0])
107
+ if loop and len(loop) >= 3:
108
+ loops.append(loop)
109
+ V = [np.asarray(mesh.vertices, float)]
110
+ F = [np.asarray(mesh.faces, np.int64)]
111
+ n = len(mesh.vertices)
112
+ widest = 0.0
113
+ for L in loops:
114
+ P = mesh.vertices[L]
115
+ widest = max(widest, float(np.ptp(P, axis=0).max()))
116
+ V.append(P.mean(0)[None])
117
+ L = np.asarray(L)
118
+ # the loop runs along the existing faces' half-edges; the fan runs back
119
+ F.append(np.stack([np.roll(L, -1), L, np.full(len(L), n)], axis=1))
120
+ n += 1
121
+ out = trimesh.Trimesh(np.concatenate(V), np.concatenate(F), process=True)
122
+ out.merge_vertices()
123
+ if not out.is_winding_consistent:
124
+ trimesh.repair.fix_winding(out)
125
+ if out.volume < 0:
126
+ out.invert()
127
+ rep = {"loops": len(loops), "widestGap": round(widest, 2),
128
+ "volumeChange": round(float(out.volume - mesh.volume), 1), "watertight": bool(out.is_watertight)}
129
+ if widest > HEAL_WARN:
130
+ rep["warning"] = (f"a {widest:.1f} mm gap was closed with a flat fan: the file was missing faces, "
131
+ "check the part's shape there")
132
+ return out, rep
133
+
134
+
135
+ def _tessellate(shape, tolerance):
136
+ """Triangulate an OpenCascade shape, remembering each triangle's face."""
137
+ import trimesh
138
+ from OCP.BRep import BRep_Tool
139
+ from OCP.BRepMesh import BRepMesh_IncrementalMesh
140
+ from OCP.TopAbs import TopAbs_FACE, TopAbs_REVERSED
141
+ from OCP.TopExp import TopExp_Explorer
142
+ from OCP.TopLoc import TopLoc_Location
143
+ from OCP.TopoDS import TopoDS
144
+
145
+ BRepMesh_IncrementalMesh(shape, tolerance, False, 0.2, True)
146
+ V, F, owner, faces = [], [], [], []
147
+ off = 0
148
+ ex = TopExp_Explorer(shape, TopAbs_FACE)
149
+ while ex.More():
150
+ f = TopoDS.Face(ex.Current())
151
+ fi = len(faces)
152
+ faces.append(f)
153
+ loc = TopLoc_Location()
154
+ tri = BRep_Tool.Triangulation_s(f, loc)
155
+ if tri is not None:
156
+ tr = loc.Transformation()
157
+ for i in range(1, tri.NbNodes() + 1):
158
+ p = tri.Node(i).Transformed(tr)
159
+ V.append((p.X(), p.Y(), p.Z()))
160
+ rev = f.Orientation() == TopAbs_REVERSED
161
+ for i in range(1, tri.NbTriangles() + 1):
162
+ a, b, c = tri.Triangle(i).Get()
163
+ F.append((off + a - 1, off + c - 1, off + b - 1) if rev else (off + a - 1, off + b - 1, off + c - 1))
164
+ owner.append(fi)
165
+ off += tri.NbNodes()
166
+ ex.Next()
167
+ m = trimesh.Trimesh(np.asarray(V, float), np.asarray(F, np.int64), process=False)
168
+ m.merge_vertices(digits_vertex=6)
169
+ keep = m.nondegenerate_faces()
170
+ owner = np.asarray(owner, np.int64)[keep]
171
+ m.update_faces(keep)
172
+ m.remove_unreferenced_vertices()
173
+ return m, owner, faces
174
+
175
+
176
+ def load_part(path, tolerance: float = DEFAULT_TOLERANCE) -> Part:
177
+ """A part from a STEP, STL, 3MF or OBJ file, closed if it was not."""
178
+ import trimesh
179
+
180
+ ext = os.path.splitext(str(path))[1].lower()
181
+ if ext in STEP_EXT:
182
+ from OCP.BRepBuilderAPI import BRepBuilderAPI_Sewing
183
+
184
+ from .step import read_step
185
+ shape = read_step(path)
186
+ sew = BRepBuilderAPI_Sewing(1e-3)
187
+ sew.Add(shape)
188
+ sew.Perform()
189
+ shape = sew.SewedShape()
190
+ mesh, owner, faces = _tessellate(shape, tolerance)
191
+ if len(mesh.faces) == 0:
192
+ raise ValueError(f"{path}: the STEP file has no surfaces to mesh")
193
+ n0 = len(mesh.faces)
194
+ healed, rep = heal_mesh(mesh)
195
+ # healing only appends fans: the original triangles keep their order
196
+ # (merge_vertices does not reorder faces)
197
+ owner = np.concatenate([owner, np.full(len(healed.faces) - n0, -1)]) if len(healed.faces) >= n0 else None
198
+ part = Part(healed, str(path), shape, owner, rep)
199
+ part.faces = faces
200
+ return part
201
+ mesh = trimesh.load(path, force="mesh")
202
+ if mesh.volume < 0:
203
+ mesh.invert()
204
+ healed, rep = heal_mesh(mesh)
205
+ return Part(healed, str(path), healed=rep)
206
+
207
+
208
+ # ---- cylinders and planes ------------------------------------------------------------
209
+
210
+ def _unit(v):
211
+ v = np.asarray(v, float)
212
+ return v / np.linalg.norm(v)
213
+
214
+
215
+ def _canon_axis(p, d):
216
+ """A line as (point nearest the origin, direction with its largest
217
+ component positive): equal lines get equal keys."""
218
+ d = _unit(d)
219
+ if d[np.argmax(np.abs(d))] < 0:
220
+ d = -d
221
+ p = np.asarray(p, float)
222
+ return p - np.dot(p, d) * d, d
223
+
224
+
225
+ def _concave(mesh, pts, axis_p, axis_d, r):
226
+ """True where the cylinder at these surface points is a hole: just
227
+ inside the surface, towards the axis, there is no material."""
228
+ pts = np.asarray(pts, float).reshape(-1, 3)
229
+ foot = axis_p + np.outer((pts - axis_p) @ axis_d, axis_d)
230
+ radial = pts - foot
231
+ radial /= np.maximum(np.linalg.norm(radial, axis=1, keepdims=True), 1e-12)
232
+ probe = pts - radial * min(0.3, 0.2 * r)
233
+ return ~mesh.contains(probe)
234
+
235
+
236
+ def _brep_surfaces(part: Part):
237
+ """Exact cylinders and planes of a STEP part, one record per face."""
238
+ from OCP.BRepAdaptor import BRepAdaptor_Surface
239
+ from OCP.BRepGProp import BRepGProp
240
+ from OCP.GeomAbs import GeomAbs_Cylinder, GeomAbs_Plane
241
+ from OCP.GProp import GProp_GProps
242
+ from OCP.TopAbs import TopAbs_REVERSED
243
+
244
+ cyl, planes = [], []
245
+ tris_of = {}
246
+ if part.face_of is not None:
247
+ for t, fi in enumerate(part.face_of):
248
+ tris_of.setdefault(int(fi), []).append(t)
249
+ for fi, f in enumerate(part.faces):
250
+ a = BRepAdaptor_Surface(f)
251
+ kind = a.GetType()
252
+ tris = np.asarray(tris_of.get(fi, []), np.int64)
253
+ if kind == GeomAbs_Cylinder:
254
+ c = a.Cylinder()
255
+ ax = c.Axis()
256
+ p0 = np.array([ax.Location().X(), ax.Location().Y(), ax.Location().Z()])
257
+ d = np.array([ax.Direction().X(), ax.Direction().Y(), ax.Direction().Z()])
258
+ u0, u1 = a.FirstUParameter(), a.LastUParameter()
259
+ v0, v1 = a.FirstVParameter(), a.LastVParameter()
260
+ um, vm = 0.5 * (u0 + u1), 0.5 * (v0 + v1)
261
+ pm = a.Value(um, vm)
262
+ cyl.append({"r": float(c.Radius()), "p": p0, "d": d, "span": float(u1 - u0),
263
+ "ends": [p0 + v0 * d, p0 + v1 * d], "sample": np.array([pm.X(), pm.Y(), pm.Z()]),
264
+ "tris": tris})
265
+ elif kind == GeomAbs_Plane:
266
+ pl = a.Plane()
267
+ n = np.array([pl.Axis().Direction().X(), pl.Axis().Direction().Y(), pl.Axis().Direction().Z()])
268
+ if f.Orientation() == TopAbs_REVERSED:
269
+ n = -n
270
+ g = GProp_GProps()
271
+ BRepGProp.SurfaceProperties_s(f, g)
272
+ cm = g.CentreOfMass()
273
+ planes.append({"n": n, "c": np.array([cm.X(), cm.Y(), cm.Z()]), "area": float(g.Mass()),
274
+ "tris": tris})
275
+ return cyl, planes
276
+
277
+
278
+ def _mesh_surfaces(mesh):
279
+ """Fitted cylinders and planes of a mesh. Cylinders: smooth patches
280
+ (dihedral under 25 degrees) whose normals lie square to one axis and
281
+ whose points fit a circle, several per patch when fillets join them.
282
+ Planes: coplanar facets of three or more triangles that no cylinder
283
+ claimed (a finely cut cylinder's strips are coplanar facets too)."""
284
+ import trimesh
285
+
286
+ planes, cyl = [], []
287
+ adj = mesh.face_adjacency
288
+ ok = mesh.face_adjacency_angles < math.radians(25)
289
+ claimed = np.zeros(len(mesh.faces), bool)
290
+ for comp in trimesh.graph.connected_components(adj[ok], nodes=np.arange(len(mesh.faces)), min_len=6):
291
+ left = np.asarray(comp)
292
+ for _ in range(4):
293
+ if len(left) < 6:
294
+ break
295
+ fit = _fit_cylinder(mesh, left)
296
+ if fit is None:
297
+ break
298
+ cyl.append(fit)
299
+ claimed[fit["tris"]] = True
300
+ left = left[~claimed[left]]
301
+ for fac, n, area in zip(mesh.facets, mesh.facets_normal, mesh.facets_area):
302
+ if len(fac) < 3 or claimed[fac].mean() > 0.5:
303
+ continue
304
+ w = mesh.area_faces[fac]
305
+ planes.append({"n": np.asarray(n, float), "c": (mesh.triangles_center[fac] * w[:, None]).sum(0) / w.sum(),
306
+ "area": float(area), "tris": np.asarray(fac)})
307
+ return cyl, planes
308
+
309
+
310
+ def _fit_cylinder(mesh, comp):
311
+ """A cylinder fitted to a smooth patch of triangles, or None. Faces
312
+ whose normals leave the axis's plane (a fillet rolling onto a face) are
313
+ peeled off and the axis refitted; then points off the circle (the far
314
+ side of a fillet, a flat beside it) are trimmed."""
315
+ for _ in range(4):
316
+ nrm = mesh.face_normals[comp]
317
+ w = mesh.area_faces[comp]
318
+ M = (nrm * w[:, None]).T @ nrm
319
+ evals, evecs = np.linalg.eigh(M)
320
+ d = evecs[:, 0]
321
+ off = np.abs(nrm @ d) > 0.2
322
+ if not off.any():
323
+ break
324
+ comp = comp[~off]
325
+ if len(comp) < 6:
326
+ return None
327
+ if evals[0] > 0.02 * evals.sum() or evals[1] < 0.05 * evals.sum():
328
+ return None # not square to one axis, or flat
329
+ vid = np.unique(mesh.faces[comp])
330
+ P = mesh.vertices[vid]
331
+ e1 = _unit(np.cross(d, [1, 0, 0] if abs(d[0]) < 0.9 else [0, 1, 0]))
332
+ e2 = np.cross(d, e1)
333
+ x, y = P @ e1, P @ e2
334
+ inl = np.ones(len(P), bool)
335
+ for _ in range(3):
336
+ A = np.stack([x[inl], y[inl], np.ones(inl.sum())], 1)
337
+ sol, *_ = np.linalg.lstsq(A, x[inl] ** 2 + y[inl] ** 2, rcond=None)
338
+ cx, cy = sol[0] / 2, sol[1] / 2
339
+ r = math.sqrt(max(sol[2] + cx * cx + cy * cy, 0.0))
340
+ if not 0 < r < 1e4:
341
+ return None
342
+ resid = np.abs(np.hypot(x - cx, y - cy) - r)
343
+ inl = resid <= max(0.01 * r, 0.02)
344
+ if inl.sum() < 6:
345
+ return None
346
+ if inl.sum() < 6:
347
+ return None
348
+ on = np.zeros(len(mesh.vertices), bool)
349
+ on[vid[inl]] = True
350
+ comp = comp[on[mesh.faces[comp]].all(1)]
351
+ if len(comp) < 4 or (inl.mean() < 0.7 and len(comp) < 12):
352
+ return None
353
+ a = np.sort(np.arctan2(y[inl] - cy, x[inl] - cx))
354
+ gaps = np.diff(np.concatenate([a, a[:1] + 2 * math.pi]))
355
+ t = P[inl] @ d
356
+ p0 = cx * e1 + cy * e2
357
+ w = mesh.area_faces[comp]
358
+ return {"r": float(r), "p": p0, "d": d, "span": float(2 * math.pi - gaps.max()),
359
+ "ends": [p0 + t.min() * d, p0 + t.max() * d],
360
+ "sample": mesh.triangles_center[comp[np.argmax(w)]], "tris": comp}
361
+
362
+
363
+ # ---- features -----------------------------------------------------------------------
364
+
365
+ def _group_cylinders(mesh, cyl, scale):
366
+ """Merge cylinder faces into holes (and bosses): same line, same radius,
367
+ then coaxial steps into one bore."""
368
+ tol_p, tol_r = 1e-3 * scale + 0.01, 0.02
369
+ groups = []
370
+ for c in cyl:
371
+ p, d = _canon_axis(c["p"], c["d"])
372
+ for g in groups:
373
+ if abs(abs(np.dot(g["d"], d)) - 1) < 1e-4 and np.linalg.norm(g["p"] - p) < tol_p \
374
+ and abs(g["r"] - c["r"]) < tol_r:
375
+ g["faces"].append(c)
376
+ break
377
+ else:
378
+ groups.append({"p": p, "d": d, "r": c["r"], "faces": [c]})
379
+ holes, bosses = [], []
380
+ for g in groups:
381
+ span = sum(f["span"] for f in g["faces"])
382
+ if span < FULL_SPAN:
383
+ continue # a fillet, a slot end, a partial arc
384
+ ts = np.concatenate([[(e - g["p"]) @ g["d"] for e in f["ends"]] for f in g["faces"]])
385
+ g["t0"], g["t1"] = float(ts.min()), float(ts.max())
386
+ if g["t1"] - g["t0"] < 1e-3:
387
+ continue
388
+ samples = np.array([f["sample"] for f in g["faces"]])
389
+ hole = _concave(mesh, samples, g["p"], g["d"], g["r"])
390
+ g["tris"] = np.concatenate([f["tris"] for f in g["faces"]])
391
+ (holes if hole.mean() > 0.5 else bosses).append(g)
392
+ # coaxial holes: one bore with steps
393
+ bores = []
394
+ for h in sorted(holes, key=lambda h: h["r"]):
395
+ for b in bores:
396
+ if abs(abs(np.dot(b["d"], h["d"])) - 1) < 1e-4 and np.linalg.norm(b["p"] - h["p"]) < tol_p:
397
+ b["segments"].append(h)
398
+ break
399
+ else:
400
+ bores.append({"p": h["p"], "d": h["d"], "segments": [h]})
401
+ return bores, bosses
402
+
403
+
404
+ def _inside(mesh, pts):
405
+ return mesh.contains(np.asarray(pts, float).reshape(-1, 3))
406
+
407
+
408
+ def _bore_record(mesh, b, i, scale):
409
+ segs = sorted(b["segments"], key=lambda s: s["t0"])
410
+ d, p = b["d"], b["p"]
411
+ t0 = min(s["t0"] for s in segs)
412
+ t1 = max(s["t1"] for s in segs)
413
+ r_min = min(s["r"] for s in segs)
414
+ eps = 0.5
415
+ through = not bool(_inside(mesh, [p + (t0 - eps) * d, p + (t1 + eps) * d]).any())
416
+ return {"id": f"bore-{i}", "kind": "bolt" if r_min <= BOLT_MAX_R else "bore",
417
+ "axis": d.round(6).tolist(), "center": (p + 0.5 * (t0 + t1) * d).round(4).tolist(),
418
+ "p0": (p + t0 * d).round(4).tolist(), "p1": (p + t1 * d).round(4).tolist(),
419
+ "r": round(r_min, 4), "length": round(t1 - t0, 4), "through": through,
420
+ "segments": [{"r": round(s["r"], 4), "p0": (p + s["t0"] * d).round(4).tolist(),
421
+ "p1": (p + s["t1"] * d).round(4).tolist()} for s in segs]}
422
+
423
+
424
+ def _exposed(mesh, plane, samples=12):
425
+ """Share of the face that sees nothing of the part along its normal."""
426
+ tris = plane["tris"]
427
+ if len(tris) == 0:
428
+ return 0.0
429
+ rng = np.random.default_rng(0)
430
+ pick = tris if len(tris) <= samples else rng.choice(tris, samples, replace=False, p=None)
431
+ origins = mesh.triangles_center[pick] + plane["n"] * 1e-3
432
+ dirs = np.repeat(plane["n"][None], len(origins), 0)
433
+ hit = mesh.ray.intersects_any(origins, dirs)
434
+ return float(1.0 - hit.mean())
435
+
436
+
437
+ def detect_features(part: Part) -> dict:
438
+ """Bores, flat mounting faces and interfaces of a part (see the module
439
+ docstring). Units mm."""
440
+ mesh = part.mesh
441
+ scale = float(np.max(mesh.extents))
442
+ if part.brep is not None:
443
+ cyl, planes = _brep_surfaces(part)
444
+ source = "brep"
445
+ else:
446
+ cyl, planes = _mesh_surfaces(mesh)
447
+ source = "mesh"
448
+ raw_bores, bosses = _group_cylinders(mesh, cyl, scale)
449
+ raw_bores.sort(key=lambda b: (-max(s["r"] for s in b["segments"]),
450
+ tuple(np.round(b["p"] + b["segments"][0]["t0"] * b["d"], 3))))
451
+ bores = [_bore_record(mesh, b, i, scale) for i, b in enumerate(raw_bores)]
452
+
453
+ # mounting faces: exposed planes big enough to sit on
454
+ total = float(mesh.area)
455
+ # big enough to bolt to: 1% of the surface (a chamfer or a step is not
456
+ # a mounting face; a plain L bracket read 14 of them at 0.2%)
457
+ min_area = max(10.0, 0.01 * total)
458
+ faces = []
459
+ for pl in sorted(planes, key=lambda q: -q["area"]):
460
+ if pl["area"] < min_area:
461
+ continue
462
+ exp = _exposed(mesh, pl)
463
+ if exp < 0.6:
464
+ continue
465
+ n, c = _unit(pl["n"]), pl["c"]
466
+ pts = mesh.vertices[np.unique(mesh.faces[pl["tris"]])] if len(pl["tris"]) else c[None]
467
+ holes = []
468
+ for b in bores:
469
+ d = np.asarray(b["axis"])
470
+ if abs(np.dot(d, n)) < 0.98:
471
+ continue
472
+ ends = [np.asarray(b["p0"]), np.asarray(b["p1"])]
473
+ for s in b["segments"]:
474
+ ends += [np.asarray(s["p0"]), np.asarray(s["p1"])]
475
+ if min(abs(np.dot(e - c, n)) for e in ends) > 0.05:
476
+ continue
477
+ # where the axis meets the plane, the face lies within a wall's reach
478
+ q = np.asarray(b["center"]) + d * np.dot(c - np.asarray(b["center"]), n) / np.dot(d, n)
479
+ rmax = max(s["r"] for s in b["segments"])
480
+ if np.min(np.linalg.norm(pts - q, axis=1)) <= rmax + 3.0:
481
+ holes.append(b["id"])
482
+ faces.append({"id": f"face-{len(faces)}", "normal": n.round(6).tolist(), "center": c.round(4).tolist(),
483
+ "area": round(pl["area"], 2), "exposed": round(exp, 2), "bores": holes,
484
+ "_tris": pl["tris"]})
485
+
486
+ # interfaces: bolted faces, loose bolt patterns, bores
487
+ by_id = {b["id"]: b for b in bores}
488
+ used = set()
489
+ interfaces = []
490
+ for f in faces:
491
+ bolts = [h for h in f["bores"] if by_id[h]["kind"] == "bolt" and h not in used]
492
+ big = [h for h in f["bores"] if by_id[h]["kind"] == "bore"]
493
+ if len(bolts) >= 2 or (bolts and f["area"] > 0.02 * total):
494
+ used.update(bolts)
495
+ interfaces.append({"id": f"{f['id']}-mount", "kind": "bolted-face", "face": f["id"],
496
+ "bores": bolts, "alsoOnFace": big})
497
+ loose = [b for b in bores if b["kind"] == "bolt" and b["id"] not in used]
498
+ while loose:
499
+ b0 = loose.pop(0)
500
+ d0 = np.asarray(b0["axis"])
501
+ grp = [b0]
502
+ for b in list(loose):
503
+ if abs(abs(np.dot(np.asarray(b["axis"]), d0)) - 1) < 1e-3 and abs(b["r"] - b0["r"]) < 0.05 and \
504
+ abs(np.dot(np.asarray(b["p0"]) - np.asarray(b0["p0"]), d0)) < 0.5:
505
+ grp.append(b)
506
+ loose.remove(b)
507
+ used.update(b["id"] for b in grp)
508
+ interfaces.append({"id": f"{grp[0]['id']}-pattern" if len(grp) > 1 else grp[0]["id"],
509
+ "kind": "bolt-pattern" if len(grp) > 1 else "bolt", "bores": [b["id"] for b in grp]})
510
+ for b in bores:
511
+ if b["kind"] == "bore":
512
+ interfaces.append({"id": b["id"], "kind": "bore", "bores": [b["id"]]})
513
+ for it in interfaces:
514
+ cs = np.array([by_id[h]["center"] for h in it["bores"]]) if it["bores"] else None
515
+ if it["kind"] == "bolted-face":
516
+ # centred on its bolts, which carry the load, not on the face
517
+ fc = next(f for f in faces if f["id"] == it["face"])
518
+ it["center"] = cs.mean(0).round(4).tolist()
519
+ it["normal"] = fc["normal"]
520
+ else:
521
+ it["center"] = cs.mean(0).round(4).tolist()
522
+ it["normal"] = by_id[it["bores"][0]]["axis"]
523
+ out = {"source": source, "units": "mm", "bounds": np.asarray(mesh.bounds).round(4).tolist(),
524
+ "volume": round(float(mesh.volume), 1), "area": round(total, 1),
525
+ "bores": bores, "bosses": [{"axis": b["d"].round(6).tolist(), "r": round(b["r"], 4),
526
+ "p0": (b["p"] + b["t0"] * b["d"]).round(4).tolist(),
527
+ "p1": (b["p"] + b["t1"] * b["d"]).round(4).tolist()} for b in bosses],
528
+ "faces": faces, "interfaces": interfaces}
529
+ if part.healed.get("loops"):
530
+ out["healed"] = part.healed
531
+ return out
532
+
533
+
534
+ def _interface_size(it, feats):
535
+ """How much an interface carries, roughly: bearing area of its bores
536
+ (r x length, mm^2) plus a tenth of its face."""
537
+ by_id = {b["id"]: b for b in feats["bores"]}
538
+ s = sum(by_id[h]["r"] * by_id[h]["length"] for h in it["bores"])
539
+ if it["kind"] == "bolted-face":
540
+ s += 0.1 * next(f["area"] for f in feats["faces"] if f["id"] == it["face"])
541
+ return s
542
+
543
+
544
+ def suggest_setup(feats: dict, force: float = 100.0) -> dict:
545
+ """A support and a load to start from. A part is held by its bolts:
546
+ the bolted face (or bolt pattern) with the most bolts holds it, the
547
+ largest by area on a tie (any interface when nothing is bolted). The
548
+ load goes where the most is carried farthest from that (size x lever:
549
+ a bearing seat or a socket across the part), `force` N square to the
550
+ lever, as close to straight down (-z) as that allows. A placeholder for
551
+ the real loads, which only the machine knows."""
552
+ its = feats["interfaces"]
553
+ if not its:
554
+ return {}
555
+ bolted = [it for it in its if it["kind"] in ("bolted-face", "bolt-pattern")]
556
+ pool = bolted or its
557
+ hold = max(pool, key=lambda it: (len(it["bores"]) if bolted else 0, _interface_size(it, feats)))
558
+ c0 = np.asarray(hold["center"])
559
+ rest = [it for it in its if it is not hold]
560
+ if not rest:
561
+ return {"support": hold["id"]}
562
+ load = max(rest, key=lambda it: _interface_size(it, feats) * np.linalg.norm(np.asarray(it["center"]) - c0))
563
+ lever = np.asarray(load["center"]) - c0
564
+ lever = lever / max(np.linalg.norm(lever), 1e-9)
565
+ g = np.array([0.0, 0.0, -1.0])
566
+ dirn = g - np.dot(g, lever) * lever
567
+ if np.linalg.norm(dirn) < 0.3:
568
+ a = np.asarray(load["normal"], float)
569
+ dirn = np.cross(lever, a)
570
+ if np.linalg.norm(dirn) < 1e-6:
571
+ dirn = np.cross(lever, [1.0, 0.0, 0.0] if abs(lever[0]) < 0.9 else [0.0, 1.0, 0.0])
572
+ dirn = dirn / np.linalg.norm(dirn)
573
+ return {"support": hold["id"], "load": load["id"], "force": (force * dirn).round(3).tolist(),
574
+ "lever": round(float(np.linalg.norm(np.asarray(load["center"]) - c0)), 2),
575
+ "note": f"placeholder {force:g} N: replace with the part's real loads"}
576
+
577
+
578
+ # ---- the draft study ----------------------------------------------------------------
579
+
580
+ def _extrude(mesh, tris, normal, depth):
581
+ """The triangles `tris` of `mesh` (a flat face) swept `depth` mm along
582
+ `normal`, closed: a pad behind a face (depth < 0) or a clearance slab in
583
+ front of it."""
584
+ import trimesh
585
+
586
+ F = mesh.faces[tris]
587
+ vid, inv = np.unique(F, return_inverse=True)
588
+ top = mesh.vertices[vid]
589
+ bot = top + depth * np.asarray(normal)
590
+ n = len(vid)
591
+ f_top = inv.reshape(-1, 3)
592
+ # boundary half-edges of the face
593
+ e = np.concatenate([f_top[:, [0, 1]], f_top[:, [1, 2]], f_top[:, [2, 0]]])
594
+ key = np.sort(e, axis=1)
595
+ _, ix, cnt = np.unique(key, axis=0, return_inverse=True, return_counts=True)
596
+ be = e[cnt[ix.ravel()] == 1]
597
+ sides = np.concatenate([np.stack([be[:, 1], be[:, 0], be[:, 0] + n], 1),
598
+ np.stack([be[:, 1], be[:, 0] + n, be[:, 1] + n], 1)])
599
+ faces = np.concatenate([f_top, f_top[:, ::-1] + n, sides])
600
+ m = trimesh.Trimesh(np.concatenate([top, bot]), faces, process=True)
601
+ if m.volume < 0:
602
+ m.invert()
603
+ return m
604
+
605
+
606
+ def _tube(seg, wall, extend=0.0):
607
+ from .shapes import Cylinder
608
+ p0, p1 = np.asarray(seg["p0"], float), np.asarray(seg["p1"], float)
609
+ a = _unit(p1 - p0)
610
+ return Cylinder(p0 - extend * a, p1 + extend * a, seg["r"] + wall, 0.0)
611
+
612
+
613
+ def _load_pairs(loads):
614
+ """[(interface id, force)] from a list of pairs or {id, force} dicts, or
615
+ a dict {id: force}."""
616
+ if not loads:
617
+ return []
618
+ if isinstance(loads, dict):
619
+ return [(k, v) for k, v in loads.items()]
620
+ out = []
621
+ for x in loads:
622
+ if isinstance(x, dict):
623
+ out.append((x.get("interface") or x.get("region") or x.get("id"), x["force"]))
624
+ else:
625
+ out.append((x[0], x[1]))
626
+ for k, f in out:
627
+ if len(f) != 3:
628
+ raise ValueError(f"load on {k!r}: force is [fx, fy, fz] in N")
629
+ return out
630
+
631
+
632
+ def washer_r(r):
633
+ """mm: the radius a bolt's head or washer bears on, for a clearance
634
+ hole of radius r (ISO metric, fasteners.METRIC; about 1.9 r between)."""
635
+ from .fasteners import METRIC
636
+ best = min(METRIC.values(), key=lambda v: abs(v[0] / 2 - r))
637
+ return best[1] / 2 if abs(best[0] / 2 - r) < 0.4 else 1.9 * r
638
+
639
+
640
+ def _bolt_ends(b, face_n=None, face_c=None):
641
+ """(head end, far end, head-recess radius) of a bolt bore. The head sits
642
+ in a counterbore when there is one (at its step); else on the end away
643
+ from the face the part bolts against."""
644
+ p0, p1 = np.asarray(b["p0"], float), np.asarray(b["p1"], float)
645
+ segs = b["segments"]
646
+ if len(segs) > 1:
647
+ cb = max(segs, key=lambda s: s["r"])
648
+ q0, q1 = np.asarray(cb["p0"], float), np.asarray(cb["p1"], float)
649
+ # the counterbore's outer end is the bore's own end; its other end is the step
650
+ if np.linalg.norm(q0 - p0) < np.linalg.norm(q1 - p1):
651
+ return q1, p1, cb["r"]
652
+ return q0, p0, cb["r"]
653
+ if face_n is not None and abs(np.dot(p1 - face_c, face_n)) < abs(np.dot(p0 - face_c, face_n)):
654
+ return p0, p1, b["r"]
655
+ return p1, p0, b["r"]
656
+
657
+
658
+ def ring_wall(r):
659
+ """mm of material kept round a bore of radius r: three FDM perimeters
660
+ round a bolt, a little more round a bearing seat."""
661
+ return float(np.clip(2.0 + 0.1 * r, 2.0, 5.0))
662
+
663
+
664
+ def _write_stl(mesh, path):
665
+ mesh.export(path)
666
+ return os.path.basename(path)
667
+
668
+
669
+ def draft_study(part: Part, feats: dict, out_dir: str, *, name: str | None = None, space: str = "part",
670
+ material: str = "petg", force: float = 100.0, pad: float = 2.0, grow: float = 0.0,
671
+ safety_factor: float = 2.0, process: str | None = None, support: str | None = None,
672
+ loads=None, ring: float | None = None) -> dict:
673
+ """Write a study for the part into `out_dir` (design space, preserved
674
+ interfaces and clearance slabs as STL beside it) and return it.
675
+ `support` (an interface id) and `loads` ([(interface id, [fx, fy, fz] N)],
676
+ or a dict of them; each its own load case) replace the suggested setup;
677
+ the suggestion stands for whichever is not given. `ring` (mm): every bore
678
+ keeps an exact tube of that wall (a cylinder in the study, not cut to the
679
+ part) instead of the default ring."""
680
+ import trimesh
681
+
682
+ from .shapes import Cylinder, manifold_from_trimesh, trimesh_from_manifold
683
+
684
+ if space not in ("part", "hull", "box"):
685
+ raise ValueError("space is 'part' (the part as drawn), 'hull' (its convex hull) or 'box'")
686
+ os.makedirs(out_dir, exist_ok=True)
687
+ mesh = part.mesh
688
+ stem = name or os.path.splitext(os.path.basename(part.path or "part"))[0]
689
+ by_id = {b["id"]: b for b in feats["bores"]}
690
+ faces = {f["id"]: f for f in feats["faces"]}
691
+
692
+ if space == "part":
693
+ ds_mesh = mesh
694
+ elif space == "hull":
695
+ ds_mesh = mesh.convex_hull
696
+ else:
697
+ lo, hi = mesh.bounds
698
+ ds_mesh = trimesh.creation.box(bounds=[lo - grow, hi + grow])
699
+ if grow > 0 and space != "box":
700
+ from .shapes import MeshShape, grow as grow_shape
701
+ ds_spec_shape = grow_shape(MeshShape(ds_mesh), grow)
702
+ ds_mesh = trimesh_from_manifold(ds_spec_shape.to_manifold())
703
+ design = [{"type": "mesh", "path": _write_stl(ds_mesh, os.path.join(out_dir, f"{stem}-design-space.stl"))}]
704
+ part_m = manifold_from_trimesh(mesh)
705
+
706
+ preserve, keep_out = [], []
707
+ lo, hi = np.asarray(ds_mesh.bounds[0]), np.asarray(ds_mesh.bounds[1])
708
+ reach = float(np.linalg.norm(hi - lo))
709
+
710
+ def bore_cyls(b, extend):
711
+ segs = [dict(s) for s in b["segments"]]
712
+ ends = [np.asarray(b["p0"], float), np.asarray(b["p1"], float)]
713
+ out = []
714
+ for s in segs:
715
+ p0, p1 = np.asarray(s["p0"]), np.asarray(s["p1"])
716
+ a = _unit(p1 - p0)
717
+ ext = (reach if extend else 0.5) if b["through"] else 0.0
718
+ # only past the bore's own ends: a counterbore stretched past its
719
+ # step would cut away the material its screw clamps
720
+ e0, e1 = (ext if min(np.linalg.norm(q - e) for e in ends) < 1e-6 else 0.0 for q in (p0, p1))
721
+ out.append({"type": "cylinder", "p0": (p0 - e0 * a).round(4).tolist(),
722
+ "p1": (p1 + e1 * a).round(4).tolist(), "r": s["r"]})
723
+ return out
724
+
725
+ def bore_path(b, face_n=None, face_c=None):
726
+ """The bore's line out of each end, from near where its keep-out
727
+ stops (0.5 mm past the end) to past the design space, not over the
728
+ hole: coincident cylinders (or caps) are what OpenCascade's booleans
729
+ (the STEP export) handle worst, to the point of hanging. Not out of
730
+ a bolt's head end: its driver path is there already."""
731
+ ends = [np.asarray(b[k], float) for k in ("p0", "p1")]
732
+ if b["kind"] == "bolt":
733
+ far = _bolt_ends(b, face_n, face_c)[1]
734
+ ends = [min(ends, key=lambda e: np.linalg.norm(e - far))]
735
+ out = []
736
+ for s in b["segments"]:
737
+ p0, p1 = np.asarray(s["p0"], float), np.asarray(s["p1"], float)
738
+ a = _unit(p1 - p0)
739
+ for q, d in ((p0, -a), (p1, a)):
740
+ if min(np.linalg.norm(q - e) for e in ends) < 1e-6:
741
+ # overlapping the hole's last 0.25 mm, 0.01 mm thinner:
742
+ # no shared face, coincident or end to end
743
+ out.append({"type": "cylinder", "p0": (q + 0.25 * d).round(4).tolist(),
744
+ "p1": (q + reach * d).round(4).tolist(), "r": round(s["r"] - 0.01, 4)})
745
+ return out
746
+
747
+ grown = space != "part" or grow > 0
748
+ setup = suggest_setup(feats, force)
749
+ ids = {it["id"] for it in feats["interfaces"]}
750
+ pairs = _load_pairs(loads)
751
+ for k in [support] + [p[0] for p in pairs]:
752
+ if k is not None and k not in ids:
753
+ raise ValueError(f"no interface {k!r}; this part has {sorted(ids)} (see features.json)")
754
+ used = {support or setup.get("support")} | ({p[0] for p in pairs} if pairs else {setup.get("load")})
755
+ for it in feats["interfaces"]:
756
+ bores = [by_id[h] for h in it["bores"]]
757
+ cyls = [c for b in bores for c in bore_cyls(b, False)]
758
+ keep_out.append({"id": f"{it['id']}-hole" if it["kind"] == "bolted-face" or len(bores) > 1
759
+ else it["id"],
760
+ "shape": cyls[0] if len(cyls) == 1 else {"type": "union", "shapes": cyls}})
761
+ fn = fc = None
762
+ if it["kind"] == "bolted-face":
763
+ fn, fc = np.asarray(faces[it["face"]]["normal"]), np.asarray(faces[it["face"]]["center"])
764
+ path = [c for b in bores if grown and b["through"] for c in bore_path(b, fn, fc)]
765
+ if path:
766
+ # the bolts and shafts run on out of a grown space (or material
767
+ # grows over a bearing seat's ends and the shaft can't go in):
768
+ # kept clear, but not the region that holds or loads the
769
+ # interface, or the optimizer hangs the part off the line far
770
+ # from the bore
771
+ keep_out.append({"id": f"{it['id']}-path",
772
+ "shape": path[0] if len(path) == 1 else {"type": "union", "shapes": path}})
773
+ if it["kind"] == "bore" and bores[0]["r"] < SEAT_MIN_R and it["id"] not in used:
774
+ continue # a cable pass or a pilot hole: kept open, not kept solid
775
+ # preserved: rings round the bores, a pad under a bolted face, cut to the part
776
+ keep = []
777
+ face_n = face_c = None
778
+ if it["kind"] == "bolted-face":
779
+ f = faces[it["face"]]
780
+ face_n, face_c = np.asarray(f["normal"]), np.asarray(f["center"])
781
+ for b in bores:
782
+ if b["kind"] == "bolt":
783
+ # a bolt needs a flat seat under its head and where it clamps,
784
+ # not a boss: a washer-sized ring `pad` mm deep at each (the way
785
+ # fasteners.py preserves a bolted joint)
786
+ head, far, r_head = _bolt_ends(b, face_n, face_c)
787
+ a = _unit(far - head)
788
+ rw = washer_r(b["r"])
789
+ keep.append(Cylinder(head - 0.5 * a, head + pad * a, rw, 0.0).to_manifold())
790
+ keep.append(Cylinder(far - pad * a, far + 0.5 * a, rw, 0.0).to_manifold())
791
+ if grown:
792
+ # the head and the driver come in from outside
793
+ keep_out.append({"id": f"{b['id']}-driver", "bore": False, "shape": {
794
+ "type": "cylinder", "p0": (head - reach * a).round(4).tolist(),
795
+ "p1": head.round(4).tolist(), "r": round(max(r_head, rw), 4)}})
796
+ continue
797
+ if ring:
798
+ for s in b["segments"]:
799
+ p0, p1 = np.asarray(s["p0"]), np.asarray(s["p1"])
800
+ preserve.append({"id": f"{b['id']}-ring" + (f"-{len(preserve)}" if len(b["segments"]) > 1 else ""),
801
+ "shape": {"type": "cylinder", "p0": p0.round(4).tolist(),
802
+ "p1": p1.round(4).tolist(), "r": round(s["r"] + ring, 4),
803
+ "rInner": s["r"]}})
804
+ continue
805
+ w = ring_wall(b["r"])
806
+ for s in b["segments"]:
807
+ # past the bore's ends, so no cap lies on the part's own face
808
+ # (coplanar caps leave slivers that do not survive an STL)
809
+ keep.append(_tube(s, w, extend=0.5).to_manifold())
810
+ if it["kind"] == "bolted-face" and grown:
811
+ # don't grow over the mating face: clear a slab in front of it
812
+ slab = _extrude(mesh, f["_tris"], face_n, max(grow, 0.0) + reach)
813
+ keep_out.append({"id": f"{it['id']}-clear", "bore": False,
814
+ "shape": {"type": "mesh", "path": _write_stl(
815
+ slab, os.path.join(out_dir, f"{stem}-{it['id']}-clear.stl"))}})
816
+ if not keep:
817
+ continue
818
+ acc = keep[0]
819
+ for k in keep[1:]:
820
+ acc = acc + k
821
+ kept = trimesh_from_manifold(acc ^ part_m)
822
+ if len(kept.faces) == 0:
823
+ continue
824
+ preserve.append({"id": f"{it['id']}-keep",
825
+ "shape": {"type": "mesh", "path": _write_stl(
826
+ kept, os.path.join(out_dir, f"{stem}-{it['id']}-keep.stl"))}})
827
+ # every other bore stays a hole
828
+ covered = {h for it in feats["interfaces"] for h in it["bores"]}
829
+ for b in feats["bores"]:
830
+ if b["id"] not in covered:
831
+ cyls = bore_cyls(b, False)
832
+ keep_out.append({"id": b["id"], "shape": cyls[0] if len(cyls) == 1 else
833
+ {"type": "union", "shapes": cyls}})
834
+
835
+ study = {"name": stem,
836
+ "description": f"Drafted by `loadpath import` from {os.path.basename(part.path or 'a mesh')}: "
837
+ f"{len(feats['bores'])} bores, {len(feats['faces'])} mounting faces, "
838
+ f"{len(feats['interfaces'])} interfaces. Check the loads before trusting a result.",
839
+ "designSpace": design, "preserve": preserve, "keepOut": keep_out,
840
+ "material": material, "goal": {"safetyFactor": safety_factor}}
841
+ if process:
842
+ study["process"] = process
843
+ ko_of = {}
844
+ for it in feats["interfaces"]:
845
+ bores = it["bores"]
846
+ ko_of[it["id"]] = f"{it['id']}-hole" if it["kind"] == "bolted-face" or len(bores) > 1 else it["id"]
847
+ hold = support or setup.get("support")
848
+ if hold:
849
+ study["supports"] = [{"region": ko_of[hold]}]
850
+ given = bool(pairs)
851
+ if not pairs and setup.get("load"):
852
+ pairs = [(setup["load"], setup["force"])]
853
+ cases = []
854
+ for k, (iid, fv) in enumerate(pairs):
855
+ it = next(i for i in feats["interfaces"] if i["id"] == iid)
856
+ ld = {"region": ko_of[iid], "force": [float(x) for x in fv]}
857
+ if it["kind"] == "bore" and len(by_id[it["bores"][0]]["segments"]) == 1:
858
+ ld["distribution"] = "bearing"
859
+ cases.append({"name": f"load-{k + 1}" if given else "suggested", "loads": [ld]})
860
+ if cases:
861
+ study["loadCases"] = cases
862
+ feats_out = {k: v for k, v in feats.items() if k != "faces"}
863
+ feats_out["faces"] = [{k: v for k, v in f.items() if not k.startswith("_")} for f in feats["faces"]]
864
+ feats_out["suggested"] = setup
865
+ with open(os.path.join(out_dir, f"{stem}.study.json"), "w") as fh:
866
+ json.dump(study, fh, indent=1)
867
+ with open(os.path.join(out_dir, f"{stem}.features.json"), "w") as fh:
868
+ json.dump(feats_out, fh, indent=1)
869
+ return study
870
+
871
+
872
+ def summarize(feats: dict) -> dict:
873
+ """The short read of a part's features: counts, and each interface in a
874
+ line an agent can pick from."""
875
+ by_id = {b["id"]: b for b in feats["bores"]}
876
+ rows = []
877
+ for it in feats["interfaces"]:
878
+ bs = [by_id[h] for h in it["bores"]]
879
+ if it["kind"] == "bore":
880
+ b = bs[0]
881
+ what = f"bore r {b['r']:g} mm x {b['length']:g} mm, {'through' if b['through'] else 'blind'}"
882
+ else:
883
+ rs = sorted({round(b["r"], 2) for b in bs})
884
+ what = f"{len(bs)} bolt hole(s) r {'/'.join(f'{r:g}' for r in rs)} mm" + (
885
+ f" on {it['face']}" if it["kind"] == "bolted-face" else "")
886
+ rows.append({"id": it["id"], "kind": it["kind"], "what": what, "center": it["center"]})
887
+ return {"bolts": sum(b["kind"] == "bolt" for b in feats["bores"]),
888
+ "bores": sum(b["kind"] == "bore" for b in feats["bores"]),
889
+ "boltedFaces": sum(it["kind"] == "bolted-face" for it in feats["interfaces"]),
890
+ "mountingFaces": len(feats["faces"]), "interfaces": rows}
891
+
892
+
893
+ def import_part(path, out_dir: str | None = None, *, tolerance: float = DEFAULT_TOLERANCE, **kw) -> dict:
894
+ """Read a part, find its features and (with `out_dir`) draft its study.
895
+ Returns {"features", "study"?}."""
896
+ part = load_part(path, tolerance)
897
+ feats = detect_features(part)
898
+ out = {"features": {k: v for k, v in feats.items() if k != "faces"}}
899
+ out["features"]["faces"] = [{k: v for k, v in f.items() if not k.startswith("_")} for f in feats["faces"]]
900
+ out["features"]["suggested"] = suggest_setup(feats, kw.get("force", 100.0))
901
+ out["summary"] = summarize(out["features"])
902
+ if out_dir is not None:
903
+ out["study"] = draft_study(part, feats, out_dir, **kw)
904
+ return out