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/ibrep.py ADDED
@@ -0,0 +1,552 @@
1
+ """
2
+ Smooth B-rep patches straight from an implicit field: one level set f = 0
3
+ (f < 0 inside) as a quad layout of bicubic Bezier patches that sit on the
4
+ true surface, ready to sew into a closed OpenCascade shell.
5
+
6
+ 1. surface nets: the zero set on a grid of spacing h as a quad mesh (one
7
+ quad per grid edge the surface crosses), made manifold (one vertex per
8
+ surface sheet in a cell, ambiguous faces decided by their centre sample,
9
+ the grid shifted until no edge has more than two quads), vertices on the
10
+ surface (Newton along the gradient of the exact field);
11
+ 2. patches: each quad edge a cubic Bezier through points draped onto the
12
+ surface, each quad a bicubic Bezier patch through its sides and four
13
+ inside points on the surface. Shared edges are computed once, so
14
+ neighbouring patches meet exactly and sew at 1e-4 mm.
15
+
16
+ Accuracy is to the field itself, not to a mesh of it: the patches sit within
17
+ microns of the level set where it is smooth at the scale of h (the report
18
+ samples them). Used by latticestep.py for a skinned TPMS part's cavities.
19
+
20
+ (A general builder for intersections of several families, trimming patches
21
+ along their creases, was tried first, 2026-11-02: 1-3% of trimmed faces
22
+ failed at grazing creases, so the cavity route that needs no trimming won.)
23
+ """
24
+ from __future__ import annotations
25
+
26
+ from math import comb
27
+
28
+ import numpy as np
29
+
30
+ __all__ = ["Family", "Sheet", "manifold_nets", "surface_nets"]
31
+
32
+ DEG = 3 # patch and edge degree
33
+ EPS = 1e-4 # mm: finite-difference step for gradients
34
+
35
+
36
+ class Family:
37
+ """A smooth implicit surface: f(P) (n, 3) -> (n,) mm, negative inside,
38
+ meshed at grid spacing h (mm)."""
39
+
40
+ def __init__(self, name, f, h):
41
+ self.name, self.f, self.h = name, f, float(h)
42
+
43
+ def vg(self, P):
44
+ P = np.asarray(P, float)
45
+ n = len(P)
46
+ Q = np.concatenate([P] + [P + d for d in np.eye(3) * EPS] + [P - d for d in np.eye(3) * EPS])
47
+ v = np.asarray(self.f(Q), float)
48
+ g = np.stack([(v[(1 + a) * n:(2 + a) * n] - v[(4 + a) * n:(5 + a) * n]) / (2 * EPS) for a in range(3)], 1)
49
+ return v[:n], g
50
+
51
+
52
+ def _project(fam, P, it=10, step=None):
53
+ """Points onto fam = 0 (Newton along the gradient, steps capped)."""
54
+ P = np.array(P, float)
55
+ if not len(P):
56
+ return P
57
+ for _ in range(it):
58
+ v, g = fam.vg(P)
59
+ dx = (v / np.maximum((g * g).sum(1), 1e-12))[:, None] * g
60
+ if step is not None:
61
+ L = np.linalg.norm(dx, axis=1, keepdims=True)
62
+ dx *= np.minimum(1.0, step / np.maximum(L, 1e-300))
63
+ P -= dx
64
+ if np.abs(v).max() < 1e-7:
65
+ break
66
+ return P
67
+
68
+
69
+ def surface_nets(fam, lo, hi):
70
+ """Quad mesh of fam's zero set over the box lo..hi: (V (n, 3), Q (m, 4)),
71
+ quads counter-clockwise seen from outside (f > 0). Manifold: a cell the
72
+ surface passes through more than once (a gap thinner than the grid) gets
73
+ one vertex per sheet, the sheets told apart on each cube face as
74
+ marching cubes would (an ambiguous face by its centre's sign), so two
75
+ sheets never share a vertex."""
76
+ h = fam.h
77
+ lo = np.asarray(lo, float)
78
+ n = np.ceil((np.asarray(hi, float) - lo) / h).astype(int) + 1
79
+ ax = [lo[i] + np.arange(n[i]) * h for i in range(3)]
80
+ G = np.stack(np.meshgrid(*ax, indexing="ij"), -1).reshape(-1, 3)
81
+ f = np.empty(len(G))
82
+ for s0 in range(0, len(G), 400_000):
83
+ f[s0:s0 + 400_000] = fam.f(G[s0:s0 + 400_000])
84
+ f = f.reshape(tuple(n))
85
+ inside = f < 0
86
+ nc = n - 1
87
+ corners = [(i, j, k) for i in (0, 1) for j in (0, 1) for k in (0, 1)]
88
+ S = np.stack([inside[i:i + nc[0], j:j + nc[1], k:k + nc[2]] for i, j, k in corners], -1)
89
+ act = S.any(-1) & ~S.all(-1)
90
+ cells = np.argwhere(act)
91
+ cid = -np.ones(tuple(nc), np.int64)
92
+ cid[tuple(cells.T)] = np.arange(len(cells))
93
+ # the 12 cell edges: (axis, offset in the two other axes)
94
+ edges = []
95
+ for a in range(3):
96
+ b, c = [x for x in range(3) if x != a]
97
+ for o1 in (0, 1):
98
+ for o2 in (0, 1):
99
+ off = np.zeros(3, int)
100
+ off[b], off[c] = o1, o2
101
+ edges.append((a, off))
102
+ eix = {(a, tuple(off)): k for k, (a, off) in enumerate(edges)}
103
+ # crossings per cell and edge
104
+ cross = np.zeros((len(cells), 12), bool)
105
+ X = np.zeros((len(cells), 12, 3))
106
+ for k, (a, off) in enumerate(edges):
107
+ e = np.zeros(3, int)
108
+ e[a] = 1
109
+ p0 = cells + off
110
+ p1 = p0 + e
111
+ f0, f1 = f[tuple(p0.T)], f[tuple(p1.T)]
112
+ ch = (f0 < 0) != (f1 < 0)
113
+ t = np.where(ch, f0 / np.where(ch, f0 - f1, 1.0), 0.0)
114
+ cross[:, k] = ch
115
+ X[:, k] = lo + (p0 + t[:, None] * e) * h
116
+ # the 6 faces: normal axis, side; their 4 edges in order round the face
117
+ faces = []
118
+ for a in range(3):
119
+ b, c = [x for x in range(3) if x != a]
120
+ for sd in (0, 1):
121
+ def E(ax_, ob, oc, a=a, b=b, c=c, sd=sd):
122
+ off = np.zeros(3, int)
123
+ off[a], off[b], off[c] = sd, ob, oc
124
+ off[ax_] = 0
125
+ return eix[(ax_, tuple(off))]
126
+ # corners c00 (b=0,c=0), c10, c11, c01; edges bottom (c00-c10, along b, c=0), right (c10-c11, along c, b=1),
127
+ # top (c01-c11, along b, c=1), left (c00-c01, along c, b=0)
128
+ faces.append((a, sd, b, c, [E(b, 0, 0), E(c, 1, 0), E(b, 0, 1), E(c, 0, 0)]))
129
+ # union-find of crossed edges per cell, across its faces
130
+ parent = np.tile(np.arange(12), (len(cells), 1))
131
+
132
+ def find(r, x):
133
+ while parent[r, x] != x:
134
+ parent[r, x] = parent[r, parent[r, x]]
135
+ x = parent[r, x]
136
+ return x
137
+
138
+ def union(rows, x, y):
139
+ for r, a_, b_ in zip(rows, x, y):
140
+ ra, rb = find(r, a_), find(r, b_)
141
+ if ra != rb:
142
+ parent[r, ra] = rb
143
+
144
+ for a, sd, b, c, (eb, er, et, el) in faces:
145
+ cc = cross[:, [eb, er, et, el]]
146
+ cnt = cc.sum(1)
147
+ two = np.flatnonzero(cnt == 2)
148
+ if len(two):
149
+ idx = np.argsort(~cc[two], axis=1, kind="stable")[:, :2]
150
+ loc = np.array([eb, er, et, el])[idx]
151
+ union(two, loc[:, 0], loc[:, 1])
152
+ four = np.flatnonzero(cnt == 4)
153
+ if len(four):
154
+ off = np.zeros(3)
155
+ off[a] = sd
156
+ off[b] = off[c] = 0.5
157
+ Pc = lo + (cells[four] + off) * h
158
+ fc = np.asarray(fam.f(Pc), float)
159
+ p00 = cells[four].copy()
160
+ p00[:, a] += sd
161
+ c00 = inside[tuple(p00.T)]
162
+ joined = (fc < 0) == c00 # c00 and c11 joined through the centre
163
+ # joined: the curves cut off c10 (bottom-right) and c01 (top-left)
164
+ union(four[joined], np.full(joined.sum(), eb), np.full(joined.sum(), er))
165
+ union(four[joined], np.full(joined.sum(), et), np.full(joined.sum(), el))
166
+ union(four[~joined], np.full((~joined).sum(), eb), np.full((~joined).sum(), el))
167
+ union(four[~joined], np.full((~joined).sum(), et), np.full((~joined).sum(), er))
168
+ roots = np.array([[find(r, k) for k in range(12)] for r in range(len(cells))]) if len(cells) else np.zeros((0, 12), int)
169
+ # one vertex per (cell, component)
170
+ key = np.where(cross, np.arange(len(cells))[:, None] * 12 + roots, -1)
171
+ uk, inv = np.unique(key[cross], return_inverse=True)
172
+ vid = -np.ones((len(cells), 12), np.int64)
173
+ vid[cross] = inv
174
+ acc = np.zeros((len(uk), 3))
175
+ np.add.at(acc, inv, X[cross])
176
+ cnt = np.bincount(inv, minlength=len(uk))
177
+ V = acc / np.maximum(cnt, 1)[:, None]
178
+ V = _project(fam, V, step=0.5 * h)
179
+ quads = []
180
+ for a in range(3):
181
+ e = np.zeros(3, int)
182
+ e[a] = 1
183
+ s0 = inside[:n[0] - e[0], :n[1] - e[1], :n[2] - e[2]]
184
+ s1 = inside[e[0]:, e[1]:, e[2]:]
185
+ ch = np.argwhere(s0 != s1)
186
+ b, c = [(1, 2), (2, 0), (0, 1)][a]
187
+ ids, ok = [], np.ones(len(ch), bool)
188
+ for db, dc in ((0, 0), (-1, 0), (-1, -1), (0, -1)):
189
+ q = ch.copy()
190
+ q[:, b] += db
191
+ q[:, c] += dc
192
+ good = (q >= 0).all(1) & (q < nc).all(1)
193
+ ok &= good
194
+ qc = np.where(good[:, None], q, 0)
195
+ ci = cid[tuple(qc.T)]
196
+ off = np.zeros((len(ch), 3), int)
197
+ off[:, b], off[:, c] = -db, -dc # the grid edge's place in this cell
198
+ k = np.array([eix[(a, tuple(o))] for o in off]) if len(ch) else np.zeros(0, int)
199
+ ids.append(np.where(good & (ci >= 0), vid[np.maximum(ci, 0), k], -1))
200
+ ids = np.stack(ids, 1)[ok]
201
+ flip = s0[tuple(ch[ok].T)]
202
+ ids[~flip] = ids[~flip][:, ::-1]
203
+ quads.append(ids)
204
+ Q = np.concatenate(quads) if quads else np.zeros((0, 4), np.int64)
205
+ Q = Q[(Q >= 0).all(1)]
206
+ if len(Q):
207
+ # the grid's own orientation is consistent; check its sense once, on the majority
208
+ nrm = np.cross(V[Q[:, 2]] - V[Q[:, 0]], V[Q[:, 3]] - V[Q[:, 1]])
209
+ _, g = fam.vg(V[Q].mean(axis=1))
210
+ if (np.einsum("ij,ij->i", nrm, g) < 0).mean() > 0.5:
211
+ Q = Q[:, ::-1]
212
+ return V, Q
213
+
214
+
215
+ def nonmanifold(Q):
216
+ """Edges of a quad mesh not shared by exactly two quads."""
217
+ if not len(Q):
218
+ return 0
219
+ E = np.sort(np.concatenate([Q[:, [k, (k + 1) % 4]] for k in range(4)]), axis=1)
220
+ _, c = np.unique(E, axis=0, return_counts=True)
221
+ return int((c != 2).sum())
222
+
223
+
224
+ def _bad_edges(Q):
225
+ """The vertex pairs of edges not shared by exactly two quads."""
226
+ if not len(Q):
227
+ return np.zeros((0, 2), np.int64)
228
+ E = np.sort(np.concatenate([Q[:, [k, (k + 1) % 4]] for k in range(4)]), axis=1)
229
+ u, c = np.unique(E, axis=0, return_counts=True)
230
+ return u[c != 2]
231
+
232
+
233
+ def _same_spot(V0, e, V, f, tol=1e-9):
234
+ """Whether edge e of mesh V0 and edge f of V are the same edge (the
235
+ split moves copies, never the first vertex)."""
236
+ return bool(np.allclose(np.sort(V0[e], axis=0), np.sort(V[f], axis=0), atol=tol) or
237
+ (set(map(int, e)) == set(map(int, f))))
238
+
239
+
240
+ def manifold_nets(fam, lo, hi, tries=16, split=True):
241
+ """surface_nets on a grid shifted until the mesh is a closed manifold
242
+ (a cell the surface crosses in the ambiguous way marching cubes cannot
243
+ split moves off the grid), or the best of `tries` shifts; then (split)
244
+ each try's pinches (split_pinches) pulled apart first. Returns (V, Q,
245
+ non-manifold edges left)."""
246
+ best = None
247
+ rng = np.random.default_rng(7)
248
+ for k in range(tries):
249
+ shift = np.zeros(3) if k == 0 else rng.uniform(0, 1, 3) * fam.h
250
+ V, Q = surface_nets(fam, np.asarray(lo, float) - shift, np.asarray(hi, float) - shift + fam.h)
251
+ bad = nonmanifold(Q)
252
+ if bad and split:
253
+ V0, Q0 = V, Q
254
+ V, Q = split_pinches(fam, V0, Q0)
255
+ bad = nonmanifold(Q)
256
+ if bad:
257
+ # the first pairing at an edge can join the sheets' fans
258
+ # again at its ends (each vertex one fan, the edge still
259
+ # four quads): try the other pairing at the edges left
260
+ left = _bad_edges(Q)
261
+ alt = {(int(min(e)), int(max(e))) for e in _bad_edges(Q0)
262
+ if any(_same_spot(V0, e, V, f) for f in left)}
263
+ if alt:
264
+ V2, Q2 = split_pinches(fam, V0, Q0, alternate=alt)
265
+ if nonmanifold(Q2) < bad:
266
+ V, Q, bad = V2, Q2, nonmanifold(Q2)
267
+ if best is None or bad < best[0]:
268
+ best = (bad, V, Q)
269
+ if bad == 0:
270
+ break
271
+ bad, V, Q = best
272
+ return V, Q, bad
273
+
274
+
275
+ def split_pinches(fam, V, Q, alternate=()):
276
+ """Pull a quad mesh apart where two sheets of the surface pinch together
277
+ at a near-zero saddle (two struts' fillets that barely touch, a skin
278
+ kissed by a fillet): one cell meets the surface twice and its one vertex
279
+ joins both, so a quad edge has four quads, or a vertex two separate
280
+ fans. Each such edge's quads are paired (one each way along the edge,
281
+ neighbours round it) and every vertex gets one copy per fan of its
282
+ quads; a copy is moved a little towards its own fan and back onto the
283
+ surface, so the sheets part by the width of the saddle. Returns (V, Q):
284
+ a manifold mesh (every edge two quads, every vertex one fan)."""
285
+ Q = np.array(Q, np.int64)
286
+ if not len(Q):
287
+ return V, Q
288
+ # directed edges: quad q, side k runs Q[q, k] -> Q[q, k+1]
289
+ a = Q.reshape(-1)
290
+ b = np.roll(Q, -1, axis=1).reshape(-1)
291
+ qid = np.repeat(np.arange(len(Q)), 4)
292
+ lo_, hi_ = np.minimum(a, b), np.maximum(a, b)
293
+ key = lo_ * (len(V) + 1) + hi_
294
+ _, inv, cnt = np.unique(key, return_inverse=True, return_counts=True)
295
+ many = np.flatnonzero(cnt > 2)
296
+ # pairs: half-edge index -> partner half-edge index (normal edges: the other one)
297
+ order = np.argsort(inv, kind="stable")
298
+ starts = np.r_[0, np.cumsum(cnt)[:-1]]
299
+ partner = -np.ones(len(a), np.int64)
300
+ two = np.flatnonzero(cnt == 2)
301
+ h0, h1 = order[starts[two]], order[starts[two] + 1]
302
+ partner[h0], partner[h1] = h1, h0
303
+ C = V[Q].mean(axis=1)
304
+ for e in many:
305
+ hs = order[starts[e]:starts[e] + cnt[e]]
306
+ va, vb = V[lo_[hs[0]]], V[hi_[hs[0]]]
307
+ ax = vb - va
308
+ ax /= max(np.linalg.norm(ax), 1e-12)
309
+ ref = np.cross(ax, [1.0, 0, 0])
310
+ if np.linalg.norm(ref) < 0.1:
311
+ ref = np.cross(ax, [0, 1.0, 0])
312
+ ref /= np.linalg.norm(ref)
313
+ ref2 = np.cross(ax, ref)
314
+ d = C[qid[hs]] - va
315
+ ang = np.arctan2(d @ ref2, d @ ref)
316
+ hs = hs[np.argsort(ang)]
317
+ # neighbours round the edge, one each way: two choices of pairing
318
+ # (0-1, 2-3, ...) or (1-2, 3-0, ...); take the one whose pairs sit
319
+ # closer in angle (the same sheet)
320
+ m = len(hs)
321
+ cands = []
322
+ for sh in (0, 1):
323
+ pairs = [(hs[(i + sh) % m], hs[(i + sh + 1) % m]) for i in range(0, m, 2)]
324
+ if any((a[x] == lo_[x]) == (a[y] == lo_[y]) for x, y in pairs):
325
+ continue
326
+ cands.append((sum(np.linalg.norm(C[qid[x]] - C[qid[y]]) for x, y in pairs), pairs))
327
+ if m == 4:
328
+ # the angular order round a short edge (its ends projected close
329
+ # together) can interleave the sheets: any pairing of the four
330
+ # whose halves run opposite ways is a candidate
331
+ for pairs in (((hs[0], hs[2]), (hs[1], hs[3])),):
332
+ if not any((a[x] == lo_[x]) == (a[y] == lo_[y]) for x, y in pairs):
333
+ cands.append((sum(np.linalg.norm(C[qid[x]] - C[qid[y]]) for x, y in pairs), list(pairs)))
334
+ if not cands: # odd or unpaired: leave it
335
+ continue
336
+ cands.sort(key=lambda c: c[0])
337
+ # `alternate`: edges a first split left joined (manifold_nets asks
338
+ # again): the next candidate pairing
339
+ best = cands[1] if (int(lo_[hs[0]]), int(hi_[hs[0]])) in alternate and len(cands) > 1 else cands[0]
340
+ for x, y in best[1]:
341
+ partner[x], partner[y] = y, x
342
+ # fans: at each vertex, quads joined across the paired edges at it
343
+ import scipy.sparse as sp
344
+ from scipy.sparse.csgraph import connected_components
345
+
346
+ # a corner (q, k) is vertex Q[q, k]; its two sides are half-edges k
347
+ # (leaving it) and k-1 (arriving); join the corner to the corner of
348
+ # the partner quad at the same vertex
349
+ corner = np.arange(len(a))
350
+ side_out = corner # half-edge leaving corner
351
+ side_in = qid * 4 + (corner % 4 + 3) % 4 # half-edge arriving at corner
352
+ rows, cols = [], []
353
+ for hs_ in (side_out, side_in):
354
+ p = partner[hs_]
355
+ ok = p >= 0
356
+ # partner half-edge runs the other way: its corner at this vertex is
357
+ # its end (k+1) when hs_ leaves, its start when hs_ arrives
358
+ pq, pk = p[ok] // 4, p[ok] % 4
359
+ if hs_ is side_out:
360
+ pc = pq * 4 + (pk + 1) % 4
361
+ else:
362
+ pc = pq * 4 + pk
363
+ rows.append(corner[ok])
364
+ cols.append(pc)
365
+ rows, cols = np.concatenate(rows), np.concatenate(cols)
366
+ good = a[rows] == a[cols]
367
+ G = sp.coo_matrix((np.ones(good.sum()), (rows[good], cols[good])), shape=(len(a), len(a)))
368
+ _, lab = connected_components(G, directed=False)
369
+ # one vertex per (vertex, fan)
370
+ vk = a * (len(a) + 1) + lab
371
+ ufan, finv = np.unique(vk, return_inverse=True)
372
+ vert = ufan // (len(a) + 1)
373
+ first = np.zeros(len(V), bool)
374
+ newid = np.empty(len(ufan), np.int64)
375
+ extra = []
376
+ for i, v in enumerate(vert):
377
+ if not first[v]:
378
+ first[v] = True
379
+ newid[i] = v
380
+ else:
381
+ newid[i] = len(V) + len(extra)
382
+ extra.append(i)
383
+ Q2 = newid[finv].reshape(-1, 4)
384
+ if extra:
385
+ # each copy (and the vertex it came from) nudged towards its own fan
386
+ split_v = np.unique(vert[extra])
387
+ fans = np.flatnonzero(np.isin(vert, split_v))
388
+ cen = np.zeros((len(ufan), 3))
389
+ np.add.at(cen, finv, C[qid])
390
+ cen /= np.maximum(np.bincount(finv, minlength=len(ufan)), 1)[:, None]
391
+ P = V[vert[fans]] + 0.2 * (cen[fans] - V[vert[fans]])
392
+ P = _project(fam, P, step=0.1 * fam.h)
393
+ Vn = np.concatenate([V, np.zeros((len(extra), 3))])
394
+ Vn[newid[fans]] = P
395
+ V = Vn
396
+ return V, Q2
397
+
398
+
399
+ _B = np.array([[comb(DEG, j) * x ** j * (1 - x) ** (DEG - j) for j in range(DEG + 1)]
400
+ for x in np.linspace(0, 1, DEG + 1)])
401
+ _BI = np.linalg.inv(_B)
402
+ _BINT = np.linalg.inv(_B[1:-1, 1:-1])
403
+ _U = np.linspace(0, 1, DEG + 1)
404
+
405
+
406
+ def bern(t):
407
+ t = np.atleast_1d(np.asarray(t, float))
408
+ return np.stack([comb(DEG, j) * t ** j * (1 - t) ** (DEG - j) for j in range(DEG + 1)], -1)
409
+
410
+
411
+ def dbern(t):
412
+ t = np.atleast_1d(np.asarray(t, float))
413
+ d = DEG
414
+ out = np.zeros(t.shape + (d + 1,))
415
+ for j in range(d + 1):
416
+ a = j * t ** (j - 1) * (1 - t) ** (d - j) if j > 0 else 0.0
417
+ b = (d - j) * t ** j * (1 - t) ** (d - j - 1) if j < d else 0.0
418
+ out[..., j] = comb(d, j) * (a - b)
419
+ return out
420
+
421
+
422
+ def _resample(P, n):
423
+ d = np.r_[0, np.cumsum(np.linalg.norm(np.diff(P, axis=0), axis=1))]
424
+ if d[-1] <= 0:
425
+ return np.repeat(P[:1], n, axis=0)
426
+ t = np.linspace(0, d[-1], n)
427
+ return np.stack([np.interp(t, d, P[:, k]) for k in range(3)], 1)
428
+
429
+
430
+ class Sheet:
431
+ """One family's patches: V, Q, edge poles {(a, b) a < b: (4, 3) from a
432
+ to b}, patch poles C (m, 4, 4, 3) (C[i, j]: i along u, j along v; u
433
+ from corner 0 to 1, v from corner 0 to 3)."""
434
+
435
+ def __init__(self, fam, V, Q):
436
+ self.fam, self.V, self.Q = fam, V, Q
437
+ E = np.sort(np.concatenate([Q[:, [k, (k + 1) % 4]] for k in range(4)]), axis=1)
438
+ self.E = np.unique(E, axis=0)
439
+ self.eidx = {(int(a), int(b)): i for i, (a, b) in enumerate(self.E)}
440
+ n = DEG + 1
441
+ A = self.E
442
+ # drape: onto the surface, evenly spaced by arc length
443
+ t = np.linspace(0, 1, 9)[1:-1][None, :, None]
444
+ P = V[A[:, 0]][:, None] * (1 - t) + V[A[:, 1]][:, None] * t
445
+ for r in range(4):
446
+ P = _project(fam, P.reshape(-1, 3), step=0.5 * fam.h).reshape(P.shape)
447
+ full = np.concatenate([V[A[:, 0]][:, None], P, V[A[:, 1]][:, None]], 1)
448
+ P = np.stack([_resample(x, 9 if r < 3 else n) for x in full])[:, 1:-1]
449
+ P = _project(fam, P.reshape(-1, 3), step=0.5 * fam.h).reshape(P.shape)
450
+ full = np.concatenate([V[A[:, 0]][:, None], P, V[A[:, 1]][:, None]], 1)
451
+ self.EP = np.einsum("ij,ejk->eik", _BI, full) # (e, 4, 3) poles a -> b
452
+ # an edge whose drape ran off (a pull onto another sheet, a fold)
453
+ # is the chord: wrong by its sag, never by a spike
454
+ S = np.einsum("ta,eax->etx", bern(np.linspace(0, 1, 9)), self.EP)
455
+ a0, a1 = V[A[:, 0]], V[A[:, 1]]
456
+ L = np.linalg.norm(a1 - a0, axis=1)
457
+ wild = np.linalg.norm(S - 0.5 * (a0 + a1)[:, None], axis=2).max(1) > 0.5 * L + 0.5 * fam.h
458
+ if wild.any():
459
+ t_ = _U[None, :, None]
460
+ self.EP[wild] = np.einsum("ij,ejk->eik", _BI, a0[wild][:, None] * (1 - t_) + a1[wild][:, None] * t_)
461
+ self.wild_edges = int(wild.sum())
462
+ sides = [self.curve(Q[:, 0], Q[:, 1]), self.curve(Q[:, 3], Q[:, 2]),
463
+ self.curve(Q[:, 0], Q[:, 3]), self.curve(Q[:, 1], Q[:, 2])]
464
+ sb, st, sl, sr = (np.einsum("ij,mjk->mik", _B, s) for s in sides)
465
+ Uu, W = np.meshgrid(_U, _U, indexing="ij")
466
+ X = ((1 - W)[None, ..., None] * sb[:, :, None] + W[None, ..., None] * st[:, :, None]
467
+ + (1 - Uu)[None, ..., None] * sl[:, None] + Uu[None, ..., None] * sr[:, None]
468
+ - ((1 - Uu) * (1 - W))[None, ..., None] * sb[:, :1, None] - (Uu * (1 - W))[None, ..., None] * sb[:, -1:, None]
469
+ - ((1 - Uu) * W)[None, ..., None] * st[:, :1, None] - (Uu * W)[None, ..., None] * st[:, -1:, None])
470
+ X0 = X.copy()
471
+ inner = _project(fam, X[:, 1:-1, 1:-1].reshape(-1, 3), step=0.5 * fam.h)
472
+ X[:, 1:-1, 1:-1] = inner.reshape(len(Q), n - 2, n - 2, 3)
473
+ # an inside point pulled further than the patch is across went to
474
+ # another sheet: that patch keeps its Coons blend
475
+ span = np.linalg.norm(X0[:, 0, 0] - X0[:, -1, -1], axis=1) + np.linalg.norm(X0[:, -1, 0] - X0[:, 0, -1], axis=1)
476
+ moved = np.linalg.norm(X - X0, axis=3).max((1, 2))
477
+ off = moved > 0.35 * span
478
+ X[off] = X0[off]
479
+ self.wild_patches = int(off.sum())
480
+ C = np.zeros((len(Q), n, n, 3))
481
+ C[:, :, 0], C[:, :, -1], C[:, 0, :], C[:, -1, :] = sides[0], sides[1], sides[2], sides[3]
482
+ Cb = C.copy()
483
+ Cb[:, 1:-1, 1:-1] = 0
484
+ R = X[:, 1:-1, 1:-1] - np.einsum("ia,mabk,jb->mijk", _B[1:-1], Cb, _B[1:-1])
485
+ C[:, 1:-1, 1:-1] = np.einsum("ia,mabk,jb->mijk", _BINT, R, _BINT)
486
+ self.C = C
487
+
488
+ def curve(self, a, b):
489
+ """Poles of edges a -> b (arrays of vertex ids): (m, 4, 3)."""
490
+ a, b = np.asarray(a), np.asarray(b)
491
+ lo, hi = np.minimum(a, b), np.maximum(a, b)
492
+ i = np.array([self.eidx[(int(x), int(y))] for x, y in zip(lo, hi)], np.int64)
493
+ P = self.EP[i]
494
+ rev = a > b
495
+ P[rev] = P[rev][:, ::-1]
496
+ return P
497
+
498
+ def at(self, qi, u, v):
499
+ """Patch points: qi (k,), u, v (k,) or grids broadcast."""
500
+ return np.einsum("ka,kabx,kb->kx", bern(u), self.C[qi], bern(v))
501
+
502
+ def grid(self, qi, m):
503
+ """(len(qi), m, m, 3) points at an m x m grid of (u, v)."""
504
+ B = bern(np.linspace(0, 1, m))
505
+ return np.einsum("ua,qabx,vb->quvx", B, self.C[qi], B)
506
+
507
+ def invert(self, q, x, m=9, it=12):
508
+ """(u, v) of the point of patch q closest to x, not clamped: outside
509
+ [0, 1]^2 when x is past the patch's sides."""
510
+ G = self.grid(np.array([q]), m)[0]
511
+ k = int(np.argmin(np.linalg.norm(G.reshape(-1, 3) - x, axis=1)))
512
+ u, v = (k // m) / (m - 1), (k % m) / (m - 1)
513
+ C = self.C[q]
514
+ for _ in range(it):
515
+ bu, bv = bern(u)[0], bern(v)[0]
516
+ du, dv = dbern(u)[0], dbern(v)[0]
517
+ P = np.einsum("a,abx,b->x", bu, C, bv)
518
+ Pu = np.einsum("a,abx,b->x", du, C, bv)
519
+ Pv = np.einsum("a,abx,b->x", bu, C, dv)
520
+ J = np.stack([Pu, Pv], 1)
521
+ step = np.linalg.lstsq(J, x - P, rcond=None)[0]
522
+ u, v = u + float(step[0]), v + float(step[1])
523
+ if abs(step).max() < 1e-9:
524
+ break
525
+ return u, v
526
+
527
+ def folds(self, m=5):
528
+ """Patches whose normal turns against the surface's somewhere (a
529
+ fold: the patch is too coarse for the surface there): (m,) bool."""
530
+ t = np.linspace(0.05, 0.95, m)
531
+ B, D = bern(t), dbern(t)
532
+ C = self.C
533
+ P = np.einsum("ua,qabx,vb->quvx", B, C, B)
534
+ Su = np.einsum("ua,qabx,vb->quvx", D, C, B)
535
+ Sv = np.einsum("ua,qabx,vb->quvx", B, C, D)
536
+ N = np.cross(Su, Sv)
537
+ _, g = self.fam.vg(P.reshape(-1, 3))
538
+ s = np.sign(np.einsum("ix,ix->i", N.reshape(-1, 3), g)).reshape(len(C), -1)
539
+ major = np.sign(s.sum()) or 1.0
540
+ return (s != major).any(1)
541
+
542
+ def deviation(self, m=7):
543
+ return self.deviation_of(np.arange(len(self.Q)), m)
544
+
545
+ def deviation_of(self, qi, m=7):
546
+ """Distance (mm, first order) of patches qi from the level set at an
547
+ m x m grid each."""
548
+ P = self.grid(np.asarray(qi), m).reshape(-1, 3)
549
+ v, g = self.fam.vg(P)
550
+ return np.abs(v) / np.maximum(np.linalg.norm(g, axis=1), 1e-9)
551
+
552
+