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/crease.py ADDED
@@ -0,0 +1,1138 @@
1
+ """
2
+ Sharp creases between field surfaces, without an exact B-rep.
3
+
4
+ Marching cubes cannot hold a crease: wherever a hard boolean joins two
5
+ surfaces at an angle, the triangles that straddle the join cut the corner
6
+ off, and the part comes out rounded over about one grid step. sharp.py
7
+ fixes that where one side of the boolean is a plain shape or a mesh, by
8
+ doing the boolean exactly; between two organic fields (a TPMS clipped by
9
+ a shell, two smooth bodies unioned without a blend, a body minus another)
10
+ there is no exact surface to hand to a mesh boolean.
11
+
12
+ But the graph still knows how the field was built. A hard union,
13
+ intersect or subtract is a min or a max of its inputs, so at every point
14
+ one input (a *leaf*) is the active one, and the crease is where the active
15
+ leaf changes: the curve where two leaf surfaces meet (or the corner where
16
+ three do). `CreaseTree.from_graph` reads that min/max tree off the graph;
17
+ `sharpen(V, F, tree, pitch)` then works on the finished, projected
18
+ marching-cubes mesh:
19
+
20
+ 1. every vertex is labelled with its active leaf; an edge whose ends have
21
+ different labels crosses a crease;
22
+ 2. a vertex very close to the crease (the crease point found from its
23
+ edge is within `snap` of the edge length of it) is moved onto the
24
+ crease and takes both labels, so the split below leaves no slivers;
25
+ 3. every edge that still crosses a crease is split at the crease point,
26
+ found by Gauss-Newton on the two leaves' zero levels (two constraints:
27
+ the intersection curve; three where three labels meet in a triangle:
28
+ the corner), and every triangle is re-cut by how many of its edges
29
+ were split (two, three or six triangles);
30
+ 4. near the creases, every triangle thinner than 8 degrees or turned over
31
+ against its own surface's true normal is collapsed away (shortest edge,
32
+ the crease end kept, link condition checked, nothing may turn over);
33
+ 5. a crease vertex whose triangles still cross another part of the mesh
34
+ or face the wrong way goes back to where the plain mesh had it: the
35
+ crease is kept only where it does no harm.
36
+
37
+ Edges are left alone where there is no crease to see or the tree does not
38
+ describe the mesh: two leaves meeting at under `tangent` degrees (a skin's
39
+ outside lying on the body it was shelled from), within a few rings of a hole
40
+ the mesher left (a thin wall; the boolean step repairs it), and where
41
+ `tree.ignore` says so (sharp.py: near an exact cut, where the mesh is the
42
+ extended field). Creases are on by default (mesh.creases).
43
+
44
+ Where lattice walls meet a body at under ~45 degrees the material (or the
45
+ gap) between them is thinner than one pitch for a pitch or more back from
46
+ the true crease: that knife edge is not in the marching-cubes mesh at all,
47
+ and no relax or snapping of its vertices brings it back (moonshots round 2,
48
+ crease-aware relax tried 2026-10-31: same-side smoothing, own-leaf
49
+ projection, a flip guard; none beat the plain relax before this pass).
50
+
51
+ The split is decided per edge, so both triangles of an edge agree and the
52
+ mesh stays watertight; no vertex off the crease moves (other than the few
53
+ collapsed into it). A crease point that
54
+ Gauss-Newton does not find near the edge (the leaves are tangent there, or
55
+ a third leaf owns the spot) falls back to the edge midpoint projected onto
56
+ the field, which is what the mesh had. The crease vertices land on the
57
+ true intersection to the solver tolerance, and the triangles on each side
58
+ lie on their own surface, so the dihedral angle comes out as designed.
59
+ """
60
+ from __future__ import annotations
61
+
62
+ import numpy as np
63
+
64
+ __all__ = ["CreaseTree", "sharpen"]
65
+
66
+
67
+ class _Leaf:
68
+ __slots__ = ("fn", "sign", "name", "index", "spec")
69
+
70
+ def __init__(self, fn, sign, name, spec=None):
71
+ self.fn, self.sign, self.name, self.index = fn, sign, name, -1
72
+ self.spec = spec # (node, plus, plus sign) for a graph's leaf: fieldjit compiles it
73
+
74
+
75
+ class _Node:
76
+ __slots__ = ("kind", "kids")
77
+
78
+ def __init__(self, kind, kids):
79
+ self.kind, self.kids = kind, kids # kind: "min" | "max"
80
+
81
+
82
+ def _negate(t):
83
+ if isinstance(t, _Leaf):
84
+ return _Leaf(t.fn, -t.sign, t.name, t.spec)
85
+ return _Node("max" if t.kind == "min" else "min", [_negate(k) for k in t.kids])
86
+
87
+
88
+ class CreaseTree:
89
+ """The hard min/max tree above a field: leaves are smooth fields, each
90
+ with a sign (a subtracted input enters negated). `values(P)` gives
91
+ every leaf's signed value, `evaluate(P)` the field and the active leaf."""
92
+
93
+ def __init__(self, root, evaluator=None):
94
+ self.root = root
95
+ self.leaves = []
96
+ seen = {}
97
+
98
+ def walk(t):
99
+ if isinstance(t, _Leaf):
100
+ # the same field met twice (a body that is both shelled and
101
+ # clipped against) is one surface: one leaf
102
+ key = (t.name, t.sign)
103
+ if key in seen:
104
+ t.index = seen[key]
105
+ return
106
+ t.index = seen[key] = len(self.leaves)
107
+ self.leaves.append(t)
108
+ else:
109
+ for k in t.kids:
110
+ walk(k)
111
+ walk(root)
112
+ self._evaluator = evaluator
113
+ # optional P -> bool: points where the mesh is not this tree's field
114
+ # (sharp.py's extended field near an exact cut): no crease there
115
+ self.ignore = None
116
+
117
+ def __len__(self):
118
+ return len(self.leaves)
119
+
120
+ # -- construction --
121
+
122
+ @classmethod
123
+ def from_graph(cls, g, name=None, shells=True):
124
+ """The tree of graph node `name` (default the output), or None when
125
+ it has no hard boolean between fields at the top (nothing to sharpen).
126
+ Descends through union, intersect and subtract with no blend and,
127
+ with `shells`, through shells (inside: max(f, -(f + t)), outside:
128
+ max(-f, f - t)), whose rims are creases too."""
129
+ from .narrowband import peval # noqa: F401 (import check: fields are numpy callables)
130
+
131
+ def is_hard(nd):
132
+ b = nd.refs.get("blend")
133
+ return b is None or (isinstance(b, float) and abs(b) <= 1e-12)
134
+
135
+ def leaf(ref, sign=1.0, plus=None):
136
+ # a leaf: node `ref` (+ an optional thickness node or number)
137
+ def fn(P, memo):
138
+ v = g._val(ref, P, memo)
139
+ if plus is not None:
140
+ v = v + g._val(plus[0], P, memo) * plus[1]
141
+ return np.broadcast_to(np.asarray(v, float), (len(P),))
142
+ t = None if plus is None else plus[0] if isinstance(plus[0], str) else f"{plus[0]:g}"
143
+ spec = (ref, None, 0.0) if plus is None else (ref, plus[0], float(plus[1]))
144
+ return _Leaf(fn, sign, ref if plus is None else f"{ref}{'+' if plus[1] > 0 else '-'}{t}", spec)
145
+
146
+ def build(ref):
147
+ if isinstance(ref, float):
148
+ return None
149
+ nd = g.nodes[ref]
150
+ if nd.op in ("union", "intersect", "subtract") and is_hard(nd):
151
+ kids = [build(x) or leaf(x) for x in nd.refs["inputs"] if not isinstance(x, float)]
152
+ if len(kids) != len(nd.refs["inputs"]):
153
+ return None
154
+ if nd.op == "union":
155
+ return _Node("min", kids)
156
+ if nd.op == "intersect":
157
+ return _Node("max", kids)
158
+ return _Node("max", [kids[0]] + [_negate(k) for k in kids[1:]])
159
+ if shells and nd.op == "shell" and not isinstance(nd.refs.get("input"), float):
160
+ f, t = nd.refs["input"], nd.refs.get("thickness", 0.0)
161
+ side = nd.spec.get("side", "inside")
162
+ inner = build(f) or leaf(f)
163
+ if side == "inside": # max(f, -(f + t))
164
+ return _Node("max", [inner, leaf(f, -1.0, (t, 1.0))])
165
+ if side == "outside": # max(-f, f - t)
166
+ return _Node("max", [_negate(inner), leaf(f, 1.0, (t, -1.0))])
167
+ return None
168
+ return None
169
+
170
+ root = build(name or g.output)
171
+ if root is None:
172
+ return None
173
+ tree = cls(root)
174
+
175
+ def ev(P):
176
+ rows = tree._compiled(g, None)
177
+ if rows is not None:
178
+ return rows(P)
179
+ memo = {}
180
+ P = np.asarray(P, float).reshape(-1, 3)
181
+ return np.stack([lf.sign * lf.fn(P, memo) for lf in tree.leaves])
182
+ tree._evaluator = ev
183
+ tree._graph = g
184
+ return tree
185
+
186
+ def _compiled(self, g, only):
187
+ """fieldjit.CompiledRows for the leaves `only` (all with None): every
188
+ leaf in one compiled pass, the same values. None when fieldjit is off."""
189
+ key = None if only is None else tuple(only)
190
+ cache = self.__dict__.setdefault("_rows", {})
191
+ if key not in cache:
192
+ from . import fieldjit
193
+
194
+ leaves = self.leaves if only is None else [self.leaves[j] for j in only]
195
+ cache[key] = fieldjit.compile_rows(g, [(lf.spec[0], lf.spec[1], lf.spec[2], lf.sign)
196
+ for lf in leaves])
197
+ return cache[key]
198
+
199
+ @classmethod
200
+ def from_callables(cls, spec):
201
+ """A tree from nested tuples: ("min"|"max", [kids]) or a callable
202
+ P -> values (a leaf), ("neg", kid) to negate. For experiments."""
203
+ def build(s):
204
+ if callable(s):
205
+ return _Leaf(lambda P, memo, s=s: np.asarray(s(P), float), 1.0, f"leaf{id(s)}")
206
+ if s[0] == "neg":
207
+ return _negate(build(s[1]))
208
+ return _Node(s[0], [build(k) for k in s[1]])
209
+ tree = cls(build(spec))
210
+
211
+ def ev(P):
212
+ P = np.asarray(P, float).reshape(-1, 3)
213
+ return np.stack([lf.sign * lf.fn(P, {}) for lf in tree.leaves])
214
+ tree._evaluator = ev
215
+ return tree
216
+
217
+ # -- evaluation --
218
+
219
+ def values(self, P, only=None):
220
+ """(n_leaves, n) signed leaf values, in chunks over threads (as
221
+ narrowband.peval); with `only` (leaf
222
+ indices), just those rows (the rest are left zero): a lattice leaf
223
+ costs many times a body's, so a stencil about one leaf's surface
224
+ need not pay for the others."""
225
+ from .narrowband import pblock
226
+
227
+ P = np.asarray(P, float).reshape(-1, 3)
228
+ L = len(self.leaves)
229
+ if only is not None:
230
+ only = [int(i) for i in np.unique(only)]
231
+ if len(only) == L:
232
+ only = None
233
+ g = getattr(self, "_graph", None)
234
+ if only is None:
235
+ # compiled leaves thread well in small chunks (narrowband.peval)
236
+ ck = 32_768 if g is not None and self._compiled(g, None) is not None else 250_000
237
+ return pblock(self._evaluator, P, L, chunk=ck)
238
+
239
+ rows = self._compiled(g, only) if g is not None else None
240
+ if rows is not None:
241
+ def some(Q):
242
+ out = np.zeros((L, len(Q)))
243
+ out[only] = rows(Q)
244
+ return out
245
+ return pblock(some, P, L, chunk=32_768)
246
+
247
+ def some(Q):
248
+ out = np.zeros((L, len(Q)))
249
+ memo = {}
250
+ for j in only:
251
+ lf = self.leaves[j]
252
+ out[j] = lf.sign * lf.fn(Q, memo)
253
+ return out
254
+ return pblock(some, P, L)
255
+
256
+ def combine(self, vals):
257
+ """(field value, active leaf index) from leaf values."""
258
+ def go(t):
259
+ if isinstance(t, _Leaf):
260
+ return vals[t.index], np.full(vals.shape[1], t.index)
261
+ parts = [go(k) for k in t.kids]
262
+ v = np.stack([p[0] for p in parts])
263
+ lab = np.stack([p[1] for p in parts])
264
+ j = np.argmin(v, axis=0) if t.kind == "min" else np.argmax(v, axis=0)
265
+ idx = np.arange(v.shape[1])
266
+ return v[j, idx], lab[j, idx]
267
+ return go(self.root)
268
+
269
+ def evaluate(self, P):
270
+ return self.combine(self.values(P))
271
+
272
+
273
+ # ---- Gauss-Newton onto the meeting of several leaf surfaces ------------------------
274
+
275
+ def _leaf_grad(tree, P, which, h, center=True):
276
+ """Values and central-difference gradients of the leaves `which`
277
+ (n, k) at P (n, 3): (n, k) and (n, k, 3); without `center` the values
278
+ are None (four evaluations a point, not five)."""
279
+ # a tetrahedral stencil (as fields._gradient): second-order like central
280
+ # differences, five evaluations a point instead of seven
281
+ n = len(P)
282
+ tet = np.array([[1, -1, -1], [-1, -1, 1], [-1, 1, -1], [1, 1, 1]], float) * h
283
+ c = 1 if center else 0
284
+ vals = tree.values(np.concatenate(([P] if center else []) + [P + t for t in tet]), only=which)
285
+ rows = np.arange(n)[:, None]
286
+ v0 = vals[:, :n].T[rows, which] if center else None # (n, k)
287
+ g = np.zeros(which.shape + (3,))
288
+ for i in range(4):
289
+ vi = vals[:, (c + i) * n:(c + 1 + i) * n].T[rows, which]
290
+ g += vi[:, :, None] * tet[i]
291
+ return v0, g / (4 * h * h)
292
+
293
+
294
+ def meet(tree, X, which, pitch, iters=12, tol=1e-7):
295
+ """Gauss-Newton from X (n, 3) onto {leaf_i = 0 for i in which[n]}
296
+ (minimum-norm steps, so the nearest point of the curve or corner).
297
+ Returns (points, converged)."""
298
+ X = np.array(X, float)
299
+ which = np.asarray(which)
300
+ h = 1e-4 * pitch
301
+ ok = np.zeros(len(X), bool)
302
+ todo = np.arange(len(X))
303
+ for _ in range(iters):
304
+ if not len(todo):
305
+ break
306
+ v, g = _leaf_grad(tree, X[todo], which[todo], h)
307
+ done = np.abs(v).max(axis=1) < tol * pitch
308
+ ok[todo[done]] = True
309
+ todo, v, g = todo[~done], v[~done], g[~done]
310
+ if not len(todo):
311
+ break
312
+ JJ = np.einsum("nka,nja->nkj", g, g)
313
+ k = which.shape[1]
314
+ JJ += 1e-12 * np.eye(k)[None]
315
+ try:
316
+ lam = np.linalg.solve(JJ, v[:, :, None])[:, :, 0]
317
+ except np.linalg.LinAlgError:
318
+ lam = np.einsum("nkj,nj->nk", np.linalg.pinv(JJ), v)
319
+ step = np.einsum("nk,nka->na", lam, g)
320
+ ln = np.linalg.norm(step, axis=1)
321
+ step *= np.minimum(1.0, pitch / np.maximum(ln, 1e-300))[:, None]
322
+ X[todo] -= step
323
+ if len(todo):
324
+ v, _ = _leaf_grad(tree, X[todo], which[todo], h)
325
+ ok[todo] = np.abs(v).max(axis=1) < tol * pitch
326
+ return X, ok
327
+
328
+
329
+ def _on_part(tree, X, pitch, tol=1e-5):
330
+ f, _ = tree.evaluate(X)
331
+ return np.abs(f) < tol * pitch
332
+
333
+
334
+ # ---- the mesh operation ------------------------------------------------------------
335
+
336
+ def _edges(F):
337
+ """Unique edges (m, 2) sorted, and face -> edge index (n, 3): edge j of
338
+ a face runs from corner j to corner j + 1."""
339
+ e = np.stack([F[:, [0, 1]], F[:, [1, 2]], F[:, [2, 0]]], axis=1).reshape(-1, 2)
340
+ s = np.sort(e, axis=1)
341
+ key = s[:, 0].astype(np.int64) * (int(F.max()) + 1) + s[:, 1]
342
+ uk, inv = np.unique(key, return_inverse=True)
343
+ E = np.stack([uk // (int(F.max()) + 1), uk % (int(F.max()) + 1)], axis=1)
344
+ return E, inv.reshape(-1, 3)
345
+
346
+
347
+ def sharpen(V, F, tree, pitch, snap=0.25, reach=4.0, field=None, stats=None, lab=None, mask=None, clean=True,
348
+ tangent=12.0):
349
+ """Put the creases of `tree` back into the projected marching-cubes
350
+ mesh (V, F). Returns (V, F, crease) with `crease` a bool mask of the
351
+ vertices on a crease (snapped or inserted). `snap` (fraction of an
352
+ edge) moves a vertex onto the crease instead of splitting next to it;
353
+ `reach` (pitches) is how far a crease point may be from its edge.
354
+ `field` (default the tree's own min/max) projects fallback points.
355
+ Two leaves meeting at under `tangent` degrees are no crease."""
356
+ V = np.asarray(V, float)
357
+ F = np.asarray(F, np.int64)
358
+ if field is None:
359
+ def field(P):
360
+ return tree.evaluate(P)[0]
361
+ st = stats if stats is not None else {}
362
+ import time
363
+
364
+ secs = st.setdefault("seconds", {})
365
+ clock = [time.time()]
366
+
367
+ def tick(name):
368
+ t = time.time()
369
+ secs[name] = round(secs.get(name, 0.0) + t - clock[0], 2)
370
+ clock[0] = t
371
+ nl = len(tree)
372
+ if nl > 62:
373
+ return V, F, np.zeros(len(V), bool)
374
+ if lab is None:
375
+ _, lab = tree.evaluate(V)
376
+ if mask is None:
377
+ mask = (np.int64(1) << lab.astype(np.int64))
378
+ mask = np.array(mask, np.int64)
379
+ tick("labels")
380
+ # the edge table only round the faces with a crossing edge (ends with
381
+ # no leaf in common): elsewhere nothing is split. Five rings out, so
382
+ # a hole within three of a crossing edge (below) is seen whole: an edge
383
+ # counts as a hole only between vertices whose faces are all in the
384
+ # patch (else it is just the patch's border)
385
+ F = np.asarray(F, np.int64)
386
+ mixed = np.zeros(len(F), bool)
387
+ for j in range(3):
388
+ mixed |= (mask[F[:, j]] & mask[F[:, (j + 1) % 3]]) == 0
389
+ if not mixed.any():
390
+ st["crossingEdges"] = 0
391
+ return V, F, (mask & (mask - 1)) != 0
392
+ near5 = np.zeros(len(V), bool)
393
+ near5[F[mixed].ravel()] = True
394
+ for _r in range(4):
395
+ near5[F[near5[F].any(axis=1)].ravel()] = True
396
+ interior = near5.copy()
397
+ near5[F[near5[F].any(axis=1)].ravel()] = True
398
+ region = np.flatnonzero(near5[F].any(axis=1))
399
+ Fr = F[region]
400
+ E, fe = _edges(Fr)
401
+ tick("edges")
402
+ cross = (mask[E[:, 0]] & mask[E[:, 1]]) == 0
403
+ # edges that are no crease: an end the tree does not describe (tree.ignore),
404
+ # or two leaves that meet tangentially (a skin's outside lying on the body
405
+ # it was shelled from: no edge to see, and Gauss-Newton is ill-posed there)
406
+ skip = np.zeros(len(E), bool)
407
+ ign = np.zeros(len(V), bool)
408
+ if tree.ignore is not None and cross.any():
409
+ ends = np.unique(E[cross].ravel())
410
+ ign[ends] = np.asarray(tree.ignore(V[ends]), bool)
411
+ # and near a hole (a thin wall the mesher left open, which the boolean
412
+ # step repairs): leave the mesh round it as it was, so the repair holds
413
+ ecount = np.bincount(fe.ravel(), minlength=len(E))
414
+ hole = (ecount != 2) & interior[E[:, 0]] & interior[E[:, 1]]
415
+ if hole.any():
416
+ near = np.zeros(len(V), bool)
417
+ near[E[hole].ravel()] = True
418
+ for _r in range(3):
419
+ near[Fr[near[Fr].any(axis=1)].ravel()] = True
420
+ ign |= near
421
+ st["nearHoles"] = int(near.sum())
422
+ skip |= ign[E[:, 0]] | ign[E[:, 1]]
423
+ ci = np.flatnonzero(cross & ~skip)
424
+ if len(ci):
425
+ # a tie: each end lies on the other end's leaf as well (two surfaces
426
+ # that coincide along a band, where the gradients may still differ)
427
+ a, b = E[ci, 0], E[ci, 1]
428
+ ends, inv = np.unique(np.r_[a, b], return_inverse=True)
429
+ vals = tree.values(V[ends])
430
+ ia, ib = inv[:len(ci)], inv[len(ci):]
431
+ tie = (np.abs(vals[lab[b], ia]) < 0.02 * pitch) & (np.abs(vals[lab[a], ib]) < 0.02 * pitch)
432
+ skip[ci[tie]] = True
433
+ st["ties"] = int(tie.sum())
434
+ ci = ci[~tie]
435
+ if len(ci):
436
+ # the two leaves' normals at both ends and the midpoint: nearly
437
+ # parallel at any of them and the surfaces only graze (a smooth
438
+ # union's bulge clipped flat): no edge to see
439
+ a, b = E[ci, 0], E[ci, 1]
440
+ P = np.concatenate([V[a], V[b], 0.5 * (V[a] + V[b])])
441
+ w = np.tile(np.stack([lab[a], lab[b]], 1), (3, 1))
442
+ _, gr = _leaf_grad(tree, P, w, 1e-3 * pitch)
443
+ gr /= np.maximum(np.linalg.norm(gr, axis=2, keepdims=True), 1e-300)
444
+ c = (gr[:, 0] * gr[:, 1]).sum(1).reshape(3, len(ci)).max(axis=0)
445
+ skip[ci[c > np.cos(np.radians(tangent))]] = True
446
+ st["tangentOrIgnored"] = int((cross & skip).sum())
447
+ tick("skip")
448
+ cross &= ~skip
449
+ st["crossingEdges"] = int(cross.sum())
450
+ crease = (mask & (mask - 1)) != 0 # more than one label: on a crease already
451
+ if not cross.any():
452
+ return V, F, crease
453
+ V = V.copy()
454
+ home = V.copy() # where each vertex goes back to if its crease tangles
455
+
456
+ def crease_points(ei):
457
+ """Crease point of edges ei (from their midpoints), and whether found."""
458
+ a, b = E[ei, 0], E[ei, 1]
459
+ mid = 0.5 * (V[a] + V[b])
460
+ which = np.stack([lab[a], lab[b]], axis=1)
461
+ X, ok = meet(tree, mid, which, pitch)
462
+ # near the edge, and on the part (no third leaf owns it)
463
+ ab = V[b] - V[a]
464
+ t = np.clip(((X - V[a]) * ab).sum(1) / np.maximum((ab * ab).sum(1), 1e-300), 0, 1)
465
+ dseg = np.linalg.norm(X - (V[a] + t[:, None] * ab), axis=1)
466
+ ok &= dseg < reach * pitch
467
+ ok[ok] &= _on_part(tree, X[ok], pitch)
468
+ return X, ok, t
469
+
470
+ # 1. snap vertices that sit almost on a crease
471
+ ci = np.flatnonzero(cross)
472
+ X, ok, t = crease_points(ci)
473
+ step1 = (ci, X.copy(), ok.copy())
474
+ moved_v = np.zeros(len(V), bool)
475
+ snapped = 0
476
+ if snap > 0 and ok.any():
477
+ a, b = E[ci, 0], E[ci, 1]
478
+ cand_v = np.r_[a[ok & (t < snap)], b[ok & (t > 1 - snap)]]
479
+ cand_p = np.r_[lab[b[ok & (t < snap)]], lab[a[ok & (t > 1 - snap)]]]
480
+ cand_t = np.r_[t[ok & (t < snap)], 1 - t[ok & (t > 1 - snap)]]
481
+ if len(cand_v):
482
+ # one partner label per vertex (else it is near a corner: leave it)
483
+ order = np.lexsort((cand_t, cand_v))
484
+ cand_v, cand_p, cand_t = cand_v[order], cand_p[order], cand_t[order]
485
+ first = np.r_[True, cand_v[1:] != cand_v[:-1]]
486
+ starts = np.flatnonzero(first)
487
+ pmin = np.minimum.reduceat(cand_p, starts)
488
+ pmax = np.maximum.reduceat(cand_p, starts)
489
+ uv = cand_v[starts]
490
+ # also every crossing edge of the vertex must have that partner
491
+ single = pmin == pmax
492
+ uv, partner, ut = uv[single], pmin[single], cand_t[starts][single]
493
+ ea, eb = E[ci, 0], E[ci, 1]
494
+ # every crossing edge of the vertex must have that partner (else
495
+ # it is near a corner: leave it to the split)
496
+ lo_p = np.full(len(V), nl, np.int64)
497
+ hi_p = np.full(len(V), -1, np.int64)
498
+ for x, y in ((ea, eb), (eb, ea)):
499
+ np.minimum.at(lo_p, x, lab[y])
500
+ np.maximum.at(hi_p, x, lab[y])
501
+ keep = (lo_p[uv] == hi_p[uv]) & (lo_p[uv] == partner)
502
+ uv, partner, ut = uv[keep], partner[keep], ut[keep]
503
+ if len(uv):
504
+ Xs, oks = meet(tree, V[uv], np.stack([lab[uv], partner], axis=1), pitch)
505
+ oks &= np.linalg.norm(Xs - V[uv], axis=1) < 0.75 * pitch
506
+ if oks.any():
507
+ oks[oks] &= _on_part(tree, Xs[oks], pitch)
508
+ uv, partner, Xs, ut = uv[oks], partner[oks], Xs[oks], ut[oks]
509
+ # no crossing edge may have both ends snapped: keep the nearer
510
+ take = np.zeros(len(V), bool)
511
+ take[uv] = True
512
+ score = np.full(len(V), np.inf)
513
+ score[uv] = ut
514
+ both = take[ea] & take[eb]
515
+ drop = np.where(score[ea[both]] > score[eb[both]], ea[both], eb[both])
516
+ take[drop] = False
517
+ # and after dropping, never two snapped ends again (greedy, repeat)
518
+ for _ in range(4):
519
+ both = take[ea] & take[eb]
520
+ if not both.any():
521
+ break
522
+ take[np.where(score[ea[both]] > score[eb[both]], ea[both], eb[both])] = False
523
+ sel = take[uv]
524
+ uv, partner, Xs = uv[sel], partner[sel], Xs[sel]
525
+ V[uv] = Xs
526
+ moved_v[uv] = True
527
+ mask[uv] |= np.int64(1) << partner.astype(np.int64)
528
+ crease[uv] = True
529
+ snapped = len(uv)
530
+ st["snapped"] = int(snapped)
531
+
532
+ tick("snap")
533
+ # 2. split the edges that still cross
534
+ cross = ((mask[E[:, 0]] & mask[E[:, 1]]) == 0) & ~skip
535
+ ci = np.flatnonzero(cross)
536
+ # an edge neither of whose ends was snapped has the crease point step 1
537
+ # found already (all of them, as snapping only removes crossings)
538
+ c0, X0, ok0 = step1
539
+ pos = np.searchsorted(c0, ci)
540
+ again = moved_v[E[ci, 0]] | moved_v[E[ci, 1]]
541
+ X, ok = X0[pos].copy(), ok0[pos].copy()
542
+ if again.any():
543
+ Xa, oka, _ = crease_points(ci[again])
544
+ X[again], ok[again] = Xa, oka
545
+ # fallback: the midpoint on the field (where the mesh had it)
546
+ from .fields import _project
547
+
548
+ mfall = 0.5 * (V[E[ci, 0]] + V[E[ci, 1]])
549
+ for _ in range(3):
550
+ mfall = _project(field, mfall, pitch)
551
+ X[~ok] = mfall[~ok]
552
+ # home: the plain midpoint of the edge as the mesh had it, on its
553
+ # faces (the projected one can poke through a thin neighbour)
554
+ home = np.concatenate([home, 0.5 * (home[E[ci, 0]] + home[E[ci, 1]])])
555
+ st["split"] = int(len(ci))
556
+ st["splitFallback"] = int((~ok).sum())
557
+ nv = len(V)
558
+ epos = np.full(len(E), -1, np.int64)
559
+ epos[ci] = nv + np.arange(len(ci))
560
+ V = np.concatenate([V, X])
561
+ newc = np.zeros(len(V), bool)
562
+ newc[:nv] = crease
563
+ newc[nv:] = ok
564
+ crease = newc
565
+
566
+ tick("split")
567
+ # 3. re-cut every face by its split edges
568
+ M = np.full(F.shape, -1, np.int64) # (nf, 3) new vertex on edge j, or -1
569
+ M[region] = epos[fe]
570
+ c = (M >= 0).sum(axis=1)
571
+ out = [F[c == 0]]
572
+ # one split edge: (a, m, c), (m, b, c) with a->b the split edge
573
+ idx = np.flatnonzero(c == 1)
574
+ if len(idx):
575
+ j = np.argmax(M[idx] >= 0, axis=1)
576
+ r = np.arange(len(idx))
577
+ a = F[idx, j]
578
+ b = F[idx, (j + 1) % 3]
579
+ cc = F[idx, (j + 2) % 3]
580
+ m = M[idx, j]
581
+ out += [np.stack([a, m, cc], 1), np.stack([m, b, cc], 1)]
582
+ del r
583
+ # two split edges: the lone corner and a quad
584
+ idx = np.flatnonzero(c == 2)
585
+ if len(idx):
586
+ j = np.argmin(M[idx] >= 0, axis=1) # the unsplit edge b->c
587
+ b = F[idx, j]
588
+ cc = F[idx, (j + 1) % 3]
589
+ a = F[idx, (j + 2) % 3]
590
+ m_ca = M[idx, (j + 1) % 3]
591
+ m_ab = M[idx, (j + 2) % 3]
592
+ out.append(np.stack([a, m_ab, m_ca], 1))
593
+ d1 = np.linalg.norm(V[m_ab] - V[cc], axis=1)
594
+ d2 = np.linalg.norm(V[b] - V[m_ca], axis=1)
595
+ s = d1 <= d2
596
+ out += [np.stack([m_ab, b, cc], 1)[s], np.stack([m_ab, cc, m_ca], 1)[s],
597
+ np.stack([m_ab, b, m_ca], 1)[~s], np.stack([b, cc, m_ca], 1)[~s]]
598
+ # three: a centre at the corner where the three leaves meet
599
+ idx = np.flatnonzero(c == 3)
600
+ st["corners"] = int(len(idx))
601
+ if len(idx):
602
+ tri = F[idx]
603
+ which = lab[tri]
604
+ cen0 = V[M[idx]].mean(axis=1)
605
+ distinct = (which[:, 0] != which[:, 1]) & (which[:, 1] != which[:, 2]) & (which[:, 0] != which[:, 2])
606
+ cen = cen0.copy()
607
+ good = np.zeros(len(idx), bool)
608
+ if distinct.any():
609
+ Xc, okc = meet(tree, cen0[distinct], which[distinct], pitch)
610
+ okc &= np.linalg.norm(Xc - cen0[distinct], axis=1) < reach * pitch
611
+ if okc.any():
612
+ okc[okc] &= _on_part(tree, Xc[okc], pitch)
613
+ sub = np.flatnonzero(distinct)
614
+ cen[sub[okc]] = Xc[okc]
615
+ good[sub[okc]] = True
616
+ if (~good).any():
617
+ from .fields import _project
618
+
619
+ q = cen[~good]
620
+ for _ in range(3):
621
+ q = _project(field, q, pitch)
622
+ cen[~good] = q
623
+ # home: the centre of the three edge midpoints' homes, inside the
624
+ # face the mesh had
625
+ home = np.concatenate([home, home[M[idx]].mean(axis=1)])
626
+ k = len(V) + np.arange(len(idx))
627
+ V = np.concatenate([V, cen])
628
+ crease = np.r_[crease, good]
629
+ v0, v1, v2 = tri[:, 0], tri[:, 1], tri[:, 2]
630
+ m01, m12, m20 = M[idx, 0], M[idx, 1], M[idx, 2]
631
+ out += [np.stack(t, 1) for t in ((v0, m01, k), (m01, v1, k), (v1, m12, k),
632
+ (m12, v2, k), (v2, m20, k), (m20, v0, k))]
633
+ F2 = np.concatenate(out).astype(np.int64)
634
+ tick("recut")
635
+ if clean:
636
+ # masks only within three rings of the creases (clean-up looks at
637
+ # faces within two): the whole mesh's took seconds on a lattice
638
+ ring = crease.copy()
639
+ for _r in range(3):
640
+ ring[F2[ring[F2].any(axis=1)].ravel()] = True
641
+ masks = np.zeros(len(V), np.int64)
642
+ masks[ring] = label_masks(tree, V[ring], pitch)
643
+ tick("masks")
644
+ V, F2, _, crease, used = cleanup(V, F2, tree, masks, crease, pitch, stats=st, home=home)
645
+ home = home[used]
646
+ tick("cleanup")
647
+ V, crease = untangle(V, F2, tree, crease, home, pitch, stats=st)
648
+ tick("untangle")
649
+ st["creaseVertices"] = int(crease.sum())
650
+ return V, F2, crease
651
+
652
+
653
+ def label_masks(tree, V, pitch, tol=1e-5):
654
+ """Bit mask per vertex of the leaves it lies on: its active leaf, and
655
+ every other leaf within tol * pitch of zero there (a crease vertex is on
656
+ two, a corner on three)."""
657
+ vals = tree.values(V)
658
+ _, lab = tree.combine(vals)
659
+ near = np.abs(vals) < tol * pitch
660
+ bits = (np.int64(1) << np.arange(len(tree), dtype=np.int64))[:, None]
661
+ return (np.int64(1) << lab.astype(np.int64)) | np.bitwise_or.reduce(np.where(near, bits, 0), axis=0)
662
+
663
+
664
+ def _face_normals(V, F):
665
+ from .narrowband import face_cross
666
+
667
+ n = face_cross(V, F)
668
+ return n / np.maximum(np.sqrt(np.einsum("ij,ij->i", n, n))[:, None], 1e-300)
669
+
670
+
671
+ # ---- clean-up: collapse the slivers and turned-over triangles the split leaves ------
672
+
673
+ def _min_angle(T):
674
+ out = np.full(len(T), 180.0)
675
+ for i in range(3):
676
+ u = T[:, (i + 1) % 3] - T[:, i]
677
+ w = T[:, (i + 2) % 3] - T[:, i]
678
+ c = (u * w).sum(-1) / np.maximum(np.linalg.norm(u, axis=-1) * np.linalg.norm(w, axis=-1), 1e-300)
679
+ out = np.minimum(out, np.degrees(np.arccos(np.clip(c, -1, 1))))
680
+ return out
681
+
682
+
683
+ def side_normals(tree, V, F, mask):
684
+ """The true outward normal each face should have: the gradient of the
685
+ leaf its three vertices share (None-like zero where they share none)."""
686
+ common = mask[F[:, 0]] & mask[F[:, 1]] & mask[F[:, 2]]
687
+ has = common != 0
688
+ leaf = np.zeros(len(F), np.int64)
689
+ c = common[has]
690
+ # lowest set bit
691
+ leaf[has] = np.log2(c & -c).astype(np.int64)
692
+ cen = V[F].mean(1)
693
+ out = np.zeros((len(F), 3))
694
+ if has.any():
695
+ _, g = _leaf_grad(tree, cen[has], leaf[has][:, None], 1e-5, center=False)
696
+ g = g[:, 0]
697
+ out[has] = g / np.maximum(np.linalg.norm(g, axis=1, keepdims=True), 1e-300)
698
+ return out, has
699
+
700
+
701
+ def _edge_order(el, ea, eb):
702
+ """Per row, the order of a face's three edges by (length, a, b), as
703
+ sorting the tuples would."""
704
+ o = np.argsort(el, axis=1, kind="stable")
705
+ # ties in length are rare; settle them exactly where they occur
706
+ sl = np.take_along_axis(el, o, 1)
707
+ tie = np.flatnonzero((sl[:, 1:] == sl[:, :-1]).any(axis=1))
708
+ for r in tie:
709
+ o[r] = sorted(range(3), key=lambda k: (el[r, k], ea[r, k], eb[r, k]))
710
+ return o
711
+
712
+
713
+ _SIN_DEGENERATE = float(np.sin(np.radians(1e-3)))
714
+
715
+
716
+ def _turns_or_degenerates(V, old, new):
717
+ """Whether moving faces `old` to `new` (rows of vertex ids) turns one
718
+ over (normals under 0.2 in cosine, as unit vectors) or leaves one
719
+ with an angle under 1e-3 degrees: one fused pass for the clean-up's
720
+ few faces at a time."""
721
+ a0, b0, c0 = V[old[:, 0]], V[old[:, 1]], V[old[:, 2]]
722
+ a1, b1, c1 = V[new[:, 0]], V[new[:, 1]], V[new[:, 2]]
723
+ n0 = np.cross(b0 - a0, c0 - a0)
724
+ e1, e2 = b1 - a1, c1 - a1
725
+ n1 = np.cross(e1, e2)
726
+ s0, s1 = (n0 * n0).sum(1), (n1 * n1).sum(1)
727
+ if ((n0 * n1).sum(1) < 0.2 * np.sqrt(s0 * s1)).any() or (s0 == 0).any() or (s1 == 0).any():
728
+ return True
729
+ e3 = e2 - e1
730
+ l2 = np.sort(np.stack([(e1 * e1).sum(1), (e2 * e2).sum(1), (e3 * e3).sum(1)], 1), axis=1)
731
+ # the smallest angle faces the shortest edge: sin = |n| / (two longer edges)
732
+ return bool((np.sqrt(s1 / (l2[:, 1] * l2[:, 2])) < _SIN_DEGENERATE).any())
733
+
734
+
735
+ def cleanup(V, F, tree, mask, crease, pitch, min_angle=8.0, passes=6, stats=None, home=None):
736
+ """Collapse slivers and turned-over triangles near the creases (see
737
+ _cleanup), on the patch of faces within four rings of a crease vertex:
738
+ the passes look two rings out, and a vertex on the patch's border (it
739
+ has faces outside) is never collapsed, so the rest of the mesh is
740
+ untouched and never visited. Returns (V, F, mask, crease, used)."""
741
+ F = np.asarray(F, np.int64)
742
+ crease = np.asarray(crease, bool)
743
+ near = crease.copy()
744
+ for _r in range(4):
745
+ near[F[near[F].any(axis=1)].ravel()] = True
746
+ sub = near[F].any(axis=1)
747
+ inside = np.zeros(len(V), bool)
748
+ inside[F[sub].ravel()] = True
749
+ frozen = np.zeros(len(V), bool)
750
+ frozen[F[~sub].ravel()] = True
751
+ frozen &= inside
752
+ check = None if home is None else (np.asarray(home, float),)
753
+ Fs = _cleanup(V, F[sub], tree, mask, crease, pitch, min_angle, passes, stats, frozen, check)
754
+ F = np.concatenate([F[~sub], Fs])
755
+ # drop the vertices nothing uses
756
+ used = np.zeros(len(V), bool)
757
+ used[F] = True
758
+ new = np.cumsum(used) - 1
759
+ return np.asarray(V, float)[used], new[F], np.asarray(mask)[used], crease[used], used
760
+
761
+
762
+ def _fold_undo(F, alive, log, check):
763
+ """The collapses of `log` (see _cleanup) whose rewritten faces cross
764
+ another live face at any of the positions in `check`."""
765
+ owner = {}
766
+ for k, (_s, moved, _o, _a, _b) in enumerate(log):
767
+ for f in moved.tolist():
768
+ owner[f] = k
769
+ if not owner:
770
+ return []
771
+ live = np.flatnonzero(alive)
772
+ pos = np.full(len(F), -1, np.int64)
773
+ pos[live] = np.arange(len(live))
774
+ Fl = F[live]
775
+ mv = np.fromiter(owner.keys(), np.int64)
776
+ mv = mv[alive[mv]]
777
+ q = pos[mv]
778
+ bad = set()
779
+ for X in check:
780
+ x = crossing_faces(X, Fl, q)
781
+ hit = live[x]
782
+ bad |= {owner[f] for f in hit.tolist() if f in owner}
783
+ return sorted(bad)
784
+
785
+
786
+ def _cleanup(V, F, tree, mask, crease, pitch, min_angle=8.0, passes=6, stats=None, frozen=None, check=None):
787
+ """Collapse, near the creases, every triangle that is turned over against
788
+ its surface's true normal or thinner than `min_angle`: its shortest
789
+ collapsible edge goes, the crease end kept (two crease ends only on the
790
+ same curve), and only where the two ends share exactly the two opposite
791
+ vertices (so the mesh stays a closed manifold) and no remaining triangle
792
+ turns over; never an edge with a `frozen` end. With `check` (vertex
793
+ positions, one array or several), a pass's collapses that leave a face
794
+ crossing another at any of them are undone. Returns the faces."""
795
+ st = stats if stats is not None else {}
796
+ V = np.asarray(V, float)
797
+ F = np.asarray(F, np.int64).copy()
798
+ mask = np.asarray(mask, np.int64).copy()
799
+ crease = np.asarray(crease, bool).copy()
800
+ done = 0
801
+ # V and mask do not change here, so a face's true normal, own normal and
802
+ # smallest angle are functions of its three vertices: kept per face
803
+ # (rows follow F), recomputed only for faces a collapse rewrote
804
+ face_n = np.zeros((len(F), 3)) # the leaf's normal
805
+ face_h = np.zeros(len(F), bool) # (whether there is one)
806
+ face_a = np.zeros(len(F)) # smallest angle
807
+ face_m = np.zeros((len(F), 3)) # the face's own normal
808
+ known_f = np.zeros(len(F), bool)
809
+ for _ in range(passes):
810
+ # the neighbourhood of the creases: faces with a vertex within two rings
811
+ near = crease.copy()
812
+ for _r in range(2):
813
+ fn = near[F].any(axis=1)
814
+ near[F[fn].ravel()] = True
815
+ region = np.flatnonzero(near[F].any(axis=1))
816
+ if not len(region):
817
+ break
818
+ new_f = region[~known_f[region]]
819
+ if len(new_f):
820
+ face_n[new_f], face_h[new_f] = side_normals(tree, V, F[new_f], mask)
821
+ face_a[new_f] = _min_angle(V[F[new_f]])
822
+ face_m[new_f] = _face_normals(V, F[new_f])
823
+ known_f[new_f] = True
824
+ sn, has = face_n[region], face_h[region]
825
+ ma, nrm = face_a[region], face_m[region]
826
+ flipped = has & ((nrm * sn).sum(1) < 0)
827
+ bad = region[(ma < min_angle) | flipped]
828
+ if not len(bad):
829
+ break
830
+ # vertex -> faces over the whole mesh, for the vertices involved
831
+ inv = {}
832
+ involved = set(F[bad].ravel().tolist())
833
+ # one ring of faces around the involved vertices
834
+ ring = np.flatnonzero(np.isin(F, list(involved)).any(axis=1))
835
+ for fi in ring:
836
+ for v in F[fi]:
837
+ if v in involved:
838
+ inv.setdefault(v, set()).add(int(fi))
839
+ alive = np.ones(len(F), bool)
840
+ touched = set()
841
+ log = [] # per collapse: (faces removed, faces rewritten, their old rows, keep, gone)
842
+ changed = 0
843
+ order = bad[np.argsort(ma[np.searchsorted(region, bad)])]
844
+ # each face's edges, shortest first (a face is only collapsed while
845
+ # untouched, so its vertices are still these)
846
+ Fo = F[order]
847
+ ea, eb = Fo, np.roll(Fo, -1, axis=1)
848
+ el = np.einsum("nka,nka->nk", V[eb] - V[ea], V[eb] - V[ea])
849
+ eo = _edge_order(el, ea, eb)
850
+ ea = np.take_along_axis(ea, eo, 1).tolist()
851
+ eb = np.take_along_axis(eb, eo, 1).tolist()
852
+ for oi, fi in enumerate(order.tolist()):
853
+ if not alive[fi] or fi in touched:
854
+ continue
855
+ for a, b in zip(ea[oi], eb[oi]):
856
+ # keep: the crease end; both on a crease only with the same labels
857
+ if crease[a] and crease[b] and mask[a] != mask[b]:
858
+ continue
859
+ if frozen is not None and (frozen[a] or frozen[b]):
860
+ continue
861
+ keep, gone = (a, b) if (crease[a] or not crease[b]) else (b, a)
862
+ if keep not in inv or gone not in inv:
863
+ continue
864
+ fk = {x for x in inv[keep] if alive[x]}
865
+ fg = {x for x in inv[gone] if alive[x]}
866
+ if fk & touched or fg & touched:
867
+ continue
868
+ shared = fk & fg
869
+ if len(shared) != 2:
870
+ continue
871
+ nk = set(F[list(fk)].ravel().tolist()) - {keep}
872
+ ng = set(F[list(fg)].ravel().tolist()) - {gone}
873
+ opp = set(F[list(shared)].ravel().tolist()) - {keep, gone}
874
+ if (nk & ng) != opp:
875
+ continue
876
+ # the triangles that stay: gone -> keep, none may turn over
877
+ moved = np.array(sorted(fg - shared), np.int64)
878
+ if len(moved):
879
+ old = F[moved]
880
+ new = np.where(old == gone, keep, old)
881
+ if _turns_or_degenerates(V, old, new):
882
+ continue
883
+ F[moved] = new
884
+ known_f[moved] = False
885
+ log.append((list(shared), moved, old if len(moved) else None, keep, gone))
886
+ for x in shared:
887
+ alive[x] = False
888
+ for x in moved:
889
+ inv[keep].add(int(x))
890
+ touched |= fk | fg
891
+ changed += 1
892
+ break
893
+ if log and check is not None:
894
+ # a collapse may fold the mesh through a face a ring or two off
895
+ # (a thin wall's other side), with the vertices where they are
896
+ # or where untangle may send them back: undo those, and leave
897
+ # their ends alone from now on
898
+ undone = _fold_undo(F, alive, log, check)
899
+ for k in undone:
900
+ shared_k, moved_k, old_k, keep_k, gone_k = log[k]
901
+ if old_k is not None:
902
+ F[moved_k] = old_k
903
+ known_f[moved_k] = False
904
+ alive[shared_k] = True
905
+ if frozen is None:
906
+ frozen = np.zeros(len(V), bool)
907
+ frozen[[keep_k, gone_k]] = True
908
+ changed -= len(undone)
909
+ F = F[alive]
910
+ face_n, face_h, known_f = face_n[alive], face_h[alive], known_f[alive]
911
+ face_a, face_m = face_a[alive], face_m[alive]
912
+ done += changed
913
+ if not changed:
914
+ break
915
+ st["collapsed"] = int(done)
916
+ return F
917
+
918
+
919
+ def _seg_tri(p, q, T, eps=1e-9):
920
+ """Does segment pq cross triangle T (strictly inside both)?"""
921
+ d = q - p
922
+ e1 = T[:, 1] - T[:, 0]
923
+ e2 = T[:, 2] - T[:, 0]
924
+ h = np.cross(d, e2)
925
+ a = (e1 * h).sum(1)
926
+ ok = np.abs(a) > 1e-14
927
+ f = np.where(ok, 1.0 / np.where(ok, a, 1.0), 0.0)
928
+ s = p - T[:, 0]
929
+ u = f * (s * h).sum(1)
930
+ qq = np.cross(s, e1)
931
+ v = f * (d * qq).sum(1)
932
+ t = f * (e2 * qq).sum(1)
933
+ return ok & (u > eps) & (v > eps) & (u + v < 1 - eps) & (t > eps) & (t < 1 - eps)
934
+
935
+
936
+ def _cent_rad(V, F, chunk=262144):
937
+ """Face centroids and circumradii (about the centroid), in chunks: the
938
+ whole (n, 3, 3) gather at once is memory-bound and ~4x slower."""
939
+ n = len(F)
940
+ C = np.empty((n, 3))
941
+ R = np.empty(n)
942
+ for i in range(0, n, chunk):
943
+ f = F[i:i + chunk]
944
+ a, b, c = V[f[:, 0]], V[f[:, 1]], V[f[:, 2]]
945
+ m = (a + b + c) / 3
946
+ C[i:i + chunk] = m
947
+ R[i:i + chunk] = np.sqrt(np.maximum(np.maximum(((a - m) ** 2).sum(1), ((b - m) ** 2).sum(1)),
948
+ ((c - m) ** 2).sum(1)))
949
+ return C, R
950
+
951
+
952
+ class _Centroids:
953
+ """Face centroids, circumradii and a k-d tree over them, kept across
954
+ untangle's rounds: a round moves a few vertices back, so only their
955
+ faces are refreshed. The tree (built once) still holds those faces
956
+ where they were: they are marked dirty, left out of its answers, and
957
+ searched in a small tree of their own instead."""
958
+
959
+ def __init__(self, V, F, p99=None):
960
+ self._V, self._F, self._p99 = V, F, p99
961
+ self._built = False
962
+
963
+ def _build(self):
964
+ from scipy.spatial import cKDTree
965
+
966
+ self.C, self.rad = _cent_rad(self._V, self._F)
967
+ self.p99 = float(np.percentile(self.rad, 99)) if self._p99 is None else float(self._p99)
968
+ # built once and queried a few times: the unbalanced build is ~2x faster
969
+ self.tree = cKDTree(self.C, balanced_tree=False, compact_nodes=False)
970
+ self.dirty = np.zeros(len(self._F), bool)
971
+ self._V = self._F = None
972
+ self._built = True
973
+
974
+ def __getattr__(self, name):
975
+ # built on first use: the compiled search (selfx.py) needs none of it
976
+ if name in ("C", "rad", "p99", "tree", "dirty") and not self.__dict__.get("_built"):
977
+ self._build()
978
+ return getattr(self, name)
979
+ raise AttributeError(name)
980
+
981
+ def update(self, V, F, faces):
982
+ if not self._built:
983
+ self._V = V # not built yet: built from V as it is when it is
984
+ return
985
+ self.C[faces], self.rad[faces] = _cent_rad(V, F[faces])
986
+ self.dirty[faces] = True
987
+
988
+
989
+ def _within(ctree, Q, r, ids=None):
990
+ """Pairs (query row, point) with the point within r[row] of Q[row]: the
991
+ nearest 24, then 96, then 384 where that many were all within reach
992
+ (as arrays: ball queries build python lists), and a ball query for
993
+ what is left. `ids` maps the tree's points to face numbers."""
994
+ rows = np.arange(len(Q))
995
+ A, B = [], []
996
+ n = ctree.n
997
+ for k in (24, 96, 384):
998
+ if not len(rows):
999
+ break
1000
+ k = min(k, n)
1001
+ d, j = ctree.query(Q[rows], k=k, distance_upper_bound=float(r[rows].max()))
1002
+ d, j = d.reshape(len(rows), k), j.reshape(len(rows), k)
1003
+ ok = d <= r[rows][:, None]
1004
+ full = ok[:, -1].copy() if k < n else np.zeros(len(rows), bool)
1005
+ ok[full] = False
1006
+ A.append(np.broadcast_to(rows[:, None], j.shape)[ok])
1007
+ B.append(j[ok].astype(np.int64))
1008
+ rows = rows[full]
1009
+ if len(rows):
1010
+ lst = ctree.query_ball_point(Q[rows], r[rows])
1011
+ A.append(np.repeat(rows, [len(x) for x in lst]))
1012
+ B.append(np.concatenate([np.asarray(x, np.int64) for x in lst]))
1013
+ A, B = np.concatenate(A), np.concatenate(B)
1014
+ return A, (B if ids is None else ids[B])
1015
+
1016
+
1017
+ def crossing_faces(V, F, faces, cache=None):
1018
+ """Faces among `faces` that cross another face of the mesh (sharing no
1019
+ vertex with it): a self-intersection. `cache` (a _Centroids of this
1020
+ V, F) saves rebuilding the centroids and their tree."""
1021
+ from scipy.spatial import cKDTree
1022
+
1023
+ faces = np.asarray(faces, np.int64)
1024
+ if not len(faces):
1025
+ return faces
1026
+ from . import selfx
1027
+
1028
+ if selfx.available():
1029
+ return selfx.crossing_faces(V, F, faces)
1030
+ if cache is None:
1031
+ cache = _Centroids(V, F)
1032
+ C, rad = cache.C, cache.rad
1033
+ # a face's reach: its own circumradius plus a typical neighbour's
1034
+ # (the 99th percentile: the long faces are the crease's own, and those
1035
+ # are tested from their side)
1036
+ r = rad[faces] + cache.p99 + 1e-12
1037
+ i, B = _within(cache.tree, C[faces], r)
1038
+ A = faces[i]
1039
+ dirty = np.flatnonzero(cache.dirty)
1040
+ if len(dirty):
1041
+ # the tree has these where they were: drop them, search them as they are
1042
+ keep = ~cache.dirty[B]
1043
+ A, B = A[keep], B[keep]
1044
+ i2, B2 = _within(cKDTree(C[dirty]), C[faces], r, ids=dirty)
1045
+ A, B = np.r_[A, faces[i2]], np.r_[B, B2]
1046
+ # only faces whose bounding spheres overlap can cross
1047
+ keep = (A != B) & (np.linalg.norm(C[A] - C[B], axis=1) < rad[A] + rad[B] + 1e-12)
1048
+ A, B = A[keep], B[keep]
1049
+ # each pair once (both orders turn up when both faces are in `faces`)
1050
+ lo_, hi_ = np.minimum(A, B), np.maximum(A, B)
1051
+ _, one = np.unique(lo_ * np.int64(len(F)) + hi_, return_index=True)
1052
+ A, B = lo_[one], hi_[one]
1053
+ share = (F[A][:, :, None] == F[B][:, None, :]).any(axis=(1, 2))
1054
+ A, B = A[~share], B[~share]
1055
+ # no crossing if one triangle lies wholly on one side of the other's plane
1056
+ for flip in (False, True):
1057
+ X, Y = (B, A) if flip else (A, B)
1058
+ TX = V[F[X]]
1059
+ n = np.cross(TX[:, 1] - TX[:, 0], TX[:, 2] - TX[:, 0])
1060
+ sd = np.einsum("nka,na->nk", V[F[Y]] - TX[:, :1], n)
1061
+ tol = 1e-12 * (n * n).sum(1)[:, None]
1062
+ apart = (sd > tol).all(1) | (sd < -tol).all(1)
1063
+ A, B = A[~apart], B[~apart]
1064
+ hit = np.zeros(len(A), bool)
1065
+ for X, Y in ((A, B), (B, A)):
1066
+ T = V[F[Y]]
1067
+ for k in range(3):
1068
+ hit |= _seg_tri(V[F[X, k]], V[F[X, (k + 1) % 3]], T)
1069
+ return np.unique(np.r_[A[hit], B[hit]])
1070
+
1071
+
1072
+ _MOVED = 0.05 # pitches a crease vertex must move before untangle checks it
1073
+
1074
+
1075
+ def _away(V, home, crease, pitch):
1076
+ """Vertices away from home: a crease vertex moved more than _MOVED
1077
+ pitch, any other (a split's fallback, on the field) at all."""
1078
+ d = np.linalg.norm(V - home, axis=1)
1079
+ return np.where(crease, d > _MOVED * pitch, d > 0)
1080
+
1081
+
1082
+ def untangle(V, F, tree, crease, home, pitch, rounds=4, stats=None):
1083
+ """Send crease vertices (and the split points that fell back to the
1084
+ field) back to where the mesh had them (`home`: on its faces, as it
1085
+ was) wherever the sharpened crease crosses another part of the mesh or
1086
+ turns a triangle over: a crease is kept only where it does no harm."""
1087
+ V = np.array(V, float)
1088
+ crease = np.array(crease, bool)
1089
+ back = 0
1090
+ near = None
1091
+ F = np.asarray(F, np.int64)
1092
+ cache = _Centroids(V, F)
1093
+ # every round looks at faces with a moved crease vertex (a set that only
1094
+ # shrinks) and the rings round what it sent back: the patch of faces
1095
+ # within three rings of the first round's, not the whole mesh
1096
+ moved = _away(V, home, crease, pitch)
1097
+ pv = np.zeros(len(V), bool)
1098
+ pv[F[moved[F].any(axis=1)].ravel()] = True
1099
+ for _r in range(3):
1100
+ pv[F[pv[F].any(axis=1)].ravel()] = True
1101
+ loc = np.flatnonzero(pv[F].any(axis=1))
1102
+ FL = F[loc]
1103
+ for _ in range(rounds):
1104
+ # only where a crease vertex moved: elsewhere the mesh is as it was;
1105
+ # after the first round, only around what the last round sent back
1106
+ if _:
1107
+ moved = _away(V, home, crease, pitch)
1108
+ sel = moved[FL].any(axis=1)
1109
+ if near is not None:
1110
+ sel &= near[FL].any(axis=1)
1111
+ cf = loc[sel]
1112
+ if not len(cf):
1113
+ break
1114
+ bad = crossing_faces(V, F, cf, cache)
1115
+ mask = np.zeros(len(V), np.int64)
1116
+ fv = np.unique(F[cf].ravel())
1117
+ mask[fv] = label_masks(tree, V[fv], pitch)
1118
+ sn, has = side_normals(tree, V, F[cf], mask)
1119
+ flip = cf[has & ((_face_normals(V, F[cf]) * sn).sum(1) < 0)]
1120
+ bad = np.union1d(bad, flip)
1121
+ verts = np.unique(F[bad].ravel())
1122
+ verts = verts[np.any(V[verts] != home[verts], axis=1)]
1123
+ if not len(verts):
1124
+ break
1125
+ V[verts] = home[verts]
1126
+ crease[verts] = False
1127
+ back += len(verts)
1128
+ vm = np.zeros(len(V), bool)
1129
+ vm[verts] = True
1130
+ cache.update(V, F, loc[vm[FL].any(axis=1)])
1131
+ # the faces that could cross now: within a few rings of those moved back
1132
+ near = np.zeros(len(V), bool)
1133
+ near[verts] = True
1134
+ for _r in range(3):
1135
+ near[FL[near[FL].any(axis=1)].ravel()] = True
1136
+ if stats is not None:
1137
+ stats["untangled"] = int(back)
1138
+ return V, crease