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/sharp.py ADDED
@@ -0,0 +1,717 @@
1
+ """
2
+ Sharp features for field-built parts, and a measure of how sharp a part is.
3
+
4
+ A field meshed by marching cubes cannot hold a crease: wherever a hard
5
+ boolean meets two surfaces at an angle (a bearing bore through a flat face,
6
+ a bolt face, a keep-out's wall) the mesh comes out rounded over about one
7
+ grid step, and a bore cut from the field is a polygon a little undersized
8
+ near its rims. The study path (reconstruct.py) has never had this problem
9
+ for its interfaces because it does the last booleans on exact meshes; this
10
+ module gives field graphs the same treatment.
11
+
12
+ `plan(graph)` splits a graph at its hard booleans (union, intersect and
13
+ subtract with no blend) wherever one side is a plain shape (box, cylinder,
14
+ sphere, a union of those) or an imported mesh: those sides become `Exact`
15
+ leaves, cut, added or clipped with manifold3d, and everything else stays
16
+ an `Organic` leaf meshed from its field. The organic side is meshed from
17
+ an *extended* field: inside the region an exact shape will cut away (or
18
+ add, or clip off) anyway, the field is carried on from just outside it
19
+ along the shape's normal, so the organic mesh runs straight through where
20
+ the exact surface will be instead of trying to draw it (a collar built as
21
+ a shell round a bore has its own inner wall exactly on the bore's: meshed
22
+ as-is, the exact cut would leave a film of slivers there). The exact
23
+ boolean then draws the crease, on the exact shape's surface.
24
+
25
+ `edge_accuracy(mesh, refs, solid)` measures the result: it samples the
26
+ surfaces of the exact shapes where they are part of the delivered part's
27
+ boundary and reports how far the mesh is from them, which is where a
28
+ rounded crease shows (a 90 degree edge rounded with radius r is
29
+ 0.41 r off at its corner).
30
+
31
+ The same plan is what `step.export_step` turns into a B-rep: exact leaves
32
+ become exact OpenCascade solids (planes and cylinders in the STEP file),
33
+ organic leaves faceted solids.
34
+ """
35
+ from __future__ import annotations
36
+
37
+ import math
38
+ from dataclasses import dataclass, field as dfield
39
+
40
+ import numpy as np
41
+
42
+ __all__ = ["Exact", "Organic", "Op", "plan", "mesh_plan", "plan_refs", "has_exact", "exact_manifold",
43
+ "surface_samples", "edge_accuracy", "segments_for", "study_refs", "mesh_distance"]
44
+
45
+ # chordal tolerance (mm) of the exact cylinders and spheres in a sharp mesh
46
+ CHORD_TOL = 0.002
47
+
48
+
49
+ @dataclass
50
+ class Exact:
51
+ """A plain shape (shapes.Shape) taken exactly into the result."""
52
+ shape: object
53
+ name: str = ""
54
+
55
+
56
+ @dataclass
57
+ class Organic:
58
+ """A field leaf: meshed from `field(P)` (negative inside)."""
59
+ field: object
60
+ names: list = dfield(default_factory=list)
61
+ mesh: object = None # set by mesh_plan
62
+ extended: bool = False
63
+
64
+
65
+ @dataclass
66
+ class Op:
67
+ op: str # union | intersect | subtract
68
+ kids: list
69
+
70
+
71
+ def _hard(nd):
72
+ b = nd.refs.get("blend")
73
+ return b is None or (isinstance(b, float) and abs(b) <= 1e-12)
74
+
75
+
76
+ def _exact_leaf(g, name):
77
+ """The exact shape behind a graph node, or None."""
78
+ from .shapes import MeshShape, shape_from_spec
79
+
80
+ nd = g.nodes[name]
81
+ if nd.op == "shape":
82
+ return g._resource(nd, lambda: shape_from_spec(nd.spec["shape"], g.base_dir))
83
+ if nd.op == "mesh":
84
+ mf = g._resource(nd, lambda: g._mesh_field(nd))
85
+ try:
86
+ return MeshShape(mf.mesh)
87
+ except ValueError:
88
+ return None
89
+ return None
90
+
91
+
92
+ def _chunk(fields):
93
+ """The points per thread task the fields' evaluators ask for (a compiled
94
+ graph's are small, narrowband.peval), when they all ask."""
95
+ cs = [getattr(f, "chunk", None) for f in fields]
96
+ return min(cs) if cs and all(cs) else None
97
+
98
+
99
+ class _Combined:
100
+ """Hard boolean of field leaves: min / max of their values."""
101
+
102
+ def __init__(self, op, fields):
103
+ self.op, self.fields = op, fields
104
+
105
+ @property
106
+ def chunk(self):
107
+ return _chunk(self.fields)
108
+
109
+ def __call__(self, P):
110
+ if self.op == "union":
111
+ return np.min([f(P) for f in self.fields], axis=0)
112
+ if self.op == "intersect":
113
+ return np.max([f(P) for f in self.fields], axis=0)
114
+ first, rest = self.fields[0], self.fields[1:]
115
+ return np.max([first(P)] + [-f(P) for f in rest], axis=0)
116
+
117
+
118
+ def _combine(op, fields):
119
+ if len(fields) == 1:
120
+ return fields[0]
121
+ return _Combined(op, fields)
122
+
123
+
124
+ def plan(g, name=None):
125
+ """The CSG plan of graph node `name` (default the output): an Organic
126
+ leaf for a graph with nothing to do exactly, else a tree of Op, Exact
127
+ and Organic. Hard booleans between field-only inputs stay in the field
128
+ (two field surfaces that coincide, a skin and the envelope it was
129
+ shelled from, must not meet in a mesh boolean)."""
130
+ return _plan(g, name or g.output)
131
+
132
+
133
+ def _plan(g, name):
134
+ nd = g.nodes[name]
135
+ shape = _exact_leaf(g, name)
136
+ if shape is not None:
137
+ return Exact(shape, name)
138
+ if nd.op in ("union", "intersect", "subtract") and _hard(nd):
139
+ kids = [_plan(g, x) if isinstance(x, str) else None for x in nd.refs["inputs"]]
140
+ if any(k is None for k in kids) or all(isinstance(k, Organic) for k in kids):
141
+ return Organic(g.field(name), [name])
142
+ if nd.op in ("union", "intersect"):
143
+ org = [k for k in kids if isinstance(k, Organic)]
144
+ rest = [k for k in kids if not isinstance(k, Organic)]
145
+ if len(org) > 1:
146
+ org = [Organic(_combine(nd.op, [k.field for k in org]), sum((k.names for k in org), []))]
147
+ return Op(nd.op, org + rest)
148
+ first, cutters = kids[0], kids[1:]
149
+ org = [k for k in cutters if isinstance(k, Organic)]
150
+ if isinstance(first, Organic) and org:
151
+ first = Organic(_combine("subtract", [first.field] + [k.field for k in org]),
152
+ first.names + sum((k.names for k in org), []))
153
+ cutters = [k for k in cutters if not isinstance(k, Organic)]
154
+ if not cutters:
155
+ return first
156
+ return Op("subtract", [first] + cutters)
157
+ return Organic(g.field(name), [name])
158
+
159
+
160
+ def has_exact(p):
161
+ return not isinstance(p, Organic)
162
+
163
+
164
+ def _leaves(p):
165
+ if isinstance(p, Op):
166
+ for k in p.kids:
167
+ yield from _leaves(k)
168
+ else:
169
+ yield p
170
+
171
+
172
+ def plan_refs(p, sign=1):
173
+ """[(shape, role)] of the plan's exact leaves: 'cut' (the part is outside
174
+ it), 'add' (unioned on) or 'clip' (the part is inside it)."""
175
+ out = []
176
+ if isinstance(p, Exact):
177
+ return [(p.shape, "add" if sign > 0 else "cut")]
178
+ if isinstance(p, Op):
179
+ for i, k in enumerate(p.kids):
180
+ if p.op == "subtract" and i > 0:
181
+ out += plan_refs(k, -sign)
182
+ elif p.op == "intersect" and isinstance(k, Exact):
183
+ out.append((k.shape, "clip" if sign > 0 else "cut"))
184
+ elif p.op == "subtract" and i == 0 and isinstance(k, Exact):
185
+ out.append((k.shape, "clip" if sign > 0 else "cut"))
186
+ else:
187
+ out += plan_refs(k, sign)
188
+ return out
189
+
190
+
191
+ def study_refs(study):
192
+ """[(shape, role)] of a study's exact interfaces: keep-outs are cut,
193
+ preserved shapes added, the design space clips."""
194
+ return ([(r.shape, "cut") for r in study.keep_out] + [(r.shape, "add") for r in study.preserve]
195
+ + [(sh, "clip") for sh in study.design_space])
196
+
197
+
198
+ # ---- the extended field ----------------------------------------------------------
199
+
200
+ def _dont_care(p, inherited=()):
201
+ """Yield (Organic, [(shape, +1 inside | -1 outside)]): for each organic
202
+ leaf, the regions where its value cannot change the result."""
203
+ if isinstance(p, Organic):
204
+ yield p, list(inherited)
205
+ return
206
+ if not isinstance(p, Op):
207
+ return
208
+ exact = [k for k in p.kids if isinstance(k, Exact)]
209
+ for i, k in enumerate(p.kids):
210
+ if not isinstance(k, (Op, Organic)):
211
+ continue
212
+ extra = []
213
+ if p.op == "union":
214
+ extra = [(e.shape, +1) for e in exact]
215
+ elif p.op == "intersect":
216
+ extra = [(e.shape, -1) for e in exact]
217
+ elif p.op == "subtract":
218
+ if i == 0:
219
+ extra = [(e.shape, +1) for e in exact[0:] if e is not p.kids[0]]
220
+ elif isinstance(p.kids[0], Exact):
221
+ extra = [(p.kids[0].shape, -1)]
222
+ yield from _dont_care(k, list(inherited) + extra)
223
+
224
+
225
+ class _Extended:
226
+ """field(P) carried on into the don't-care region D along its normals:
227
+ with g the signed distance to D (negative inside), a point with
228
+ g < eps reads the field at its projection onto the g = eps level, and
229
+ from 2 * `core` inside D it is solid regardless (the projection
230
+ jumps across D's medial axis; whatever the field does there is cut
231
+ away, so it is kept from drawing surfaces)."""
232
+
233
+ def __init__(self, field, regions, eps, core):
234
+ self.field = field
235
+ self.regions = regions
236
+ self.eps = float(eps)
237
+ self.core = float(core)
238
+
239
+ @property
240
+ def chunk(self):
241
+ return _chunk([self.field])
242
+
243
+ def g(self, P):
244
+ if not hasattr(self, "_g"):
245
+ from . import fieldjit
246
+
247
+ # the same values, one compiled pass (fieldjit.CompiledShapes)
248
+ self._g = fieldjit.compile_shapes(self.regions)
249
+ if self._g is not None:
250
+ return self._g(P)
251
+ return np.min([s * sh.sdf(P) for sh, s in self.regions], axis=0)
252
+
253
+ def __call__(self, P):
254
+ P = np.asarray(P, float).reshape(-1, 3)
255
+ gv = self.g(P)
256
+ near = gv < self.eps
257
+ if not near.any():
258
+ # the same clamp as below: a point must read the same whatever
259
+ # batch it comes in (narrowband merges block seams bit for bit;
260
+ # unclamped here, seams away from the cuts left holes)
261
+ out = np.asarray(self.field(P), float)
262
+ return np.minimum(np.maximum(out, -self.core), gv + self.core)
263
+ # 2 * core deep the clamp below gives gv + core whatever the field
264
+ # reads (a NaN aside): those points skip the projection and the field
265
+ deep = gv + self.core <= -self.core
266
+ live = near & ~deep
267
+ Q = P.copy()
268
+ Pn = P[live]
269
+ h = 1e-3
270
+ grad = np.stack([(self.g(Pn + h * e) - self.g(Pn - h * e)) / (2 * h) for e in np.eye(3)], axis=1)
271
+ nrm = grad / np.maximum(np.linalg.norm(grad, axis=1, keepdims=True), 1e-12)
272
+ Q[live] = Pn + (self.eps - gv[live])[:, None] * nrm
273
+ if deep.any():
274
+ out = np.full(len(P), -self.core)
275
+ if not deep.all():
276
+ out[~deep] = np.asarray(self.field(Q[~deep]), float)
277
+ else:
278
+ out = np.asarray(self.field(Q), float)
279
+ # clamped from below, the carried-on field gives way to the solid
280
+ # core by 2 * core deep: continuous there, whatever the projection
281
+ # does at the medial axis (a jump would wreck narrowband's
282
+ # Lipschitz bound)
283
+ return np.minimum(np.maximum(out, -self.core), gv + self.core)
284
+
285
+
286
+ # ---- meshing ---------------------------------------------------------------------
287
+
288
+ def segments_for(r, tol=CHORD_TOL):
289
+ """Segments per circle so a radius-r polygon is within tol of the circle."""
290
+ if r <= tol:
291
+ return 8
292
+ return int(min(max(math.ceil(math.pi / math.acos(1.0 - tol / r)), 32), 2048))
293
+
294
+
295
+ def exact_manifold(shape, tol=CHORD_TOL):
296
+ """shape.to_manifold() with circles fine enough to be `tol` mm true."""
297
+ import manifold3d as m3d
298
+
299
+ from .shapes import Box, Cylinder, Shell, Sphere, Union
300
+
301
+ if isinstance(shape, Cylinder):
302
+ n = segments_for(shape.r, tol)
303
+ outer = m3d.Manifold.cylinder(shape.length, shape.r, shape.r, n)
304
+ if shape.r_inner > 0:
305
+ ni = segments_for(shape.r_inner, tol)
306
+ inner = m3d.Manifold.cylinder(shape.length + 2.0, shape.r_inner, shape.r_inner, ni).translate((0, 0, -1.0))
307
+ outer = outer - inner
308
+ return shape._align(outer)
309
+ if isinstance(shape, Sphere):
310
+ return m3d.Manifold.sphere(shape.r, segments_for(shape.r, tol)).translate(tuple(shape.center))
311
+ if isinstance(shape, Union):
312
+ mans = [exact_manifold(s, tol) for s in shape.shapes]
313
+ return m3d.Manifold.batch_boolean(mans, m3d.OpType.Add) if len(mans) > 1 else mans[0]
314
+ if isinstance(shape, Shell):
315
+ man = exact_manifold(shape.outer, tol) - exact_manifold(shape.inner, tol)
316
+ return man if shape.clip is None else man ^ exact_manifold(shape.clip, tol)
317
+ if isinstance(shape, Box):
318
+ return shape.to_manifold()
319
+ return shape.to_manifold()
320
+
321
+
322
+ def mesh_plan(p, lo, hi, pitch=None, project=3, progress=None, tol=CHORD_TOL, decimate=None, creases=None):
323
+ """Mesh a plan: organic leaves by fields.field_mesh over [lo, hi] (from
324
+ their extended fields), exact leaves exactly, booleans by manifold3d.
325
+ Returns a trimesh; each Organic leaf keeps its mesh in `.mesh`.
326
+ `creases` (the FieldGraph): an organic leaf that is one graph node gets
327
+ the creases between its own field surfaces back (crease.py); near an
328
+ exact cut, where the mesh is the extended field, none are put in."""
329
+ import manifold3d as m3d
330
+
331
+ from .fields import _finish, field_mesh
332
+ from .shapes import manifold_from_trimesh, trimesh_from_manifold
333
+
334
+ lo = np.asarray(lo, float)
335
+ hi = np.asarray(hi, float)
336
+ if pitch is None:
337
+ pitch = float((np.prod(hi - lo) / 4e6) ** (1 / 3))
338
+ pitch = float(pitch)
339
+ stats = {}
340
+ for leaf, regions in _dont_care(p):
341
+ fld = leaf.field
342
+ if regions:
343
+ fld = _Extended(leaf.field, regions, eps=pitch, core=pitch)
344
+ leaf.extended = True
345
+ tree = None
346
+ if creases is not None and len(leaf.names) == 1:
347
+ from .crease import CreaseTree
348
+
349
+ tree = CreaseTree.from_graph(creases, leaf.names[0])
350
+ if tree is not None and regions:
351
+ # near the exact cuts the mesh is the extended field, not the
352
+ # tree's (and the boolean cuts it away): no creases there
353
+ margin = fld.eps + 4 * pitch
354
+ tree.ignore = lambda P, fld=fld, margin=margin: fld.g(np.asarray(P, float)) < margin
355
+ leaf.mesh = field_mesh(fld, lo, hi, pitch=pitch, project=project, progress=progress, decimate=decimate,
356
+ creases=tree)
357
+ leaf.plain = None
358
+ if tree is not None:
359
+ # if the creased mesh will not close, the plain one is used instead
360
+ leaf.plain = lambda fld=fld: field_mesh(fld, lo, hi, pitch=pitch, project=project, decimate=decimate)
361
+ if leaf.mesh.metadata.get("sampling"):
362
+ stats = leaf.mesh.metadata["sampling"]
363
+
364
+ def organic(m):
365
+ try:
366
+ return manifold_from_trimesh(m)
367
+ except ValueError:
368
+ import trimesh
369
+ m = m.copy()
370
+ trimesh.repair.fix_winding(m)
371
+ trimesh.repair.fill_holes(m)
372
+ # fill_holes leaves loops over four edges: a few slivers a thin
373
+ # lattice wall left open at a coarse pitch. A loop that touches
374
+ # another at a vertex closes on the next pass.
375
+ for last in (False, False, True):
376
+ try:
377
+ return manifold_from_trimesh(m)
378
+ except ValueError:
379
+ if last:
380
+ raise
381
+ m = _close_loops(m)
382
+
383
+ def build(q):
384
+ if isinstance(q, Exact):
385
+ return exact_manifold(q.shape, tol)
386
+ if isinstance(q, Organic):
387
+ try:
388
+ return organic(q.mesh)
389
+ except ValueError:
390
+ if getattr(q, "plain", None) is None:
391
+ raise
392
+ q.mesh = q.plain()
393
+ q.mesh.metadata["creases"] = {"reverted": "the creased mesh would not close"}
394
+ return organic(q.mesh)
395
+ kids = [build(k) for k in q.kids]
396
+ if q.op == "union":
397
+ return m3d.Manifold.batch_boolean(kids, m3d.OpType.Add)
398
+ if q.op == "intersect":
399
+ return m3d.Manifold.batch_boolean(kids, m3d.OpType.Intersect)
400
+ return m3d.Manifold.batch_boolean(kids, m3d.OpType.Subtract)
401
+
402
+ if progress is not None:
403
+ progress({"stage": "booleans", "exact": True})
404
+ man = build(p)
405
+ if man.is_empty():
406
+ raise ValueError("the part is empty after its exact booleans")
407
+ mesh = trimesh_from_manifold(man)
408
+ mesh = _finish(mesh.vertices, mesh.faces, None, pitch, 0, stats or None)
409
+ mesh.metadata["sharp"] = True
410
+ from .step import attach_plan
411
+
412
+ return attach_plan(mesh, p)
413
+
414
+
415
+ def _close_loops(m, max_len=64):
416
+ """Close each open boundary loop of up to `max_len` edges with a fan
417
+ from its centroid, wound against the faces beside it."""
418
+ import trimesh
419
+
420
+ F = np.asarray(m.faces)
421
+ d = np.concatenate([F[:, [0, 1]], F[:, [1, 2]], F[:, [2, 0]]])
422
+ key = np.sort(d, axis=1)
423
+ _, inv, cnt = np.unique(key, axis=0, return_inverse=True, return_counts=True)
424
+ nxt = {int(a): int(b) for a, b in d[cnt[inv.ravel()] == 1]}
425
+ V = [np.asarray(m.vertices, float)]
426
+ new = []
427
+ n = len(V[0])
428
+ while nxt:
429
+ a0, a = nxt.popitem()
430
+ loop = [a0]
431
+ while a is not None and a != a0:
432
+ loop.append(a)
433
+ a = nxt.pop(a, None)
434
+ if a is None or len(loop) < 3 or len(loop) > max_len:
435
+ continue
436
+ V.append(V[0][loop].mean(axis=0)[None])
437
+ new += [[loop[(i + 1) % len(loop)], loop[i], n] for i in range(len(loop))]
438
+ n += 1
439
+ if not new:
440
+ return m
441
+ return trimesh.Trimesh(np.vstack(V), np.vstack([F, np.asarray(new)]), process=False)
442
+
443
+
444
+ # ---- measuring -------------------------------------------------------------------
445
+
446
+ def _frame(axis):
447
+ a = np.asarray(axis, float)
448
+ a = a / np.linalg.norm(a)
449
+ t = np.array([1.0, 0, 0]) if abs(a[0]) < 0.9 else np.array([0, 1.0, 0])
450
+ u = np.cross(a, t)
451
+ u /= np.linalg.norm(u)
452
+ return a, u, np.cross(a, u)
453
+
454
+
455
+ def surface_samples(shape, spacing=0.25):
456
+ """(points, outward normals) spread over a shape's surface about
457
+ `spacing` mm apart."""
458
+ from .shapes import Box, Cylinder, MeshShape, Sphere, Union
459
+
460
+ P, N = [], []
461
+ if isinstance(shape, Box):
462
+ for a in range(3):
463
+ b, c = [i for i in range(3) if i != a]
464
+ ub = np.arange(shape.lo[b] + spacing / 2, shape.hi[b], spacing)
465
+ uc = np.arange(shape.lo[c] + spacing / 2, shape.hi[c], spacing)
466
+ B, C = (x.ravel() for x in np.meshgrid(ub, uc, indexing="ij"))
467
+ for sgn, at in ((-1.0, shape.lo[a]), (1.0, shape.hi[a])):
468
+ q = np.empty((len(B), 3))
469
+ q[:, a], q[:, b], q[:, c] = at, B, C
470
+ n = np.zeros((len(B), 3))
471
+ n[:, a] = sgn
472
+ P.append(q)
473
+ N.append(n)
474
+ elif isinstance(shape, Cylinder):
475
+ a, u, v = _frame(shape.axis)
476
+ ts = np.arange(spacing / 2, shape.length, spacing)
477
+ for r, sgn in ((shape.r, 1.0), (shape.r_inner, -1.0)):
478
+ if r <= 0:
479
+ continue
480
+ th = np.arange(0, 2 * math.pi, spacing / r)
481
+ T, TH = (x.ravel() for x in np.meshgrid(ts, th, indexing="ij"))
482
+ radial = np.cos(TH)[:, None] * u + np.sin(TH)[:, None] * v
483
+ P.append(shape.p0 + T[:, None] * a + r * radial)
484
+ N.append(sgn * radial)
485
+ rings = np.arange(max(shape.r_inner, 0) + spacing / 2, shape.r, spacing)
486
+ for at, sgn in ((shape.p0, -1.0), (shape.p1, 1.0)):
487
+ for r in rings:
488
+ th = np.arange(0, 2 * math.pi, spacing / r)
489
+ radial = np.cos(th)[:, None] * u + np.sin(th)[:, None] * v
490
+ P.append(at + r * radial)
491
+ N.append(np.repeat(sgn * a[None], len(th), axis=0))
492
+ elif isinstance(shape, Sphere):
493
+ n = max(int(4 * math.pi * shape.r ** 2 / spacing ** 2), 16)
494
+ i = np.arange(n) + 0.5
495
+ phi = np.arccos(1 - 2 * i / n)
496
+ th = math.pi * (1 + 5 ** 0.5) * i
497
+ d = np.stack([np.cos(th) * np.sin(phi), np.sin(th) * np.sin(phi), np.cos(phi)], axis=1)
498
+ P.append(shape.center + shape.r * d)
499
+ N.append(d)
500
+ elif isinstance(shape, Union):
501
+ for s in shape.shapes:
502
+ p, n = surface_samples(s, spacing)
503
+ P.append(p)
504
+ N.append(n)
505
+ elif isinstance(shape, MeshShape):
506
+ import trimesh
507
+
508
+ m = shape.mesh
509
+ cnt = int(min(max(m.area / spacing ** 2, 100), 400_000))
510
+ p, fi = trimesh.sample.sample_surface_even(m, cnt, seed=1)
511
+ P.append(np.asarray(p))
512
+ N.append(np.asarray(m.face_normals)[fi])
513
+ if not P:
514
+ return np.zeros((0, 3)), np.zeros((0, 3))
515
+ return np.concatenate(P), np.concatenate(N)
516
+
517
+
518
+ def edge_accuracy(mesh, refs, solid=None, boundary=None, spacing=0.25, tol=0.01, delta=0.05, near=1.0):
519
+ """How exactly `mesh` reproduces the exact shapes it was built with.
520
+
521
+ refs: [(shape, role)] (see plan_refs; studies: keep-outs 'cut', preserved
522
+ 'add', design space 'clip'). A sample of each shape's surface counts
523
+ where it is the part's boundary: `boundary(P)` (True where P is on the
524
+ ideal part's surface, from an exact field) when given, else the
525
+ `solid(P)` oracle (default: the mesh itself) a `delta` step off the
526
+ surface on the material side. For each counted point the distance to
527
+ the mesh is the error; samples more than max(0.5, 4 delta) mm from the
528
+ mesh are taken as not on the part, so that is the largest error read.
529
+
530
+ Returns overall and per-role stats: mean, p99 and max error (mm), the
531
+ fraction of interface area within `tol` (`exactFraction`), and the
532
+ same over the points within `near` mm of a crease of the interface (the
533
+ edges: where the rounding is)."""
534
+ if solid is None:
535
+ def solid(P):
536
+ # the probes are a step off the surface: the closest triangles' side
537
+ return mesh_distance(mesh, P, signed=True) < 0
538
+ rows = {}
539
+ allP, allE, allEdge = [], [], []
540
+ for shape, role in refs:
541
+ P, N = surface_samples(shape, spacing)
542
+ if not len(P):
543
+ continue
544
+ # the shape's own surface, not the parts of it buried in another
545
+ # piece of the same shape (a union's members)
546
+ if not _has_mesh(shape):
547
+ own = np.abs(shape.sdf(P)) < 1e-6
548
+ P, N = P[own], N[own]
549
+ # a sample the mesh does not come near is not on the part (most of
550
+ # a design space's faces): drop those before the costlier tests
551
+ far = max(0.5, 4 * delta)
552
+ dist = mesh_distance(mesh, P, cap=far)
553
+ near_mesh = dist < far
554
+ P, N, dist = P[near_mesh], N[near_mesh], dist[near_mesh]
555
+ if boundary is not None:
556
+ keep = boundary(P)
557
+ else:
558
+ side = -1.0 if role == "clip" else 1.0
559
+ probe = P + side * delta * N
560
+ inside = solid(probe)
561
+ keep = inside if role in ("cut", "clip") else ~inside
562
+ if role == "add":
563
+ # out in the air beside a preserved face, and on the part
564
+ keep &= solid(P - delta * N)
565
+ P, N, dist = P[keep], N[keep], dist[keep]
566
+ if not len(P):
567
+ continue
568
+ # near an edge of this interface: a counted point with an uncounted
569
+ # sample within `near` (the interface stops there: a crease)
570
+ allP.append(P)
571
+ allE.append(dist)
572
+ rows.setdefault(role, []).append(dist)
573
+ if not allE:
574
+ return {"points": 0}
575
+ P = np.concatenate(allP)
576
+ E = np.concatenate(allE)
577
+ edge = _near_boundary(P, near, spacing)
578
+
579
+ def stats(e):
580
+ if not len(e):
581
+ return {"points": 0}
582
+ return {"points": int(len(e)), "mean": round(float(e.mean()), 4), "p99": round(float(np.percentile(e, 99)), 4),
583
+ "max": round(float(e.max()), 4), "exactFraction": round(float((e <= tol).mean()), 4)}
584
+
585
+ if hasattr(mesh, "_loadpath_dist"):
586
+ del mesh._loadpath_dist # the split triangles: big, and only for this measurement
587
+ out = stats(E)
588
+ out["edges"] = stats(E[edge])
589
+ out["tol"] = tol
590
+ out["byRole"] = {k: stats(np.concatenate(v)) for k, v in rows.items()}
591
+ return out
592
+
593
+
594
+ def mesh_distance(mesh, P, edge=0.5, k=16, cap=np.inf, signed=False):
595
+ """Distance from points P to a triangle mesh's surface (signed,
596
+ negative inside, with `signed`: by the normals of the closest
597
+ triangles, so for points near a closed mesh).
598
+
599
+ trimesh.proximity.closest_point gathers candidates by triangle
600
+ bounding boxes, and an exact boolean's long slivers (a bore wall is a
601
+ ring of triangles the length of the bore) make every point a candidate
602
+ of all of them; here the mesh is split to `edge` mm and each point
603
+ tries the k triangles with the nearest centroids. Distances are exact
604
+ up to `cap`; beyond it they are only known to be at least cap."""
605
+ import trimesh
606
+ from scipy.spatial import cKDTree
607
+
608
+ from . import meshops as _meshops
609
+ from .fields import _closest_on_triangles
610
+
611
+ key = (id(mesh), edge)
612
+ cache = getattr(mesh, "_loadpath_dist", None)
613
+ if cache is None or cache[0] != key:
614
+ v, f = np.asarray(mesh.vertices, float), np.asarray(mesh.faces, np.int64)
615
+ # only the long triangles need splitting (a marching-cubes surface's
616
+ # are already short): subdividing those alone gives the same
617
+ # triangles without trimesh's passes over the whole mesh (21 of the
618
+ # arm link's 36 s here)
619
+ tri = v[f]
620
+ e2 = np.max([np.einsum("ij,ij->i", d, d) for d in (tri[:, 1] - tri[:, 0], tri[:, 2] - tri[:, 1],
621
+ tri[:, 0] - tri[:, 2])], axis=0)
622
+ long = e2 > edge * edge
623
+ if long.any():
624
+ vs, fs = trimesh.remesh.subdivide_to_size(v, f[long], max_edge=edge, max_iter=16)
625
+ tri = np.concatenate([tri[~long], vs[fs]])
626
+ nrm = np.cross(tri[:, 1] - tri[:, 0], tri[:, 2] - tri[:, 0])
627
+ nrm /= np.maximum(np.linalg.norm(nrm, axis=1, keepdims=True), 1e-300)
628
+ cache = (key, tri, nrm, cKDTree(tri.mean(axis=1)))
629
+ mesh._loadpath_dist = cache
630
+ _, tri, nrm, tree = cache
631
+ P = np.asarray(P, float).reshape(-1, 3)
632
+ out = np.empty(len(P))
633
+ side = np.ones(len(P))
634
+ k = min(k, len(tri))
635
+ reach = edge / math.sqrt(3.0) # no point of a split triangle is further from its centroid
636
+
637
+ def best(q, cand):
638
+ n = cand.shape[1]
639
+ step = max(1, 2_000_000 // n)
640
+ if len(q) > step:
641
+ parts = [best(q[j:j + step], cand[j:j + step]) for j in range(0, len(q), step)]
642
+ return np.concatenate([x[0] for x in parts]), np.concatenate([x[1] for x in parts])
643
+ if _meshops.available():
644
+ b2, sg = _meshops.nearest_on_triangles(q, cand, tri, nrm if signed else None, 1e-18, parallel=True)
645
+ return np.sqrt(b2), sg
646
+ T = tri[cand]
647
+ Q = np.repeat(q[:, None, :], n, axis=1).reshape(-1, 3)
648
+ C = _closest_on_triangles(Q, T[:, :, 0].reshape(-1, 3), T[:, :, 1].reshape(-1, 3),
649
+ T[:, :, 2].reshape(-1, 3)).reshape(len(q), n, 3)
650
+ d2 = np.sum((C - q[:, None, :]) ** 2, axis=2)
651
+ b2 = d2.min(axis=1)
652
+ if not signed:
653
+ return np.sqrt(b2), np.ones(len(q))
654
+ # the side: normals of every candidate sharing the closest point
655
+ # (an edge or a corner), as fields.MeshField does
656
+ tie = d2 <= b2[:, None] * (1 + 1e-6) + 1e-18
657
+ cb = C[np.arange(len(q)), np.argmin(d2, axis=1)]
658
+ sn = np.einsum("ij,ij->i", q - cb, (nrm[cand] * tie[:, :, None]).sum(axis=1))
659
+ return np.sqrt(b2), np.where(sn < 0, -1.0, 1.0)
660
+
661
+ for i in range(0, len(P), 50_000):
662
+ q = P[i:i + 50_000]
663
+ d = np.full(len(q), np.inf)
664
+ sg = np.ones(len(q))
665
+ todo = np.arange(len(q))
666
+ kk = k
667
+
668
+ def take(idx, cand):
669
+ dd, ss = best(q[idx], cand)
670
+ better = dd < d[idx]
671
+ d[idx[better]] = dd[better]
672
+ sg[idx[better]] = ss[better]
673
+
674
+ # the k nearest centroids miss the closest triangle only if a
675
+ # centroid beyond them is within d + reach: widen k for those
676
+ while len(todo):
677
+ kk = min(kk, len(tri))
678
+ dk = np.empty((len(todo), kk))
679
+ for j in range(0, len(todo), max(1, 2_000_000 // kk)):
680
+ part = todo[j:j + max(1, 2_000_000 // kk)]
681
+ dkj, cand = tree.query(q[part], k=kk)
682
+ dk[j:j + len(part)] = np.asarray(dkj).reshape(len(part), kk)
683
+ take(part, np.asarray(cand).reshape(len(part), kk))
684
+ if kk == len(tri):
685
+ break
686
+ todo = todo[dk[:, -1] < np.minimum(d[todo], cap) + reach]
687
+ kk *= 4
688
+ if kk > 4096 and len(todo):
689
+ for j in todo: # a handful of points among thousands of tiny triangles
690
+ c = tree.query_ball_point(q[j], d[j] + reach)
691
+ if c:
692
+ take(np.array([j]), np.asarray(c)[None])
693
+ break
694
+ out[i:i + 50_000] = d
695
+ side[i:i + 50_000] = sg
696
+ return out * side if signed else out
697
+
698
+
699
+ def _has_mesh(shape):
700
+ from .shapes import MeshShape, Union
701
+
702
+ if isinstance(shape, Union):
703
+ return any(_has_mesh(s) for s in shape.shapes)
704
+ return isinstance(shape, MeshShape)
705
+
706
+
707
+ def _near_boundary(P, near, spacing):
708
+ """Points with fewer neighbours within `near` than a flat interior
709
+ point would have: the rims of an interface patch."""
710
+ from scipy.spatial import cKDTree
711
+
712
+ if len(P) < 4:
713
+ return np.ones(len(P), bool)
714
+ tree = cKDTree(P)
715
+ cnt = np.asarray([len(x) for x in tree.query_ball_point(P, near, workers=-1)])
716
+ full = math.pi * near ** 2 / spacing ** 2
717
+ return cnt < 0.8 * full