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/fields.py ADDED
@@ -0,0 +1,3697 @@
1
+ """
2
+ Field-driven (implicit) design: a part is a graph of implicit fields that an
3
+ agent writes as JSON (or builds in Python), and every operation can be driven
4
+ by another field.
5
+
6
+ {"nodes": {
7
+ "body": {"op": "mesh", "path": "arm-link.stl"},
8
+ "stress": {"op": "result", "path": "arm-link.fields.npz", "quantity": "vonMises"},
9
+ "wall": {"op": "remap", "input": "stress", "from": [2, 30], "to": [0.8, 2.0]},
10
+ "core": {"op": "lattice", "type": "gyroid", "cell": 8, "thickness": "wall"},
11
+ "skin": {"op": "shell", "input": "body", "thickness": 1.2},
12
+ "part": {"op": "union", "inputs": ["skin", {"op": "intersect", "inputs": ["core", "body"]}],
13
+ "blend": 1.5}},
14
+ "output": "part",
15
+ "mesh": {"pitch": 0.3}}
16
+
17
+ python -m loadpath field part.json --out out/
18
+
19
+ Two kinds of field, one representation. A SOLID is a signed distance in mm,
20
+ negative inside (shapes, meshes, lattices, booleans, shells). A SCALAR is
21
+ any number per point (a coordinate, a distance, a stress, a temperature, a
22
+ remapped ramp). Every parameter that takes a number (a wall thickness, a
23
+ fillet radius, a lattice cell, an offset) also takes a scalar field, which
24
+ is the whole idea: the wall follows the stress, the cell grows away from
25
+ the bolts, the fillet is larger where the load comes in.
26
+
27
+ A parameter is a number, the name of another node, or an inline node
28
+ object. Nodes are evaluated lazily at whatever points are asked for, each
29
+ once per batch of points, so a graph can share a subtree (the body used by
30
+ the skin, the clip and the distance ramp) without paying for it three
31
+ times.
32
+
33
+ Meshes come out smooth: the field is marching-cubed on a grid and each
34
+ vertex is then moved onto the field's own zero level along its gradient (a
35
+ Newton step), so a curved wall is curved, not faceted at the grid, and no
36
+ step of a voxel mask survives into the part. A mesh imported as a field
37
+ keeps its own surface to within the projection's tolerance: near the
38
+ surface its distance is exact (closest point on the closest triangle), and
39
+ only far from it does the voxel distance transform stand in.
40
+
41
+ The pieces this builds on: shapes.py's exact primitives, sdf_kit's smooth
42
+ booleans, lattice.py's TPMS level sets and homogenized density tables, and
43
+ voxels.py's scanline voxelizer. See `schema.FIELD_SCHEMA` for every node.
44
+ """
45
+ from __future__ import annotations
46
+
47
+ import ast
48
+ import json
49
+ import math
50
+ import os
51
+ import threading
52
+ from dataclasses import dataclass
53
+
54
+ import numpy as np
55
+ from scipy import ndimage
56
+
57
+ from .narrowband import face_cross
58
+
59
+ __all__ = ["FieldGraph", "MeshField", "field_mesh", "save_result_field", "result_samples", "OPS"]
60
+
61
+ # the operations and what they take (the schema says the same, with docs)
62
+ OPS = ("constant", "coordinate", "distance", "shape", "mesh", "result", "grid", "image", "remap", "expr",
63
+ "union", "intersect", "subtract", "shell", "offset", "lattice", "transform")
64
+
65
+ _CELL_BUDGET = 60_000_000
66
+ _CHUNK = 1_000_000
67
+ _JIT_CHUNK = 32_768 # points per thread task for a compiled field (narrowband.peval)
68
+
69
+
70
+ # ---- expressions -----------------------------------------------------------
71
+
72
+ def _smoothstep(e0, e1, x):
73
+ u = np.clip((x - e0) / np.where(e1 == e0, 1e-12, e1 - e0), 0.0, 1.0)
74
+ return u * u * (3.0 - 2.0 * u)
75
+
76
+
77
+ _FUNCS = {
78
+ "abs": np.abs, "sqrt": lambda a: np.sqrt(np.maximum(a, 0.0)), "exp": np.exp,
79
+ "log": lambda a: np.log(np.maximum(a, 1e-300)), "sin": np.sin, "cos": np.cos, "tan": np.tan,
80
+ "floor": np.floor, "ceil": np.ceil, "sign": np.sign,
81
+ "min": lambda *a: _reduce(np.minimum, a), "max": lambda *a: _reduce(np.maximum, a),
82
+ "clamp": lambda x, lo, hi: np.clip(x, lo, hi),
83
+ "lerp": lambda a, b, t: a + (b - a) * t,
84
+ "smoothstep": _smoothstep,
85
+ "step": lambda edge, x: (x >= edge).astype(float),
86
+ "where": lambda c, a, b: np.where(c > 0, a, b),
87
+ }
88
+ _CONSTS = {"pi": math.pi, "e": math.e}
89
+
90
+
91
+ def _reduce(fn, args):
92
+ out = args[0]
93
+ for a in args[1:]:
94
+ out = fn(out, a)
95
+ return out
96
+
97
+
98
+ _BINOPS = {ast.Add: np.add, ast.Sub: np.subtract, ast.Mult: np.multiply, ast.Div: np.divide,
99
+ ast.Pow: np.power, ast.Mod: np.mod}
100
+ _CMPOPS = {ast.Lt: np.less, ast.LtE: np.less_equal, ast.Gt: np.greater, ast.GtE: np.greater_equal}
101
+
102
+
103
+ def _compile_expr(text: str, lib=None):
104
+ """(names it reads, fn(env) -> value) for an arithmetic expression over
105
+ node names, x, y, z, pi, e and the functions in _FUNCS. A whitelist over
106
+ Python's AST: no attributes, no subscripts, no calls but those. `lib`
107
+ swaps the array functions (funcs, binops, cmpops, where, float) for
108
+ another array library's (fieldtorch.py)."""
109
+ lib = lib or {}
110
+ funcs, binops, cmpops = lib.get("funcs", _FUNCS), lib.get("binops", _BINOPS), lib.get("cmpops", _CMPOPS)
111
+ where = lib.get("where", lambda c, a, b: np.where(np.asarray(c) > 0, a, b))
112
+ as_float = lib.get("float", lambda a: a.astype(float))
113
+ try:
114
+ tree = ast.parse(text, mode="eval")
115
+ except SyntaxError as exc:
116
+ raise ValueError(f"expr {text!r} is not a valid expression: {exc.msg}") from None
117
+ names = set()
118
+
119
+ def build(n):
120
+ if isinstance(n, ast.Expression):
121
+ return build(n.body)
122
+ if isinstance(n, ast.Constant) and isinstance(n.value, (int, float)) and not isinstance(n.value, bool):
123
+ v = float(n.value)
124
+ return lambda env: v
125
+ if isinstance(n, ast.Name):
126
+ if n.id in _CONSTS:
127
+ v = _CONSTS[n.id]
128
+ return lambda env: v
129
+ names.add(n.id)
130
+ k = n.id
131
+ return lambda env: env[k]
132
+ if isinstance(n, ast.BinOp) and type(n.op) in binops:
133
+ f, a, b = binops[type(n.op)], build(n.left), build(n.right)
134
+ return lambda env: f(a(env), b(env))
135
+ if isinstance(n, ast.UnaryOp) and isinstance(n.op, (ast.USub, ast.UAdd)):
136
+ a = build(n.operand)
137
+ return (lambda env: -a(env)) if isinstance(n.op, ast.USub) else a
138
+ if isinstance(n, ast.Compare) and len(n.ops) == 1 and type(n.ops[0]) in cmpops:
139
+ f, a, b = cmpops[type(n.ops[0])], build(n.left), build(n.comparators[0])
140
+ return lambda env: as_float(f(a(env), b(env)))
141
+ if isinstance(n, ast.IfExp):
142
+ c, a, b = build(n.test), build(n.body), build(n.orelse)
143
+ return lambda env: where(c(env), a(env), b(env))
144
+ if isinstance(n, ast.Call) and isinstance(n.func, ast.Name) and n.func.id in funcs and not n.keywords:
145
+ f, args = funcs[n.func.id], [build(a) for a in n.args]
146
+ return lambda env: f(*(a(env) for a in args))
147
+ bad = ast.unparse(n) if hasattr(ast, "unparse") else type(n).__name__
148
+ raise ValueError(f"expr {text!r}: {bad!r} is not allowed (arithmetic, comparisons, "
149
+ f"node names, x/y/z and {', '.join(sorted(_FUNCS))})")
150
+
151
+ fn = build(tree)
152
+ return names, fn
153
+
154
+
155
+ # ---- exact distance to a triangle mesh ---------------------------------------
156
+
157
+ def _closest_on_triangles(p, a, b, c):
158
+ """Closest points on triangles (a, b, c) to points p, all (N, 3)
159
+ (Ericson, Real-Time Collision Detection 5.1.5, vectorized)."""
160
+ ab, ac, ap = b - a, c - a, p - a
161
+ d1 = np.einsum("ij,ij->i", ab, ap)
162
+ d2 = np.einsum("ij,ij->i", ac, ap)
163
+ bp = p - b
164
+ d3 = np.einsum("ij,ij->i", ab, bp)
165
+ d4 = np.einsum("ij,ij->i", ac, bp)
166
+ cp = p - c
167
+ d5 = np.einsum("ij,ij->i", ab, cp)
168
+ d6 = np.einsum("ij,ij->i", ac, cp)
169
+ va = d3 * d6 - d5 * d4
170
+ vb = d5 * d2 - d1 * d6
171
+ vc = d1 * d4 - d3 * d2
172
+ with np.errstate(divide="ignore", invalid="ignore"):
173
+ denom = 1.0 / (va + vb + vc)
174
+ v = vb * denom
175
+ w = vc * denom
176
+ out = a + ab * v[:, None] + ac * w[:, None]
177
+ # edges
178
+ t_ab = d1 / (d1 - d3)
179
+ e_ab = (vc <= 0) & (d1 >= 0) & (d3 <= 0)
180
+ out = np.where(e_ab[:, None], a + ab * t_ab[:, None], out)
181
+ t_ac = d2 / (d2 - d6)
182
+ e_ac = (vb <= 0) & (d2 >= 0) & (d6 <= 0)
183
+ out = np.where(e_ac[:, None], a + ac * t_ac[:, None], out)
184
+ t_bc = (d4 - d3) / ((d4 - d3) + (d5 - d6))
185
+ e_bc = (va <= 0) & ((d4 - d3) >= 0) & ((d5 - d6) >= 0)
186
+ out = np.where(e_bc[:, None], b + (c - b) * t_bc[:, None], out)
187
+ # vertices
188
+ out = np.where(((d1 <= 0) & (d2 <= 0))[:, None], a, out)
189
+ out = np.where(((d3 >= 0) & (d4 <= d3))[:, None], b, out)
190
+ out = np.where(((d6 >= 0) & (d5 <= d6))[:, None], c, out)
191
+ return out
192
+
193
+
194
+ class MeshField:
195
+ """Signed distance to a closed triangle mesh, exact near the surface.
196
+
197
+ Far from the surface (beyond `band` mm) it is a voxel distance transform
198
+ at `pitch`, interpolated: good to half a voxel, which is all a grading
199
+ ramp needs. Within the band it is the distance to the closest point on
200
+ the closest triangle (candidates from a k-d tree over the centroids of
201
+ the mesh subdivided to `pitch`), signed by the angle-weighted normal of
202
+ the triangles that share that point. That is what keeps an imported part
203
+ smooth when it is re-meshed: the zero level is the mesh's own surface,
204
+ not a voxel staircase."""
205
+
206
+ def __init__(self, mesh, pitch=None, band=None, k=12):
207
+ import trimesh
208
+ from scipy.spatial import cKDTree
209
+
210
+ from .voxels import mesh_solid
211
+
212
+ if not isinstance(mesh, trimesh.Trimesh):
213
+ raise ValueError("MeshField needs a trimesh.Trimesh")
214
+ if not mesh.is_watertight:
215
+ raise ValueError("a mesh field needs a closed (watertight) mesh to have an inside")
216
+ ext = float(np.max(mesh.extents))
217
+ self.pitch = float(pitch or max(ext / 200.0, 0.25))
218
+ self.band = float(band if band is not None else 3.0 * self.pitch)
219
+ self.k = int(k)
220
+ self.mesh = mesh
221
+ self.lo, self.hi = (np.asarray(b, float) for b in mesh.bounds)
222
+ solid, origin, _ = mesh_solid(mesh, self.pitch, pad=4)
223
+ dist = (ndimage.distance_transform_edt(~solid) - ndimage.distance_transform_edt(solid)) * self.pitch
224
+ dist -= 0.5 * self.pitch * np.sign(dist)
225
+ self._grid = dist.astype(np.float32)
226
+ self._origin = origin
227
+ v, f = np.asarray(mesh.vertices, float), np.asarray(mesh.faces, np.int64)
228
+ edge = max(self.pitch, ext / 400.0)
229
+ if np.linalg.norm(v[f[:, 0]] - v[f[:, 1]], axis=1).max() > edge:
230
+ v, f = trimesh.remesh.subdivide_to_size(v, f, max_edge=edge, max_iter=12)
231
+ self._tri = v[f]
232
+ n = np.cross(self._tri[:, 1] - self._tri[:, 0], self._tri[:, 2] - self._tri[:, 0])
233
+ self._normal = n / np.maximum(np.linalg.norm(n, axis=1, keepdims=True), 1e-300)
234
+ self._tree = cKDTree(self._tri.mean(axis=1))
235
+
236
+ def _coarse(self, P):
237
+ idx = ((P - self._origin) / self.pitch).T
238
+ n = np.asarray(self._grid.shape)[:, None] - 1
239
+ cl = np.clip(idx, 0, n)
240
+ out = ndimage.map_coordinates(self._grid, cl, order=1, mode="nearest").astype(float)
241
+ # past the grid: the distance at its nearest point plus the way there (continuous at the grid's
242
+ # face, never short of the true distance by more than the grid's error), so an offset of the mesh
243
+ # by more than the grid's padding stays the mesh's shape, not its box
244
+ return out + np.linalg.norm(idx - cl, axis=0) * self.pitch
245
+
246
+ def _exact(self, P):
247
+ k = min(self.k, len(self._tri))
248
+ from . import knn, meshops
249
+
250
+ if knn.available():
251
+ # the same neighbours, compiled and warm-started (knn.py)
252
+ if getattr(self, "_knn", None) is None:
253
+ self._knn = knn.KDTree(self._tree.data)
254
+ cand = self._knn.query(P, k)
255
+ else:
256
+ _, cand = self._tree.query(P, k=k)
257
+ cand = cand.reshape(len(P), k)
258
+
259
+ if meshops.available():
260
+ # compiled, serial (field evaluation runs on peval's threads)
261
+ best, side = meshops.nearest_on_triangles(P, cand, self._tri, self._normal, (1e-9 * self.pitch) ** 2)
262
+ return side * np.sqrt(best)
263
+ T = self._tri[cand] # (N, k, 3, 3)
264
+ Pk = np.repeat(P[:, None, :], k, axis=1).reshape(-1, 3)
265
+ C = _closest_on_triangles(Pk, T[:, :, 0].reshape(-1, 3), T[:, :, 1].reshape(-1, 3),
266
+ T[:, :, 2].reshape(-1, 3)).reshape(len(P), k, 3)
267
+ d2 = np.sum((C - P[:, None, :]) ** 2, axis=2)
268
+ best = d2.min(axis=1)
269
+ # sign: normals of every candidate whose closest point is (nearly) the
270
+ # closest point, weighted by how squarely they face it
271
+ tie = d2 <= best[:, None] * (1 + 1e-6) + (1e-9 * self.pitch) ** 2
272
+ ib = np.argmin(d2, axis=1)
273
+ cb = C[np.arange(len(P)), ib]
274
+ nrm = (self._normal[cand] * tie[:, :, None]).sum(axis=1)
275
+ s = np.einsum("ij,ij->i", P - cb, nrm)
276
+ dist = np.sqrt(best)
277
+ return np.where(s < 0, -dist, dist)
278
+
279
+ def __call__(self, P):
280
+ P = np.asarray(P, float).reshape(-1, 3)
281
+ d = self._coarse(P)
282
+ near = np.abs(d) < self.band
283
+ if near.any():
284
+ d[near] = self._exact(P[near])
285
+ return d
286
+
287
+
288
+ # ---- result fields -------------------------------------------------------------
289
+
290
+ def result_samples(fields: dict, quantity: str = "vonMises", case=None):
291
+ """(points, values) from verify_part(..., fields=True)["fields"]: von
292
+ Mises (MPa, the worst case at each node unless `case` names one) or
293
+ displacement magnitude (mm)."""
294
+ cases = fields["cases"]
295
+ if case is not None:
296
+ cases = [c for c in cases if c["name"] == case]
297
+ if not cases:
298
+ raise ValueError(f"no load case {case!r} in the result (have {[c['name'] for c in fields['cases']]})")
299
+ if quantity == "vonMises":
300
+ vals = np.max([np.asarray(c["vonMises"], float) for c in cases], axis=0)
301
+ elif quantity == "displacement":
302
+ vals = np.max([np.linalg.norm(np.asarray(c["displacement"], float), axis=1) for c in cases], axis=0)
303
+ else:
304
+ raise ValueError("quantity must be 'vonMises' or 'displacement' for a verify result")
305
+ return np.asarray(fields["nodes"], float), vals
306
+
307
+
308
+ def save_result_field(path, points, values, **extra):
309
+ """Write scattered samples (N, 3) / (N,) as the .npz a `result` node
310
+ reads. Extra arrays (other quantities) go in under their own names."""
311
+ np.savez_compressed(path, points=np.asarray(points, np.float32), values=np.asarray(values, np.float32),
312
+ **{k: np.asarray(v) for k, v in extra.items()})
313
+
314
+
315
+ class _Samples:
316
+ """A scalar field from samples: on a regular grid (origin, pitch,
317
+ values[nx, ny, nz]) trilinear; from scattered points (verify's FE nodes,
318
+ a CFD export) put onto the grid they lie on when they do (FE nodes
319
+ always do), else inverse-distance weighted over the 8 nearest. Outside
320
+ the samples the nearest value carries on, so a remap never sees NaN."""
321
+
322
+ def __init__(self, points=None, values=None, grid=None, origin=None, pitch=None):
323
+ if grid is not None:
324
+ self.grid = np.asarray(grid, np.float32)
325
+ self.origin = np.asarray(origin, float)
326
+ self.pitch = np.broadcast_to(np.asarray(pitch, float), (3,)).copy()
327
+ self.tree = None
328
+ return
329
+ pts = np.asarray(points, float).reshape(-1, 3)
330
+ vals = np.asarray(values, float).ravel()
331
+ if len(pts) != len(vals):
332
+ raise ValueError(f"result field has {len(pts)} points but {len(vals)} values")
333
+ g = self._regular(pts, vals)
334
+ if g is not None:
335
+ self.grid, self.origin, self.pitch = g
336
+ self.tree = None
337
+ else:
338
+ from scipy.spatial import cKDTree
339
+
340
+ self.tree = cKDTree(pts)
341
+ self.vals = vals
342
+
343
+ @staticmethod
344
+ def _regular(pts, vals):
345
+ lo = pts.min(axis=0)
346
+ steps = []
347
+ for i in range(3):
348
+ u = np.unique(np.round(pts[:, i] - lo[i], 6))
349
+ if len(u) < 2:
350
+ return None
351
+ du = np.diff(u)
352
+ h = du.min()
353
+ if h <= 0 or not np.allclose(du / h, np.round(du / h), atol=1e-3):
354
+ return None
355
+ steps.append(h)
356
+ pitch = np.asarray(steps)
357
+ ijk = np.round((pts - lo) / pitch).astype(np.int64)
358
+ dims = ijk.max(axis=0) + 1
359
+ if np.prod(dims) > 4 * _CELL_BUDGET:
360
+ return None
361
+ grid = np.full(tuple(dims), np.nan, np.float32)
362
+ grid[ijk[:, 0], ijk[:, 1], ijk[:, 2]] = vals
363
+ hole = np.isnan(grid)
364
+ if hole.any():
365
+ idx = ndimage.distance_transform_edt(hole, return_distances=False, return_indices=True)
366
+ grid = grid[tuple(idx)]
367
+ return grid, lo, pitch
368
+
369
+ def __call__(self, P):
370
+ P = np.asarray(P, float).reshape(-1, 3)
371
+ if self.tree is None:
372
+ idx = ((P - self.origin) / self.pitch).T
373
+ return ndimage.map_coordinates(self.grid, idx, order=1, mode="nearest").astype(float)
374
+ k = min(8, len(self.vals))
375
+ d, j = self.tree.query(P, k=k)
376
+ d, j = d.reshape(len(P), k), j.reshape(len(P), k)
377
+ w = 1.0 / np.maximum(d, 1e-9) ** 2
378
+ return (w * self.vals[j]).sum(axis=1) / w.sum(axis=1)
379
+
380
+
381
+ # ---- TPMS with field-driven cell and wall ---------------------------------------
382
+
383
+ _TPMS_KIND = {"gyroid": "gyroid", "diamond": "diamond", "schwarz-p": "schwarz-p", "schwarz_p": "schwarz-p",
384
+ "schwarzP": "schwarz-p"}
385
+
386
+
387
+ def _frame_points(P, frame):
388
+ """Lattice coordinates: cartesian (P itself), or cylindrical, which wraps
389
+ the lattice round an axis so cells follow a tube or a ring (r, arc length
390
+ at `radius`, height). The cell count round the circumference is rounded
391
+ to a whole number so the lattice closes on itself."""
392
+ if not frame or frame.get("type", "cartesian") == "cartesian":
393
+ return P, None
394
+ if frame["type"] != "cylindrical":
395
+ raise ValueError("lattice frame type must be 'cartesian', 'cylindrical', 'spherical' or 'conformal'")
396
+ c = np.asarray(frame.get("center", (0, 0, 0)), float)
397
+ ax = frame.get("axis", "z")
398
+ a = np.asarray({"x": (1, 0, 0), "y": (0, 1, 0), "z": (0, 0, 1)}[ax] if isinstance(ax, str) else ax, float)
399
+ a = a / np.linalg.norm(a)
400
+ e1 = np.cross(a, (1.0, 0, 0) if abs(a[0]) < 0.9 else (0, 1.0, 0))
401
+ e1 /= np.linalg.norm(e1)
402
+ e2 = np.cross(a, e1)
403
+ Q = P - c
404
+ h = Q @ a
405
+ u, v = Q @ e1, Q @ e2
406
+ r = np.hypot(u, v)
407
+ th = np.arctan2(v, u)
408
+ return np.stack([r, th, h], axis=1), float(frame.get("radius", 0) or 0)
409
+
410
+
411
+ def lattice_orientation(nd):
412
+ """A lattice node's `orient` as a rotation matrix (columns: the cell's
413
+ axes in global components), or None. `orient` is a rotation vector in
414
+ degrees ([rx, ry, rz], its direction the axis, its length the angle) or
415
+ {"axis": [x, y, z] or "x"/"y"/"z", "degrees": a}. It turns the cells
416
+ about the lattice's origin and leaves its fields (thickness, density,
417
+ cell) where they are, unlike a transform node, which moves those too."""
418
+ o = nd.spec.get("orient")
419
+ if o is None:
420
+ return None
421
+ if isinstance(o, dict) and "zones" in o:
422
+ raise ValueError(f"node {nd.name!r}: a zoned orient has no one set of lattice coordinates "
423
+ "(lattice 3MF and beam export take a single orient)")
424
+ from scipy.spatial.transform import Rotation
425
+
426
+ if isinstance(o, dict):
427
+ ax = o.get("axis", "z")
428
+ ax = {"x": (1, 0, 0), "y": (0, 1, 0), "z": (0, 0, 1)}[ax] if isinstance(ax, str) else ax
429
+ ax = np.asarray(ax, float)
430
+ n = np.linalg.norm(ax)
431
+ if n == 0:
432
+ raise ValueError(f"node {nd.name!r}: orient axis is zero")
433
+ rv = ax / n * np.radians(float(o.get("degrees", 0.0)))
434
+ else:
435
+ rv = np.radians(np.asarray(o, float))
436
+ if rv.shape != (3,):
437
+ raise ValueError(f"node {nd.name!r}: orient is a rotation vector [rx, ry, rz] in degrees")
438
+ frame = (nd.spec.get("frame") or {}).get("type", "cartesian")
439
+ if frame != "cartesian":
440
+ raise ValueError(f"node {nd.name!r}: orient turns a cartesian lattice; a {frame} frame sets its own axes")
441
+ if isinstance(nd.refs.get("cell"), str) and nd.spec.get("grading", "warp") == "warp":
442
+ raise ValueError(f"node {nd.name!r}: orient with a graded (warp) cell is not supported; "
443
+ "grade the thickness or density instead")
444
+ if not np.any(rv):
445
+ return None
446
+ return Rotation.from_rotvec(rv).as_matrix()
447
+
448
+
449
+ def zone_rotations(nd):
450
+ """(k, 3, 3) each orient zone's rotation (columns: the cell's axes)."""
451
+ from scipy.spatial.transform import Rotation
452
+
453
+ zones = nd.spec["orient"]["zones"]
454
+ return np.stack([Rotation.from_rotvec(np.radians(np.asarray(z.get("rotate", (0, 0, 0)), float))).as_matrix()
455
+ for z in zones])
456
+
457
+
458
+ def orient_weights(nd, P):
459
+ """(n, k) blend weights of a zoned orient's zones at P, summing to 1: a
460
+ soft Voronoi split between the zones' `at` points, each zone's weight
461
+ falling as a Gaussian of how much farther its point is than the
462
+ nearest one, over `blend` mm (default one cell). Far from a boundary
463
+ one zone has all of it; across one the lattices cross-fade over about
464
+ `blend`."""
465
+ o = nd.spec["orient"]
466
+ zones = o["zones"]
467
+ if not zones:
468
+ raise ValueError(f"node {nd.name!r}: orient.zones is empty")
469
+ for z in zones:
470
+ if "at" not in z:
471
+ raise ValueError(f"node {nd.name!r}: every orient zone needs an 'at' point")
472
+ if (nd.spec.get("frame") or {}).get("type", "cartesian") != "cartesian":
473
+ raise ValueError(f"node {nd.name!r}: orient turns a cartesian lattice")
474
+ if isinstance(nd.refs.get("cell"), str) and nd.spec.get("grading", "warp") == "warp":
475
+ raise ValueError(f"node {nd.name!r}: orient with a graded (warp) cell is not supported")
476
+ at = np.array([z["at"] for z in zones], float)
477
+ if o.get("seam"):
478
+ # seam plates: each zone's cells run to the plate between it and the
479
+ # next zone, a hard split (the nearest zone's point)
480
+ d = ((np.asarray(P, float)[:, None, :] - at[None]) ** 2).sum(-1)
481
+ return np.eye(len(at))[np.argmin(d, axis=1)]
482
+ cell = nd.spec.get("cell", 8.0)
483
+ blend = float(o.get("blend", cell if isinstance(cell, (int, float)) else 8.0))
484
+ return zone_weights(at, blend, P)
485
+
486
+
487
+ def seam_distance(at, P):
488
+ """Unsigned mm distance from P to the Voronoi face between its nearest
489
+ zone point and the next nearest (the bisector plane of the two)."""
490
+ at = np.asarray(at, float)
491
+ d2 = ((np.asarray(P, float)[:, None, :] - at[None]) ** 2).sum(-1)
492
+ if len(at) < 2:
493
+ return np.full(len(P), np.inf)
494
+ o = np.argsort(d2, axis=1)[:, :2]
495
+ i = np.arange(len(P))
496
+ gap = np.linalg.norm(at[o[:, 1]] - at[o[:, 0]], axis=1)
497
+ return (d2[i, o[:, 1]] - d2[i, o[:, 0]]) / np.maximum(2.0 * gap, 1e-12)
498
+
499
+
500
+ def zone_weights(at, blend, P):
501
+ """orient_weights for zone points `at` (k, 3) and `blend` mm."""
502
+ P = np.asarray(P, float)
503
+ d = np.sqrt(((P[:, None, :] - np.asarray(at, float)[None]) ** 2).sum(-1))
504
+ x = (d - d.min(axis=1, keepdims=True)) / max(0.5 * blend, 1e-9)
505
+ w = np.exp(-x * x)
506
+ return w / w.sum(axis=1, keepdims=True)
507
+
508
+
509
+ def _tpms_distance(kind, P, cell, frame=None):
510
+ """Signed mm distance to the TPMS midsurface with period `cell` (a scalar
511
+ or a per-point array). This is the `grading: "blend"` path for a varying
512
+ cell: the distance fields of a ladder of fixed periods (at most 1.25x
513
+ apart) cross-faded with a smoothstep. Exact inside a level; across a step
514
+ the two lattices disagree and leave crumbs, which is why the default is
515
+ the warp (_CellWarp)."""
516
+ from .lattice import tpms
517
+
518
+ kind = _TPMS_KIND.get(kind)
519
+ if kind is None:
520
+ raise ValueError(f"lattice type must be one of gyroid, diamond, schwarz-p")
521
+ Q, radius = _frame_points(P, frame)
522
+ cell = np.broadcast_to(np.asarray(cell, float), (len(P),))
523
+ if np.any(cell <= 0):
524
+ raise ValueError("lattice cell must be positive everywhere")
525
+
526
+ def at(c, idx):
527
+ q = Q[idx]
528
+ if radius is None:
529
+ return tpms(kind, q, c)
530
+ # cylindrical: arc length at the frame radius, whole cells round
531
+ R = radius or float(np.median(q[:, 0]))
532
+ n = max(1, round(2 * np.pi * R / c))
533
+ q = np.stack([q[:, 0], q[:, 1] * n * c / (2 * np.pi), q[:, 2]], axis=1)
534
+ return tpms(kind, q, c)
535
+
536
+ cmin, cmax = float(cell.min()), float(cell.max())
537
+ if cmax - cmin <= 1e-9 * cmax:
538
+ return at(cmin, slice(None))
539
+ ratio = 1.25
540
+ n = max(1, int(np.ceil(np.log(cmax / cmin) / np.log(ratio))))
541
+ ratio = (cmax / cmin) ** (1.0 / n)
542
+ s = np.clip(np.log(cell / cmin) / np.log(ratio), 0.0, n - 1e-9)
543
+ lev = np.floor(s).astype(int)
544
+ t = s - lev
545
+ t = t * t * (3 - 2 * t)
546
+ out = np.empty(len(P))
547
+ for k in np.unique(lev):
548
+ idx = np.nonzero(lev == k)[0]
549
+ lo_c, hi_c = cmin * ratio ** k, cmin * ratio ** (k + 1)
550
+ tk = t[idx]
551
+ d = at(lo_c, idx)
552
+ mix = tk > 1e-6
553
+ if mix.any():
554
+ d_hi = at(hi_c, idx[mix])
555
+ d[mix] = (1 - tk[mix]) * d[mix] + tk[mix] * d_hi
556
+ out[idx] = d
557
+ return out
558
+
559
+
560
+ def _lsq_potential(T, h):
561
+ """The grid scalar u whose differences along every grid edge best match
562
+ the target gradient T (dims + (3,), per unit length) times the step h:
563
+ least squares, D^T D u = D^T t. D^T D is the grid graph's Laplacian with
564
+ free (Neumann) ends, which the DCT-II diagonalizes exactly, so the solve
565
+ is direct, O(n log n). Defined up to a constant (its mean is zero)."""
566
+ from scipy import fft
567
+
568
+ dims = T.shape[:3]
569
+ lam = sum((2 - 2 * np.cos(np.pi * np.arange(dims[a]) / dims[a])).reshape([-1 if i == a else 1
570
+ for i in range(3)])
571
+ for a in range(3))
572
+ lam[0, 0, 0] = 1.0
573
+ b = np.zeros(dims)
574
+ for a in range(3):
575
+ sl0 = [slice(None)] * 3
576
+ sl1 = [slice(None)] * 3
577
+ sl0[a] = slice(0, -1)
578
+ sl1[a] = slice(1, None)
579
+ te = 0.5 * (T[tuple(sl0) + (a,)] + T[tuple(sl1) + (a,)]) * h # target step along each edge
580
+ b[tuple(sl0)] -= te
581
+ b[tuple(sl1)] += te # D^T t
582
+ B = fft.dctn(b, type=2, norm="ortho") / lam
583
+ B[0, 0, 0] = 0.0
584
+ return fft.idctn(B, type=2, norm="ortho")
585
+
586
+
587
+ class _CellWarp:
588
+ """Lattice coordinates u(x) for a cell size that varies in space: the
589
+ least-squares map whose gradient is I / cell(x) (a Poisson solve on a
590
+ grid over the lattice's domain, direct by DCT), so one period
591
+ of u is one local cell. In 1D that is exactly u = integral of dx / cell;
592
+ in 3D, where a cell field has curl the map cannot follow everywhere, the
593
+ misfit spreads out smoothly instead of tearing. Every wall stays
594
+ continuous across a grading, unlike cross-fading lattices of different
595
+ periods, which leaves crumbs where they disagree."""
596
+
597
+ def __init__(self, cell_fn, lo, hi, max_cells=400_000):
598
+ lo, h, dims = self.grid(cell_fn, lo, hi, max_cells)
599
+ axes = [lo[i] + np.arange(dims[i]) * h for i in range(3)]
600
+ G = np.stack(np.meshgrid(*axes, indexing="ij"), -1).reshape(-1, 3)
601
+ c = np.asarray(cell_fn(G), float)
602
+ if np.any(c <= 0):
603
+ raise ValueError("lattice cell must be positive everywhere")
604
+ g = (1.0 / c).reshape(tuple(dims))
605
+ n = int(np.prod(dims))
606
+ self.u = []
607
+ for comp in range(3):
608
+ T = np.zeros(tuple(dims) + (3,))
609
+ T[..., comp] = g
610
+ u = _lsq_potential(T, h).ravel()
611
+ # anchor: u = x / cell at the domain's centre point, so a constant
612
+ # cell gives back the plain lattice
613
+ self.u.append((u - u[n // 2]).reshape(tuple(dims)) + G[n // 2, comp] * g.ravel()[n // 2])
614
+ self.lo, self.h, self.dims = lo, h, dims
615
+
616
+ def _grids(self):
617
+ if getattr(self, "_c", None) is None:
618
+ self._c = tuple(np.ascontiguousarray(u, np.float64) for u in self.u)
619
+ return self._c
620
+
621
+ @staticmethod
622
+ def grid(cell_fn, lo, hi, max_cells):
623
+ """(lo, h, dims) of the grid the map is solved on."""
624
+ lo = np.asarray(lo, float)
625
+ hi = np.asarray(hi, float)
626
+ ext = np.maximum(hi - lo, 1e-6)
627
+ h = float((np.prod(ext) / max_cells) ** (1 / 3))
628
+ # a few grid steps per cell at least, where the grid budget allows
629
+ probe = np.stack(np.meshgrid(*[np.linspace(lo[i], hi[i], 12) for i in range(3)], indexing="ij"),
630
+ -1).reshape(-1, 3)
631
+ h = max(h, 0.0) if h > 0 else 1.0
632
+ h = min(max(h, float(np.min(cell_fn(probe))) / 6.0), max(h, 1e-3))
633
+ lo = lo - 2 * h
634
+ dims = np.ceil((hi + 2 * h - lo) / h).astype(int) + 1
635
+ return lo, h, dims
636
+
637
+ def anchor(self):
638
+ """The point where u = x / cell: the grid's centre node."""
639
+ n = int(np.prod(self.dims))
640
+ ijk = np.asarray(np.unravel_index(n // 2, tuple(self.dims)))
641
+ return self.lo + ijk * self.h
642
+
643
+ def __call__(self, P, cell):
644
+ from . import fieldops
645
+
646
+ if fieldops.available() and len(P):
647
+ return fieldops.warp(P, self.lo, self.h, self._grids(), cell)
648
+ idx = ((P - self.lo) / self.h).T
649
+ n = (self.dims - 1)[:, None]
650
+ cl = np.clip(idx, 0, n)
651
+ out = np.stack([ndimage.map_coordinates(u, cl, order=1, mode="nearest") for u in self.u], axis=1)
652
+ # past the grid: carry on at the local rate
653
+ return out + (idx - cl).T * self.h / np.broadcast_to(np.asarray(cell, float), (len(P),))[:, None]
654
+
655
+
656
+ class _ConformalMap:
657
+ """Lattice coordinates that follow a surface (nTop's conformal lattice):
658
+ u3 counts cell layers in from the surface (its signed distance over the
659
+ through-thickness cell, exact, so every layer is parallel to the skin
660
+ and the first one sits on it), and (u1, u2) run along the surface. The
661
+ tangent directions are a reference axis projected onto the tangent
662
+ plane and its cross with the normal (where the normal turns towards the
663
+ reference, a second axis takes over smoothly). Round a closed surface (a
664
+ tube, a boss, the section of a link) the tangent field circulates and no
665
+ coordinate follows it, so (u1, u2) are phases, defined modulo one cell,
666
+ which is all a periodic cell reads: stripe patterns (_stripe_phase). Where
667
+ a whole number of cells cannot fit round a loop the pattern puts a phase
668
+ defect deep inside, not a shear in every cell at the skin.
669
+
670
+ `surface_fn(P)` is the surface's signed distance, `cell_fn(P)` the
671
+ in-plane cell (mm, a field grades it), `normal_cell` the cell through
672
+ the thickness (mm; default the cell)."""
673
+
674
+ def __init__(self, surface_fn, cell_fn, lo, hi, axis=None, normal_cell=None, max_cells=40_000,
675
+ layer_smoothing=0.0, frame_field=True):
676
+ # a phase needs only a few grid steps a cell (_phase_at unwraps
677
+ # between nodes); the solve is a sparse LU, so the grid stays small
678
+ lo = np.asarray(lo, float)
679
+ hi = np.asarray(hi, float)
680
+ probe = np.stack(np.meshgrid(*[np.linspace(lo[i], hi[i], 12) for i in range(3)], indexing="ij"),
681
+ -1).reshape(-1, 3)
682
+ h = max(float(np.min(cell_fn(probe))) / 3.5, float((np.prod(hi - lo + 4.0) / max_cells) ** (1 / 3)))
683
+ ext = hi - lo
684
+ lo = lo - 2 * h
685
+ dims = np.ceil((hi + 2 * h - lo) / h).astype(int) + 1
686
+ axes = [lo[i] + np.arange(dims[i]) * h for i in range(3)]
687
+ G = np.stack(np.meshgrid(*axes, indexing="ij"), -1).reshape(-1, 3)
688
+ c = np.asarray(cell_fn(G), float).reshape(tuple(dims))
689
+ if np.any(c <= 0):
690
+ raise ValueError("lattice cell must be positive everywhere")
691
+ d = np.asarray(surface_fn(G), float).reshape(tuple(dims))
692
+ n = np.stack(np.gradient(d, h), -1)
693
+ n /= np.maximum(np.linalg.norm(n, axis=-1, keepdims=True), 1e-9)
694
+ if axis is None:
695
+ a = np.eye(3)[int(np.argmax(ext))]
696
+ b = np.eye(3)[int(np.argsort(ext)[1])]
697
+ if frame_field:
698
+ # the part's own axes (the normals' principal directions),
699
+ # the ones nearest the box's: a box turned on the bed still
700
+ # gets cells square to its faces on every side
701
+ gm = np.linalg.norm(np.stack(np.gradient(d, h), -1), axis=-1)
702
+ on = (np.abs(d) < h) & (gm > 0.95)
703
+ if on.sum() > 10:
704
+ N = np.einsum("ni,nj->ij", n[on], n[on])
705
+ E = np.linalg.eigh(N)[1]
706
+ ia = int(np.argmax(np.abs(E.T @ a)))
707
+ a = E[:, ia] * np.sign(E[:, ia] @ a)
708
+ rest = [k for k in range(3) if k != ia]
709
+ ib = rest[int(np.argmax(np.abs(E[:, rest].T @ b)))]
710
+ b = E[:, ib] * np.sign(E[:, ib] @ b or 1.0)
711
+ else:
712
+ a = np.asarray(_axis_vector(axis), float)
713
+ b = np.eye(3)[int(np.argmin(np.abs(a)))]
714
+ b = b - (b @ a) * a
715
+ b /= np.linalg.norm(b)
716
+ w = np.abs(n @ a)[..., None]
717
+ sw = np.clip((w - 0.8) / 0.15, 0, 1)
718
+ sw = sw * sw * (3 - 2 * sw)
719
+ ref = (1 - sw) * a + sw * b
720
+ t1 = ref - (ref * n).sum(-1, keepdims=True) * n
721
+ t1 /= np.maximum(np.linalg.norm(t1, axis=-1, keepdims=True), 1e-9)
722
+ t2 = np.cross(n, t1)
723
+ # what matters is the cells at and just under the skin: weight
724
+ # there, so a cell count that has to change with depth (fewer cells
725
+ # round a smaller circle) changes deep inside, and the box outside
726
+ # the solid barely counts
727
+ depth = np.maximum(-d, 0.0) / c
728
+ wgt = np.where(d > 0, np.exp(-(d / h) ** 2) + 1e-3, np.exp(-depth / 1.5))
729
+ self.frame_field = bool(frame_field)
730
+ self.lo_grid = lo
731
+ if self.frame_field:
732
+ self.phase = self._frame_field_phases(d, n, t1, t2, a, c, h, wgt, normal_cell)
733
+ else:
734
+ self.phase = [_PhaseGrid(*_quiet_fill(*_stripe_phase(t / c[..., None], h, weight=wgt, amplitude=True)),
735
+ lo, h) for t in (t1, t2)]
736
+ self.lo, self.h, self.dims = lo, h, dims
737
+ self.normal_cell = normal_cell
738
+ self.layer_smoothing = float(layer_smoothing)
739
+ self.surface_fn = surface_fn
740
+
741
+ def __call__(self, P, cell, surface=None, coherence=False):
742
+ """(N, 3) coordinates, period 1 a cell (the two tangent ones are
743
+ phases, modulo 1). `surface` is the surface's distance at P when the
744
+ caller has it already. With `coherence`, also the lesser of the two
745
+ phases' coherence (_PhaseGrid), low round a defect."""
746
+ cell = np.broadcast_to(np.asarray(cell, float), (len(P),))
747
+ both = [phi(P, coherence=True) for phi in self.phase]
748
+ if self.frame_field:
749
+ U = np.stack([b[0] for b in both], axis=1)
750
+ if coherence:
751
+ return U, np.min([b[1] for b in both], axis=0)
752
+ return U
753
+ dist = np.asarray(self.surface_fn(P) if surface is None else surface, float)
754
+ dist = self._rounded_layers(P, dist, cell)
755
+ cn = cell if self.normal_cell is None else self.normal_cell
756
+ U = np.stack([b[0] for b in both] + [-dist / cn], axis=1)
757
+ if coherence:
758
+ return U, np.minimum(both[0][1], both[1][1])
759
+ return U
760
+
761
+ def _frame_field_phases(self, d, n, t1, t2, a, c, h, wgt, normal_cell):
762
+ """Three phases that follow a 3D frame field, so the cells turn a
763
+ box's edges and corners. The two-stripe frame takes its layer
764
+ coordinate from the surface's distance, which is a different axis on
765
+ each face of a box: across an edge, the tangent that follows the
766
+ edge on one face is the layer direction on the next, so along every
767
+ edge and miter two of the three coordinates run parallel and the
768
+ cells shear (over 40% of a 12 mm box's cells; 6.7% of its open void
769
+ patches folded). Here every grid node carries an orthonormal frame,
770
+ defined up to the cube's symmetries (which axis is which, and their
771
+ signs): the surface normal (of the nearest skin point) and the
772
+ smoothest cross field on the layers (_cross_field). The frames are
773
+ labelled consistently by one synchronization solve
774
+ (_label_frames), so on a box the x faces' layer direction and the y
775
+ faces' along-edge direction are the same label, and both are just
776
+ the box's x. Each label gets a stripe pattern (_stripe_phase) pinned
777
+ near the skin to the distance over the cell where that label is the
778
+ normal (a layer of nodes on every face) and following the frame
779
+ elsewhere: on a box, the box's own straight cells, a whole number
780
+ between opposite faces."""
781
+ th = _cross_field(n, t1, t2, np.exp(-(d / c) ** 2))
782
+ c4, s4 = np.cos(th)[..., None], np.sin(th)[..., None]
783
+ t1, t2 = c4 * t1 + s4 * t2, c4 * t2 - s4 * t1
784
+ F = np.stack([n, t1, t2], -1) # columns: the frame's axes
785
+ gn = np.linalg.norm(np.stack(np.gradient(d, h), -1), axis=-1)
786
+ # where the distance's normal is muddled (its medial axis: a box's
787
+ # miters, the middle of a tube, where the two faces' normals average
788
+ # to a diagonal) the frame of the nearest clean node; outside the
789
+ # solid the nearest one inside, so an open lattice runs on straight
790
+ # past the faces instead of turning round the rounded distance
791
+ # beyond an edge
792
+ clean = (gn > 0.95) & (d < 0.05 * h)
793
+ if clean.any() and not clean.all():
794
+ idx = ndimage.distance_transform_edt(~clean, return_distances=False, return_indices=True)
795
+ F = F[tuple(idx)]
796
+ A = _label_frames(F, weight=np.exp(-(d / c) ** 2))
797
+ # order the labels: the layer direction last, the one along `a` first
798
+ band = np.exp(-(np.maximum(-d, 0) / c) ** 2) * (d < 0.5 * h)
799
+ al = np.abs(np.einsum("...ik,...i->...k", A, n))
800
+ layer = int(np.argmax((al * band[..., None]).reshape(-1, 3).sum(0)))
801
+ rest = [k for k in range(3) if k != layer]
802
+ along = np.abs(np.einsum("...ik,i->...k", A, a)).reshape(-1, 3).sum(0)
803
+ rest.sort(key=lambda k: -along[k])
804
+ A = A[..., rest + [layer]]
805
+ self.frames = A
806
+ out = []
807
+ for k in range(3):
808
+ ak = A[..., k]
809
+ s_k = np.einsum("...i,...i->...", ak, n)
810
+ ck = c if normal_cell is None else c + (float(normal_cell) - c) * s_k ** 2
811
+ # pin where this label is the normal: within about a cell of the
812
+ # skin (and on outside, where an open lattice runs past the
813
+ # faces), to phase = +-distance / cell, zero on the skin
814
+ q = np.clip((np.abs(s_k) - 0.85) / 0.1, 0, 1)
815
+ q = q * q * (3 - 2 * q) * (gn > 0.95)
816
+ dep = np.where(d < 0, -d / ck, 0.5 * d / ck)
817
+ alpha = q * np.clip((1.25 - dep) / 0.5, 0, 1)
818
+ target = np.sign(s_k) * d / ck
819
+ T = ak / ck[..., None]
820
+ out.append(_PhaseGrid(*_quiet_fill(*_stripe_phase(T, h, weight=wgt, amplitude=True,
821
+ anchor=(alpha, target))), self.lo_grid, h))
822
+ return out
823
+
824
+ def skin_shear(self, cell_fn, n=6000, seed=0):
825
+ """The fraction of sheared cells (smallest over largest singular
826
+ value of the coordinates' Jacobian, in cells, under 0.3) in the first
827
+ cell under the skin: what the frame is judged by."""
828
+ hi = self.lo + (self.dims - 1) * self.h
829
+ rng = np.random.default_rng(seed)
830
+ got = []
831
+ for _ in range(8):
832
+ P = rng.uniform(self.lo, hi, (8 * n, 3))
833
+ c = np.asarray(cell_fn(P), float)
834
+ d = np.asarray(self.surface_fn(P), float)
835
+ got.append(P[(d < 0) & (d > -c)])
836
+ if sum(len(x) for x in got) >= n:
837
+ break
838
+ P = np.concatenate(got)[:n]
839
+ if not len(P):
840
+ return 0.0
841
+ c = np.asarray(cell_fn(P), float)
842
+ e = 1e-3
843
+ J = np.stack([(self(P + e * v, c) - self(P - e * v, c)) / (2 * e) for v in np.eye(3)], 2) * c[:, None, None]
844
+ sv = np.linalg.svd(J, compute_uv=False)
845
+ return float((sv[:, 2] / np.maximum(sv[:, 0], 1e-12) < 0.3).mean())
846
+
847
+ # a symmetric stencil (6 axis + 8 diagonal unit directions): its mean
848
+ # of a linear function is the centre value, so it moves nothing where
849
+ # the distance is linear
850
+ _STENCIL = np.concatenate([np.vstack([np.eye(3), -np.eye(3)]),
851
+ np.array([[a, b, c] for a in (-1, 1) for b in (-1, 1) for c in (-1, 1)]) / np.sqrt(3)])
852
+ _STENCIL_W = np.r_[np.full(6, 1 / 15), np.full(8, 9 / 120)]
853
+
854
+ def _rounded_layers(self, P, dist, cell):
855
+ """The surface distance with its folds rounded: the exact distance
856
+ kinks on the surface's medial axis (two faces of a box, the far
857
+ sides of a link's section), and a strut there meets its own mirror
858
+ image; near a corner the touch is tangential, so the part's void
859
+ surface pinches (a non-manifold edge no grid parts) and its STEP
860
+ export fails. The distance is replaced by its mean over a sphere of
861
+ radius `layer_smoothing` x depth (capped at a cell): exact at the
862
+ skin (radius 0) and wherever the distance is linear (one face in
863
+ reach), rounded where faces meet, so layers deeper in follow a
864
+ filleted offset of the surface instead of its sharp medial axis.
865
+ Opt in (frame "layerSmoothing"): it costs 14 more surface
866
+ evaluations a point, and the pinches it was meant for are parted by
867
+ the mesher's pinch splitter (ibrep.split_pinches) anyway."""
868
+ beta = self.layer_smoothing
869
+ if beta <= 0:
870
+ return dist
871
+ r = np.minimum(beta * np.maximum(-dist, 0.0), np.asarray(cell, float))
872
+ on = np.flatnonzero(r > 1e-6)
873
+ if not len(on):
874
+ return dist
875
+ S = self._STENCIL
876
+ Q = (P[on, None, :] + r[on, None, None] * S[None]).reshape(-1, 3)
877
+ avg = (np.asarray(self.surface_fn(Q), float).reshape(len(on), len(S)) * self._STENCIL_W).sum(1)
878
+ out = dist.copy()
879
+ out[on] = avg
880
+ return out
881
+
882
+ @staticmethod
883
+ def defect_fill(coh, lo=0.1, hi=0.6):
884
+ """1 where the cells are regular, falling to 0 round a phase defect:
885
+ there the cells shrink to nothing (the phase winds round the defect
886
+ line), so strut and sheet distances pile into a knot of sub-mm
887
+ features that neither the mesher nor STEP patches resolve. The
888
+ lattice's unit distance is scaled by this, so a defect prints as a
889
+ small solid node, continuously, instead.
890
+
891
+ The ramp reaches out to coherence 0.6: with it ending at 0.35, 70%
892
+ of the folded void patches of an open 12 mm conformal octet block
893
+ (cell 6) sat round defects (69% of patches folded below 0.35, 38%
894
+ at 0.35-0.6, 3.5% above 0.9, about what straight cells fold), and
895
+ its STEP failed where they met the faces. 0.1-0.6 halves the folds
896
+ (12% -> 6.7% on a corner of the block) for 7% more lattice mass on
897
+ that block, where defects fill much of the volume (a 2-cell box);
898
+ 0.3-0.7 folds 5.9% for 17%, 0.5-0.9 6.6% for 46%."""
899
+ q = np.clip((coh - lo) / (hi - lo), 0.0, 1.0)
900
+ return q * q * (3 - 2 * q)
901
+
902
+
903
+ def _stripe_phase(T, h, weight=None, amplitude=False, anchor=None):
904
+ """The phase (cycles, 0-1, on the grid) that advances by T . e h along
905
+ every grid edge as nearly as it can: Knoeppel et al.'s stripe patterns,
906
+ psi = exp(2 pi i phase) the lowest eigenvector of the connection
907
+ Laplacian sum |psi_j - exp(2 pi i w_ij) psi_i|^2. Where the target
908
+ cannot be followed (a whole number of cells has to fit round a loop)
909
+ the pattern puts a phase defect, a line where |psi| -> 0, instead of
910
+ shearing every cell. `weight` (dims) says where following the target
911
+ matters (each edge counts the mean of its ends'): a cell count that has
912
+ to change with depth is settled where the weight is low."""
913
+ import scipy.sparse as sp
914
+ from scipy.sparse.linalg import eigsh
915
+
916
+ dims = T.shape[:3]
917
+ n = int(np.prod(dims))
918
+ ids = np.arange(n).reshape(dims)
919
+ I, J, W, K = [], [], [], []
920
+ wgt = np.ones(dims) if weight is None else weight
921
+ for a in range(3):
922
+ sl0 = [slice(None)] * 3
923
+ sl1 = [slice(None)] * 3
924
+ sl0[a] = slice(0, -1)
925
+ sl1[a] = slice(1, None)
926
+ I.append(ids[tuple(sl0)].ravel())
927
+ J.append(ids[tuple(sl1)].ravel())
928
+ W.append((0.5 * (T[tuple(sl0) + (a,)] + T[tuple(sl1) + (a,)]) * h).ravel())
929
+ K.append((0.5 * (wgt[tuple(sl0)] + wgt[tuple(sl1)])).ravel())
930
+ I, J, W, K = np.concatenate(I), np.concatenate(J), np.concatenate(W), np.concatenate(K)
931
+ deg = np.bincount(I, K, minlength=n) + np.bincount(J, K, minlength=n)
932
+ rot = K * np.exp(2j * np.pi * W)
933
+ # psi_j ~ rot psi_i: L_jj += 1, L_ii += 1, L_ji -= rot, L_ij -= conj(rot)
934
+ L = sp.csr_matrix((np.concatenate([-rot, -np.conj(rot)]), (np.concatenate([J, I]), np.concatenate([I, J]))),
935
+ shape=(n, n)) + sp.diags(deg.astype(complex))
936
+ # shift-invert Lanczos on one sparse LU: the grid is a few steps a cell,
937
+ # so the factorization takes seconds
938
+ psi0 = np.exp(2j * np.pi * _lsq_potential(T, h).ravel())
939
+ alpha = None if anchor is None else np.asarray(anchor[0], float).ravel()
940
+ if alpha is not None and alpha.max() > 0.5:
941
+ # pinned (phase = target where alpha > 0): one linear solve,
942
+ # (L + mu A) psi = mu A exp(2 pi i target), which also fixes the
943
+ # pattern's free global shift
944
+ from scipy.sparse.linalg import splu
945
+
946
+ mu = 4.0 * float(deg.mean())
947
+ rhs = mu * alpha * np.exp(2j * np.pi * np.asarray(anchor[1], float).ravel())
948
+ vec = splu((L + sp.diags(mu * alpha + 1e-9 * float(deg.mean()))).tocsc()).solve(rhs)[:, None]
949
+ else:
950
+ _, vec = eigsh(L.tocsc(), k=1, sigma=-1e-6 * float(deg.mean()), which="LM", v0=psi0, tol=1e-8)
951
+ phase = (np.angle(vec[:, 0]) / (2 * np.pi) % 1.0).reshape(dims)
952
+ if not amplitude:
953
+ return phase
954
+ # how well the pattern follows the target at each node: the mean of
955
+ # its edges' misfits as phasors, 1 where every edge advances as
956
+ # asked, ~0.3 for noise (|psi| itself is no guide: the eigenvector
957
+ # concentrates in a few places and is round-off small elsewhere)
958
+ ph = phase.ravel()
959
+ e = np.exp(2j * np.pi * (ph[J] - ph[I] - W))
960
+ cnt = np.bincount(I, minlength=n) + np.bincount(J, minlength=n)
961
+ coh = np.abs(np.bincount(I, e.real, minlength=n) + np.bincount(J, e.real, minlength=n)
962
+ + 1j * (np.bincount(I, e.imag, minlength=n) + np.bincount(J, e.imag, minlength=n)))
963
+ coh = (coh / np.maximum(cnt, 1)).reshape(dims)
964
+ return phase, coh, np.angle(psi0).reshape(dims) / (2 * np.pi) % 1.0
965
+
966
+
967
+ def _cross_field(n, t1, t2, weight=None):
968
+ """The smoothest field of tangent crosses (four directions 90 degrees
969
+ apart in the plane normal to n at every node), as its angle from t1
970
+ towards t2: Knoeppel et al.'s globally optimal direction fields, the
971
+ lowest eigenvector of the connection Laplacian on z = exp(4 i angle),
972
+ each grid edge carrying the turn between its ends' tangent bases once
973
+ the first's normal is rotated onto the second's (so a cross runs over a
974
+ box's edge onto the next face as that face's own square). Singular
975
+ points (a tube's end face, a sphere) go where they cost least. Edges
976
+ between opposed normals (across a medial axis) count for nothing."""
977
+ import scipy.sparse as sp
978
+ from scipy.sparse.linalg import eigsh
979
+
980
+ dims = n.shape[:3]
981
+ nn = int(np.prod(dims))
982
+ ids = np.arange(nn).reshape(dims)
983
+ N, T1, T2 = n.reshape(-1, 3), t1.reshape(-1, 3), t2.reshape(-1, 3)
984
+ wg = np.ones(nn) if weight is None else np.asarray(weight, float).ravel()
985
+ # no node cut off (an isolated node would be the lowest mode)
986
+ wg = np.maximum(wg, 1e-6 * float(wg.max()))
987
+ I, J, R, K = [], [], [], []
988
+ for ax in range(3):
989
+ sl0 = [slice(None)] * 3
990
+ sl1 = [slice(None)] * 3
991
+ sl0[ax] = slice(0, -1)
992
+ sl1[ax] = slice(1, None)
993
+ i, j = ids[tuple(sl0)].ravel(), ids[tuple(sl1)].ravel()
994
+ ni, nj = N[i], N[j]
995
+ cs = np.clip((ni * nj).sum(1), -1.0, 1.0)
996
+ # Rodrigues: the least rotation taking ni to nj, applied to t1_i
997
+ k = np.cross(ni, nj)
998
+ v = T1[i]
999
+ kv = np.cross(k, v)
1000
+ rot = v + kv + np.cross(k, kv) / np.maximum(1.0 + cs, 1e-6)[:, None]
1001
+ ang = np.arctan2((rot * T2[j]).sum(1), (rot * T1[j]).sum(1))
1002
+ I.append(i)
1003
+ J.append(j)
1004
+ R.append(ang)
1005
+ K.append(np.sqrt(wg[i] * wg[j]) * np.clip(cs, 0.0, 1.0))
1006
+ I, J, R, K = np.concatenate(I), np.concatenate(J), np.concatenate(R), np.concatenate(K)
1007
+ deg = np.bincount(I, K, minlength=nn) + np.bincount(J, K, minlength=nn)
1008
+ rot = K * np.exp(4j * R)
1009
+ L = sp.csr_matrix((np.concatenate([-rot, -np.conj(rot)]), (np.concatenate([J, I]), np.concatenate([I, J]))),
1010
+ shape=(nn, nn)) + sp.diags(deg.astype(complex) + 1e-9 * float(deg.mean()))
1011
+ # against the nodes' weight (a mass matrix), so a node hardly tied to
1012
+ # the rest is no cheap mode of its own
1013
+ M = sp.diags(deg.astype(complex) + 1e-9 * float(deg.mean()))
1014
+ _, vec = eigsh(L.tocsc(), k=1, M=M.tocsc(), sigma=-1e-6, which="LM", v0=np.ones(nn, complex), tol=1e-8)
1015
+ z = vec[:, 0]
1016
+ # the eigenvector's global phase turns every cross alike: pick the one
1017
+ # nearest the reference bases (a box's own axes)
1018
+ z = z * np.exp(-1j * np.angle((wg * z / np.maximum(np.abs(z), 1e-300)).sum()))
1019
+ return (np.angle(z) / 4.0).reshape(dims)
1020
+
1021
+
1022
+ _PERMS = np.array([(0, 1, 2), (0, 2, 1), (1, 0, 2), (1, 2, 0), (2, 0, 1), (2, 1, 0)])
1023
+
1024
+
1025
+ def _match_frames(Fi, Fj):
1026
+ """Fj's axes reordered and signed to match Fi's (both (..., 3, 3), axes
1027
+ as columns), the cube's symmetry that brings them closest, and how well
1028
+ they match (3 when the frames are the same up to that symmetry)."""
1029
+ M = np.einsum("...ik,...il->...kl", Fi, Fj) # M[k, l] = a_ik . a_jl
1030
+ sc = np.stack([np.abs(M[..., 0, p[0]]) + np.abs(M[..., 1, p[1]]) + np.abs(M[..., 2, p[2]]) for p in _PERMS], -1)
1031
+ best = np.argmax(sc, -1)
1032
+ perm = _PERMS[best] # (..., 3)
1033
+ sgn = np.sign(np.take_along_axis(M, perm[..., :, None], -1)[..., 0])
1034
+ sgn[sgn == 0] = 1.0
1035
+ G = np.take_along_axis(Fj, np.broadcast_to(perm[..., None, :], Fj.shape), -1) * sgn[..., None, :]
1036
+ return G, np.take_along_axis(sc, best[..., None], -1)[..., 0]
1037
+
1038
+
1039
+ def _polar(M):
1040
+ """The rotation (or reflection) nearest M: U V^T of its SVD."""
1041
+ U, _, Vt = np.linalg.svd(M)
1042
+ return U @ Vt
1043
+
1044
+
1045
+ def _label_frames(F, weight=None):
1046
+ """The frames (dims + (3, 3)) relabelled consistently: each one's axes
1047
+ reordered and signed (a symmetry of the cube, R_i) so neighbours match
1048
+ column to column. Choosing R_i is a synchronization problem: maximize
1049
+ sum_ij w_ij tr((F_i R_i)^T F_j R_j) (Singer's synchronization). Its
1050
+ spectral relaxation (the top three eigenvectors of the block matrix
1051
+ w_ij P_ij^T, P_ij the symmetry that best matches j's axes to i's, whose
1052
+ node blocks are R_i up to one global rotation) settles every node at
1053
+ once, rounded to the nearest symmetry. Round a singular line whose loop permutes the
1054
+ axes no labelling is consistent: the relaxation puts the mismatch where
1055
+ the weight is least (deep inside, away from the skin), unlike growing
1056
+ the labels over a tree, which leaves it wherever the fronts meet."""
1057
+ import scipy.sparse as sp
1058
+ from scipy.sparse.linalg import lobpcg
1059
+
1060
+ dims = F.shape[:3]
1061
+ nn = int(np.prod(dims))
1062
+ ids = np.arange(nn).reshape(dims)
1063
+ Ff = F.reshape(-1, 3, 3)
1064
+ wg = np.ones(nn) if weight is None else np.maximum(np.asarray(weight, float).ravel(), 1e-6)
1065
+ rows, cols, vals = [], [], []
1066
+ deg = np.zeros(nn)
1067
+ kk, ll = np.meshgrid(np.arange(3), np.arange(3), indexing="ij")
1068
+ for ax in range(3):
1069
+ sl0 = [slice(None)] * 3
1070
+ sl1 = [slice(None)] * 3
1071
+ sl0[ax] = slice(0, -1)
1072
+ sl1[ax] = slice(1, None)
1073
+ i, j = ids[tuple(sl0)].ravel(), ids[tuple(sl1)].ravel()
1074
+ w = np.sqrt(wg[i] * wg[j])
1075
+ # the block is the symmetry that matches j's axes to i's (not the
1076
+ # frames' own small turn, which the relaxation would undo by
1077
+ # labelling every frame by the nearest world axis)
1078
+ G, _ = _match_frames(Ff[i], Ff[j])
1079
+ B = w[:, None, None] * np.einsum("nik,nil->nkl", G, Ff[j]) # P^T, where G = F_j P
1080
+ r = (3 * i[:, None, None] + kk[None]).ravel()
1081
+ c_ = (3 * j[:, None, None] + ll[None]).ravel()
1082
+ rows += [r, c_]
1083
+ cols += [c_, r]
1084
+ vals += [B.ravel(), B.ravel()]
1085
+ np.add.at(deg, i, w)
1086
+ np.add.at(deg, j, w)
1087
+ S = sp.csr_matrix((np.concatenate(vals), (np.concatenate(rows), np.concatenate(cols))), shape=(3 * nn, 3 * nn))
1088
+ dm = np.repeat(1.0 / np.sqrt(np.maximum(deg, 1e-12)), 3)
1089
+ S = sp.diags(dm) @ S @ sp.diags(dm)
1090
+ # the top eigenvalue is threefold wherever the labels can agree (the
1091
+ # global rotation), which a single-vector Krylov solve finds only once:
1092
+ # a block solve (LOBPCG), started from the frames as they are labelled
1093
+ X0 = np.tile(np.eye(3), (nn, 1)) / dm[:, None]
1094
+ X0 = X0 + 1e-3 * np.random.default_rng(0).standard_normal(X0.shape) * float(np.abs(X0).mean())
1095
+ import warnings
1096
+
1097
+ with warnings.catch_warnings():
1098
+ # the rounding below needs the blocks to a few percent, not 1e-6
1099
+ warnings.simplefilter("ignore", UserWarning)
1100
+ _, V = lobpcg(S, X0, largest=True, tol=1e-4, maxiter=200)
1101
+ V = (V * dm[:, None]).reshape(nn, 3, 3) # node blocks ~ R_i Q
1102
+ # the global rotation Q: the best-weighted node keeps its labels
1103
+ Q = _polar(V[int(np.argmax(wg))])
1104
+ M = np.swapaxes(V @ Q.T, 1, 2) # rows: labels, columns: the frame's axes
1105
+ M /= np.maximum(np.linalg.norm(M, axis=(1, 2), keepdims=True), 1e-300)
1106
+ # each block rounded to the nearest signed permutation
1107
+ sc = np.stack([np.abs(M[:, 0, p[0]]) + np.abs(M[:, 1, p[1]]) + np.abs(M[:, 2, p[2]]) for p in _PERMS], -1)
1108
+ perm = _PERMS[np.argmax(sc, -1)]
1109
+ sgn = np.sign(np.take_along_axis(M, perm[:, :, None], -1)[..., 0])
1110
+ sgn[sgn == 0] = 1.0
1111
+ # R_i maps label k to the frame's axis perm[k] (sign sgn[k]): A_i = F_i R_i
1112
+ A = np.take_along_axis(Ff, np.broadcast_to(perm[:, None, :], Ff.shape), -1) * sgn[:, None, :]
1113
+ return A.reshape(dims + (3, 3))
1114
+
1115
+
1116
+ def _quiet_fill(phase, amp, smooth, low=(0.3, 0.55)):
1117
+ """The stripe pattern where it has amplitude, a smooth phase where it
1118
+ has none. The eigenvector lives where the tangent field can be followed
1119
+ (at the skin); where it cannot (deep inside, round the surface's medial
1120
+ axis) |psi| decays to round-off and its angle is noise: continuous, but
1121
+ cells a fraction of a millimetre across that neither the mesher nor the
1122
+ STEP patches resolve (the STL and STEP of a 12 mm conformal block
1123
+ differed by ~1 mm there). Below `low` the pattern's phasor is crossed
1124
+ smoothly into the least-squares potential's (smooth everywhere, no
1125
+ defects, sheared round loops), so the core holds whole, regular cells
1126
+ and the change-over is a few defects in the crossing shell. Returns
1127
+ (phase, amplitude) for _PhaseGrid."""
1128
+ s = np.clip((amp - low[0]) / (low[1] - low[0]), 0.0, 1.0)
1129
+ s = s * s * (3 - 2 * s)
1130
+ z = s * np.exp(2j * np.pi * phase) + (1 - s) * np.exp(2j * np.pi * smooth)
1131
+ return np.angle(z) / (2 * np.pi) % 1.0, np.clip(np.abs(z), 0.0, 1.0)
1132
+
1133
+
1134
+ class _PhaseGrid:
1135
+ """A phase (cycles, mod 1) on a grid, read continuously everywhere but
1136
+ at the pattern's own defects. Unwrapping the eight corners of a voxel
1137
+ against one of them (_phase_at) is exact where the phase is nearly
1138
+ linear, but two voxels that share a face unwrap against different
1139
+ corners, so where the corners spread over a large part of a cycle (near
1140
+ a defect, where the stripes bend hard, on the medial axis of the
1141
+ surface) the two sides disagree by a whole cycle times a weight: the
1142
+ lattice jumps by up to a cell's fraction at voxel faces, ~1 mm at a
1143
+ 4 mm cell. Here every corner predicts the phase at P from its own value
1144
+ and gradient (phi_i + g_i . (P - x_i)), and the predictions are blended
1145
+ as unit phasors, weighted trilinearly and by the pattern's amplitude:
1146
+ each term is continuous in P and the trilinear weights of the corners
1147
+ off a face vanish on it, so the angle of the sum is continuous across
1148
+ faces, edges and corners; it is the exact linear phase wherever the
1149
+ corners agree, and only where the sum vanishes (a defect line, where
1150
+ the stripe pattern itself has a dislocation) has it no value."""
1151
+
1152
+ def __init__(self, phase, amp, lo, h):
1153
+ self.phi = phase
1154
+ self.lo = np.asarray(lo, float)
1155
+ self.h = float(h)
1156
+ g = []
1157
+ for a in range(3):
1158
+ d = np.zeros_like(phase)
1159
+ n = phase.shape[a]
1160
+ sl = lambda i, j: tuple(slice(i, j) if b == a else slice(None) for b in range(3))
1161
+ if n >= 3:
1162
+ fwd = np.diff(phase, axis=a)
1163
+ fwd -= np.round(fwd)
1164
+ # central differences inside, one-sided at the ends
1165
+ d[sl(1, -1)] = 0.5 * (fwd[sl(0, -1)] + fwd[sl(1, None)])
1166
+ d[sl(0, 1)] = fwd[sl(0, 1)]
1167
+ d[sl(-1, None)] = fwd[sl(-1, None)]
1168
+ elif n == 2:
1169
+ fwd = np.diff(phase, axis=a)
1170
+ fwd -= np.round(fwd)
1171
+ d[:] = fwd
1172
+ g.append(d / self.h)
1173
+ self.grad = np.stack(g, -1)
1174
+ self.amp = np.asarray(amp, float)
1175
+
1176
+ def __call__(self, P, coherence=False):
1177
+ """The phase at P (cycles, not wrapped); with `coherence`, also how
1178
+ far the corners agree (|sum of weighted phasors| / sum of weights:
1179
+ 1 where the phase is linear, -> 0 at a defect)."""
1180
+ P = np.asarray(P, float)
1181
+ dims = np.asarray(self.phi.shape)
1182
+ x = np.clip((P - self.lo) / self.h, 0, dims - 1 - 1e-9)
1183
+ i0 = np.floor(x).astype(int)
1184
+ f = x - i0
1185
+ i1 = np.minimum(i0 + 1, dims - 1)
1186
+ acc = np.zeros(len(P), complex)
1187
+ wsum = np.zeros(len(P))
1188
+ base = None
1189
+ for cx in (0, 1):
1190
+ for cy in (0, 1):
1191
+ for cz in (0, 1):
1192
+ ii = ((i1 if cx else i0)[:, 0], (i1 if cy else i0)[:, 1], (i1 if cz else i0)[:, 2])
1193
+ w = ((f[:, 0] if cx else 1 - f[:, 0]) * (f[:, 1] if cy else 1 - f[:, 1])
1194
+ * (f[:, 2] if cz else 1 - f[:, 2])) * self.amp[ii]
1195
+ xi = self.lo + np.stack(ii, 1) * self.h
1196
+ pred = self.phi[ii] + np.einsum("ij,ij->i", self.grad[ii], P - xi)
1197
+ if base is None:
1198
+ base = pred
1199
+ acc += w * np.exp(2j * np.pi * (pred - base))
1200
+ wsum += w
1201
+ # the angle about the first corner's prediction: no wrap where the
1202
+ # corners agree, so the value runs on smoothly past whole cycles
1203
+ out = base + np.angle(acc) / (2 * np.pi)
1204
+ if coherence:
1205
+ return out, np.abs(acc) / np.maximum(wsum, 1e-300)
1206
+ return out
1207
+
1208
+
1209
+ def _phase_at(phi, lo, h, P):
1210
+ """Trilinear interpolation of a phase grid (cycles, mod 1): the eight
1211
+ corners are unwrapped against the first, so a phase that advances by
1212
+ less than half a cycle a step interpolates exactly where it is linear."""
1213
+ dims = np.asarray(phi.shape)
1214
+ x = np.clip((P - lo) / h, 0, dims - 1 - 1e-9)
1215
+ i0 = np.floor(x).astype(int)
1216
+ f = x - i0
1217
+ i1 = np.minimum(i0 + 1, dims - 1)
1218
+ base = phi[i0[:, 0], i0[:, 1], i0[:, 2]]
1219
+ out = np.zeros(len(P))
1220
+ for cx in (0, 1):
1221
+ for cy in (0, 1):
1222
+ for cz in (0, 1):
1223
+ v = phi[(i1 if cx else i0)[:, 0], (i1 if cy else i0)[:, 1], (i1 if cz else i0)[:, 2]] - base
1224
+ v -= np.round(v)
1225
+ out += ((f[:, 0] if cx else 1 - f[:, 0]) * (f[:, 1] if cy else 1 - f[:, 1])
1226
+ * (f[:, 2] if cz else 1 - f[:, 2])) * v
1227
+ return base + out
1228
+
1229
+
1230
+ def _axis_vector(ax):
1231
+ if isinstance(ax, str):
1232
+ return {"x": (1, 0, 0), "y": (0, 1, 0), "z": (0, 0, 1)}[ax]
1233
+ a = np.asarray(ax, float)
1234
+ return a / np.linalg.norm(a)
1235
+
1236
+
1237
+ def _frame_coords(P, frame, cell):
1238
+ """Lattice coordinates, period 1 a cell, of a cylindrical or spherical
1239
+ frame with a constant cell: whole cells round each closed direction."""
1240
+ c = float(np.asarray(cell, float).ravel()[0])
1241
+ if frame["type"] == "cylindrical":
1242
+ Q, radius = _frame_points(P, frame)
1243
+ R = radius or float(np.median(Q[:, 0]))
1244
+ n = max(1, round(2 * np.pi * R / c))
1245
+ return np.stack([Q[:, 0] / c, Q[:, 1] * n / (2 * np.pi), Q[:, 2] / c], axis=1)
1246
+ if frame["type"] == "spherical":
1247
+ Q = P - np.asarray(frame.get("center", (0, 0, 0)), float)
1248
+ r = np.linalg.norm(Q, axis=1)
1249
+ az = np.arctan2(Q[:, 1], Q[:, 0])
1250
+ pol = np.arccos(np.clip(Q[:, 2] / np.maximum(r, 1e-12), -1, 1))
1251
+ R = float(frame.get("radius", 0) or np.median(r))
1252
+ n_az = max(1, round(2 * np.pi * R / c))
1253
+ n_pol = max(1, round(np.pi * R / c))
1254
+ return np.stack([r / c, az * n_az / (2 * np.pi), pol * n_pol / np.pi], axis=1)
1255
+ raise ValueError(f"lattice frame type {frame['type']!r} has no constant-cell coordinates")
1256
+
1257
+
1258
+ # ---- smooth booleans with a field radius -----------------------------------------
1259
+
1260
+ def _smin(a, b, k):
1261
+ """Polynomial smooth minimum with a per-point radius k (0 = hard)."""
1262
+ if np.ndim(k) == 0:
1263
+ # one radius (the usual case; 0 for every hard boolean): no masks
1264
+ k = float(k)
1265
+ if k <= 1e-9:
1266
+ return np.minimum(a, b)
1267
+ h = np.clip(0.5 + 0.5 * (b - a) / k, 0.0, 1.0)
1268
+ return b * (1 - h) + a * h - k * h * (1 - h)
1269
+ k = np.broadcast_to(np.asarray(k, float), np.shape(a))
1270
+ hard = k <= 1e-9
1271
+ kk = np.where(hard, 1.0, k)
1272
+ h = np.clip(0.5 + 0.5 * (b - a) / kk, 0.0, 1.0)
1273
+ s = b * (1 - h) + a * h - kk * h * (1 - h)
1274
+ return np.where(hard, np.minimum(a, b), s)
1275
+
1276
+
1277
+ # ---- groups: reusable sub-graphs ---------------------------------------------------
1278
+
1279
+ # a material body's field keys (FieldGraph._material_bodies)
1280
+ _BODY_KEYS = ("solid", "where", "above", "below", "mix")
1281
+
1282
+ _REF_KEYS = ("input", "value", "thickness", "distance", "blend", "cell", "density", "offset")
1283
+
1284
+
1285
+ def _rename_spec(spec, names):
1286
+ """A copy of a node spec with every reference in `names` (old -> new)
1287
+ renamed: field parameters, inputs, vars, an analyzed part, expr text,
1288
+ and inline nodes inside them."""
1289
+ import re
1290
+
1291
+ s = json.loads(json.dumps(spec))
1292
+
1293
+ def ref(v):
1294
+ if isinstance(v, str):
1295
+ return names.get(v, v)
1296
+ if isinstance(v, dict) and "op" in v:
1297
+ return _rename_spec(v, names)
1298
+ return v
1299
+
1300
+ for k in _REF_KEYS:
1301
+ if k in s and not (s.get("op") == "constant" and k == "value"):
1302
+ s[k] = ref(s[k])
1303
+ if isinstance(s.get("inputs"), list):
1304
+ s["inputs"] = [ref(v) for v in s["inputs"]]
1305
+ elif s.get("op") == "group" and isinstance(s.get("inputs"), dict):
1306
+ s["inputs"] = {k: ref(v) for k, v in s["inputs"].items()}
1307
+ if isinstance(s.get("vars"), dict):
1308
+ s["vars"] = {k: ref(v) for k, v in s["vars"].items()}
1309
+ if s.get("op") == "result" and isinstance(s.get("part"), str):
1310
+ s["part"] = names.get(s["part"], s["part"])
1311
+ if s.get("op") == "lattice" and isinstance(s.get("frame"), dict) and "surface" in s["frame"]:
1312
+ s["frame"]["surface"] = ref(s["frame"]["surface"])
1313
+ if s.get("op") == "expr" and isinstance(s.get("expr"), str):
1314
+ # names the expr reads directly (not through vars) follow too, as vars so any name works
1315
+ used = {m for m in re.findall(r"[A-Za-z_]\w*", s["expr"]) if m in names}
1316
+ for m in used:
1317
+ s.setdefault("vars", {})
1318
+ if m not in s["vars"]:
1319
+ s["vars"][m] = names[m]
1320
+ return s
1321
+
1322
+
1323
+ def expand_groups(doc, _depth=0):
1324
+ """The graph with every `group` node replaced by a copy of its
1325
+ definition from `groups`: the group's nodes come in as
1326
+ <instance>__<node>, its params as constant nodes <instance>__<param>
1327
+ (the instance's own values over the definition's defaults), its inputs
1328
+ are the instance's nodes, and the group's output takes the instance's
1329
+ name, so the rest of the graph refers to it as before. Groups may use
1330
+ groups (eight deep)."""
1331
+ nodes = doc.get("nodes")
1332
+ if not isinstance(nodes, dict) or not any(isinstance(v, dict) and v.get("op") == "group" for v in nodes.values()):
1333
+ return doc
1334
+ if _depth > 8:
1335
+ raise ValueError("groups nest more than eight deep (does a group use itself?)")
1336
+ groups = doc.get("groups") or {}
1337
+ out = {}
1338
+ for name, spec in nodes.items():
1339
+ if not (isinstance(spec, dict) and spec.get("op") == "group"):
1340
+ out[name] = spec
1341
+ continue
1342
+ gname = spec.get("group")
1343
+ if gname not in groups:
1344
+ raise ValueError(f"node {name!r}: no group {gname!r} (have {', '.join(groups) or 'none'})")
1345
+ g = groups[gname]
1346
+ gnodes = g.get("nodes") or {}
1347
+ if not gnodes:
1348
+ raise ValueError(f"group {gname!r} has no nodes")
1349
+ gout = g.get("output") or list(gnodes)[-1]
1350
+ if gout not in gnodes:
1351
+ raise ValueError(f"group {gname!r}: output {gout!r} is not one of its nodes")
1352
+ params = {**(g.get("params") or {}), **(spec.get("params") or {})}
1353
+ unknown = set(spec.get("params") or {}) - set(g.get("params") or {})
1354
+ if unknown:
1355
+ raise ValueError(f"node {name!r}: group {gname!r} has no param {sorted(unknown)[0]!r} "
1356
+ f"(has {', '.join(g.get('params') or {}) or 'none'})")
1357
+ given = spec.get("inputs") or {}
1358
+ missing = [i for i in g.get("inputs") or [] if i not in given]
1359
+ if missing:
1360
+ raise ValueError(f"node {name!r}: group {gname!r} needs input {missing[0]!r}")
1361
+ extra = set(given) - set(g.get("inputs") or [])
1362
+ if extra:
1363
+ raise ValueError(f"node {name!r}: group {gname!r} has no input {sorted(extra)[0]!r}")
1364
+ names = {n: (name if n == gout else f"{name}__{n}") for n in gnodes}
1365
+ names.update({p: f"{name}__{p}" for p in params})
1366
+ names.update({i: given[i] for i in g.get("inputs") or []})
1367
+ for p, v in params.items():
1368
+ if isinstance(v, bool) or not isinstance(v, (int, float)):
1369
+ raise ValueError(f"node {name!r}: param {p!r} must be a number")
1370
+ out[f"{name}__{p}"] = {"op": "constant", "value": float(v)}
1371
+ for n, ns in gnodes.items():
1372
+ out[names[n]] = _rename_spec(ns, names)
1373
+ return expand_groups({**doc, "nodes": out}, _depth + 1)
1374
+
1375
+
1376
+ # ---- the graph -----------------------------------------------------------------
1377
+
1378
+ @dataclass
1379
+ class _Node:
1380
+ name: str
1381
+ op: str
1382
+ spec: dict
1383
+
1384
+
1385
+ class FieldGraph:
1386
+ """A graph of named field nodes. `FieldGraph(doc)` from the JSON form
1387
+ (see the module docstring and schema.FIELD_SCHEMA); `sources` names
1388
+ in-memory data a `result` or `grid` node can refer to by `source`
1389
+ (verify_part fields, a temperature array), and `meshes` in-memory
1390
+ trimeshes a `mesh` node can name by `source`.
1391
+
1392
+ graph.field() -> callable(P) of the output node
1393
+ graph.evaluate(P, "wall")
1394
+ graph.bounds() -> (lo, hi) of the output's solid
1395
+ graph.mesh() -> smooth watertight trimesh (field_mesh)"""
1396
+
1397
+ def __init__(self, doc, base_dir=None, sources=None, meshes=None):
1398
+ if isinstance(doc, str):
1399
+ doc = json.loads(doc)
1400
+ if not isinstance(doc, dict) or not isinstance(doc.get("nodes"), dict) or not doc["nodes"]:
1401
+ raise ValueError("a field graph needs a non-empty 'nodes' object")
1402
+ doc = expand_groups(doc)
1403
+ self.doc = doc
1404
+ self.base_dir = base_dir
1405
+ self.sources = dict(sources or {})
1406
+ self.meshes = dict(meshes or {})
1407
+ self.nodes: dict[str, _Node] = {}
1408
+ self._anon = 0
1409
+ for name, spec in doc["nodes"].items():
1410
+ if name in ("x", "y", "z") or name in _CONSTS or name in _FUNCS:
1411
+ raise ValueError(f"node name {name!r} is reserved (x, y, z, pi, e and the expr functions)")
1412
+ self._add(name, spec)
1413
+ self.output = doc.get("output") or list(doc["nodes"])[-1]
1414
+ if self.output not in self.nodes:
1415
+ raise ValueError(f"output {self.output!r} is not a node (have {', '.join(doc['nodes'])})")
1416
+ self.part_node = self.output # the output as written (drains move `output` on)
1417
+ self._built = {}
1418
+ self.plan = None # the sharp CSG plan of the last mesh(), when it had one
1419
+ self.analyses = {} # verify reports behind `result` nodes that ran a study
1420
+ self.bodies = self._material_bodies(doc.get("materials"))
1421
+ self._check_refs()
1422
+
1423
+ # -- construction --
1424
+
1425
+ def _add(self, name, spec):
1426
+ if not isinstance(spec, dict) or "op" not in spec:
1427
+ raise ValueError(f"node {name!r} must be an object with an 'op' ({', '.join(OPS)})")
1428
+ if spec["op"] not in OPS:
1429
+ import difflib
1430
+
1431
+ near = difflib.get_close_matches(str(spec["op"]), OPS, 1)
1432
+ raise ValueError(f"node {name!r}: unknown op {spec['op']!r}"
1433
+ + (f"; did you mean {near[0]!r}?" if near else f" ({', '.join(OPS)})"))
1434
+ self.nodes[name] = _Node(name, spec["op"], spec)
1435
+ return name
1436
+
1437
+ def _ref(self, v, owner, key):
1438
+ """A parameter: number, node name, or inline node -> node name or float."""
1439
+ if isinstance(v, bool):
1440
+ raise ValueError(f"node {owner!r}: {key} must be a number or a field, got {v!r}")
1441
+ if isinstance(v, (int, float)):
1442
+ return float(v)
1443
+ if isinstance(v, str):
1444
+ if v not in self.nodes:
1445
+ raise ValueError(f"node {owner!r}: {key} refers to {v!r}, which is not a node")
1446
+ return v
1447
+ if isinstance(v, dict):
1448
+ self._anon += 1
1449
+ return self._add(f"{owner}.{key}#{self._anon}", v)
1450
+ raise ValueError(f"node {owner!r}: {key} must be a number, a node name or a node object, got {v!r}")
1451
+
1452
+ def _check_refs(self):
1453
+ # resolve every reference once (inline nodes get names) and refuse cycles
1454
+ self.deps = {}
1455
+ names = list(self.nodes)
1456
+ i = 0
1457
+ while i < len(names):
1458
+ nd = self.nodes[names[i]]
1459
+ self.deps[nd.name] = self._deps(nd)
1460
+ names += [n for n in self.deps[nd.name] if n not in names]
1461
+ i += 1
1462
+ state = {}
1463
+
1464
+ def visit(n, path):
1465
+ if state.get(n) == 1:
1466
+ raise ValueError("field graph has a cycle: " + " -> ".join(path + [n]))
1467
+ if state.get(n) == 2:
1468
+ return
1469
+ state[n] = 1
1470
+ for d in self.deps[n]:
1471
+ visit(d, path + [n])
1472
+ state[n] = 2
1473
+
1474
+ for n in list(self.nodes):
1475
+ visit(n, [])
1476
+
1477
+ _FIELD_KEYS = ("input", "value", "thickness", "distance", "blend", "cell", "density", "offset")
1478
+
1479
+ def _material_bodies(self, spec):
1480
+ """The graph's `materials`: [{name, material, solid, where, above,
1481
+ below, mix, extruder, color}], each field key resolved to a node name
1482
+ or a number (inline nodes get names materials.<body>.<key>#n), in
1483
+ precedence order (see material_bodies)."""
1484
+ if spec is None:
1485
+ return []
1486
+ if not isinstance(spec, list) or not spec:
1487
+ raise ValueError("materials must be a non-empty list of bodies")
1488
+ out, seen = [], set()
1489
+ for i, b in enumerate(spec):
1490
+ if not isinstance(b, dict):
1491
+ raise ValueError(f"materials[{i}] must be an object")
1492
+ name = b.get("name")
1493
+ if not isinstance(name, str) or not name.strip():
1494
+ raise ValueError(f"materials[{i}] needs a 'name'")
1495
+ if name in seen:
1496
+ raise ValueError(f"materials: two bodies are called {name!r}")
1497
+ seen.add(name)
1498
+ mat = b.get("material")
1499
+ if "mix" in b:
1500
+ if not (isinstance(mat, list) and len(mat) == 2):
1501
+ raise ValueError(f"material body {name!r}: a mix body's material is a pair [A, B] "
1502
+ "(mix 0 is all A, 1 all B)")
1503
+ elif mat is None or isinstance(mat, list):
1504
+ raise ValueError(f"material body {name!r}: needs one 'material' (a pair only with 'mix')")
1505
+ if "where" in b and "above" not in b and "below" not in b:
1506
+ raise ValueError(f"material body {name!r}: 'where' needs 'above' and/or 'below'")
1507
+ if ("above" in b or "below" in b) and "where" not in b:
1508
+ raise ValueError(f"material body {name!r}: 'above'/'below' threshold the 'where' field; "
1509
+ "give one")
1510
+ owner = f"materials.{name}"
1511
+ body = {"name": name, "material": mat, "index": i}
1512
+ for key in _BODY_KEYS:
1513
+ if key in b:
1514
+ body[key] = self._ref(b[key], owner, key)
1515
+ for key in ("extruder", "color", "voxel"):
1516
+ if key in b:
1517
+ body[key] = b[key]
1518
+ out.append(body)
1519
+ return out
1520
+
1521
+ def _deps(self, nd):
1522
+ s = nd.spec
1523
+ refs = {}
1524
+ for key in self._FIELD_KEYS:
1525
+ if key in s and not (nd.op == "constant" and key == "value"):
1526
+ refs[key] = self._ref(s[key], nd.name, key)
1527
+ if "inputs" in s:
1528
+ if not isinstance(s["inputs"], list) or not s["inputs"]:
1529
+ raise ValueError(f"node {nd.name!r}: inputs must be a non-empty list")
1530
+ refs["inputs"] = [self._ref(v, nd.name, f"inputs[{i}]") for i, v in enumerate(s["inputs"])]
1531
+ if nd.op == "lattice" and isinstance(s.get("frame"), dict) and s["frame"].get("type") == "conformal":
1532
+ if "surface" not in s["frame"]:
1533
+ raise ValueError(f"node {nd.name!r}: a conformal frame needs 'surface' (the solid it follows)")
1534
+ refs["surface"] = self._ref(s["frame"]["surface"], nd.name, "frame.surface")
1535
+ if isinstance(refs["surface"], float):
1536
+ raise ValueError(f"node {nd.name!r}: frame.surface must be a solid node")
1537
+ if nd.op == "expr":
1538
+ if not isinstance(s.get("expr"), str):
1539
+ raise ValueError(f"node {nd.name!r}: expr needs an 'expr' string")
1540
+ used, fn = _compile_expr(s["expr"])
1541
+ shadow = [k for k in (s.get("vars") or {}) if k in ("x", "y", "z") or k in _CONSTS or k in _FUNCS]
1542
+ if shadow:
1543
+ # the expr would read the constant or coordinate, never the var
1544
+ raise ValueError(f"node {nd.name!r}: var name {shadow[0]!r} is reserved (x, y, z, pi, e and the "
1545
+ f"expr functions); rename it")
1546
+ local = {k: self._ref(v, nd.name, f"vars.{k}") for k, v in (s.get("vars") or {}).items()}
1547
+ for u in used - set(local) - {"x", "y", "z"}:
1548
+ if u not in self.nodes:
1549
+ raise ValueError(f"node {nd.name!r}: expr reads {u!r}, which is not a node, a var or x/y/z")
1550
+ local[u] = u
1551
+ refs["vars"] = local
1552
+ refs["fn"] = fn
1553
+ nd.refs = refs
1554
+ out = []
1555
+ if nd.op == "result" and s.get("part") in self.nodes:
1556
+ out.append(s["part"]) # analyzed from this graph: a dependency too
1557
+ for v in refs.values():
1558
+ for x in (v if isinstance(v, list) else v.values() if isinstance(v, dict) else [v]):
1559
+ if isinstance(x, str):
1560
+ out.append(x)
1561
+ return out
1562
+
1563
+ # -- evaluation --
1564
+
1565
+ def field(self, name=None):
1566
+ name = name or self.output
1567
+
1568
+ def f(P):
1569
+ out = self.evaluate(P, name)
1570
+ if not hasattr(f, "chunk"):
1571
+ # compiled (fieldjit): no big temporaries per point, so the
1572
+ # mesher's threads can split even a small batch (most calls
1573
+ # are 50-500k points, which numpy's 250k chunks run serially)
1574
+ f.chunk = _JIT_CHUNK if self.__dict__.get("_jit", {}).get(name) is not None else None
1575
+ return out
1576
+ if self._torch_field() is not None and self._tf.batch:
1577
+ f.batch = self._tf.batch
1578
+ return f
1579
+
1580
+ def evaluate(self, P, name=None):
1581
+ tf = self._torch_field()
1582
+ if tf is not None:
1583
+ return tf(P, name)
1584
+ P = np.asarray(P, float).reshape(-1, 3)
1585
+ cf = self._compiled(name or self.output)
1586
+ if cf is not None:
1587
+ return cf(P)
1588
+ memo = {}
1589
+ return np.broadcast_to(self._eval(name or self.output, P, memo), (len(P),)).astype(float)
1590
+
1591
+ def _compiled(self, name):
1592
+ """fieldjit.CompiledField for the node (the whole graph under it as
1593
+ one compiled pass), or None: numba missing, LOADPATH_FIELDJIT=0, or
1594
+ the node itself does not compile."""
1595
+ from . import fieldjit
1596
+
1597
+ if not fieldjit.available():
1598
+ return None
1599
+ cache = self.__dict__.setdefault("_jit", {})
1600
+ if name in cache:
1601
+ return cache[name]
1602
+ with self.__dict__.setdefault("_jit_lock", threading.RLock()):
1603
+ if name not in cache:
1604
+ cache[name] = fieldjit.compile_graph(self, name)
1605
+ return cache[name]
1606
+
1607
+ def _torch_field(self):
1608
+ """fieldtorch.TorchField on the device LOADPATH_FIELD_DEVICE picks
1609
+ (a GPU by default when there is one), or None for numpy."""
1610
+ if not hasattr(self, "_tf"):
1611
+ from .fieldtorch import TorchField, field_device
1612
+
1613
+ dev = field_device()
1614
+ self._tf = TorchField(self, dev) if dev is not None else None
1615
+ return self._tf
1616
+
1617
+ def _val(self, r, P, memo):
1618
+ if isinstance(r, float):
1619
+ return r
1620
+ return self._eval(r, P, memo)
1621
+
1622
+ def _eval(self, name, P, memo):
1623
+ key = (name, id(P))
1624
+ if key in memo:
1625
+ return memo[key][1]
1626
+ out = self._compute(self.nodes[name], P, memo)
1627
+ memo[key] = (P, out) # keep P alive so its id stays unique
1628
+ return out
1629
+
1630
+ def seed(self, prebuilt):
1631
+ """Use resources built by another graph of the same nodes (an
1632
+ analysis, a meshed field, a loaded grid): {name: (resource,
1633
+ analysis or None)}. The caller vouches that each node and what it
1634
+ depends on are unchanged; the editor keys them by the node's closure."""
1635
+ for name, (res, analysis) in (prebuilt or {}).items():
1636
+ if name in self.nodes:
1637
+ self._built[name] = res
1638
+ if analysis is not None:
1639
+ self.analyses[name] = analysis
1640
+ return self
1641
+
1642
+ def _resource(self, nd, make):
1643
+ if nd.name not in self._built:
1644
+ self._built[nd.name] = make()
1645
+ return self._built[nd.name]
1646
+
1647
+ def _path(self, path):
1648
+ if self.base_dir is None:
1649
+ return path
1650
+ root = os.path.realpath(self.base_dir)
1651
+ full = os.path.realpath(os.path.join(root, path))
1652
+ if os.path.commonpath([root, full]) != root:
1653
+ raise ValueError(f"path {path!r} must stay inside the graph's folder")
1654
+ if not os.path.exists(full):
1655
+ raise ValueError(f"no file {path!r} in {root} (paths resolve from the graph's folder; "
1656
+ "--base-dir picks another)")
1657
+ return full
1658
+
1659
+ def _compute(self, nd, P, memo):
1660
+ s, r, op = nd.spec, nd.refs, nd.op
1661
+ v = lambda key, default=None: self._val(r[key], P, memo) if key in r else default # noqa: E731
1662
+ if op == "constant":
1663
+ return float(s["value"])
1664
+ if op == "coordinate":
1665
+ ax = s.get("axis", "z")
1666
+ if isinstance(ax, str):
1667
+ return P[:, "xyz".index(ax)].copy()
1668
+ a = np.asarray(ax, float)
1669
+ a = a / np.linalg.norm(a)
1670
+ return (P - np.asarray(s.get("origin", (0, 0, 0)), float)) @ a
1671
+ if op == "distance":
1672
+ return self._distance(nd, P, memo)
1673
+ if op == "shape":
1674
+ from .shapes import shape_from_spec
1675
+
1676
+ if s["shape"].get("type") == "mesh":
1677
+ raise ValueError(f"node {nd.name!r}: use op 'mesh' for meshes (exact surface distance)")
1678
+ shp = self._resource(nd, lambda: shape_from_spec(s["shape"], self.base_dir))
1679
+ return shp.sdf(P)
1680
+ if op == "mesh":
1681
+ mf = self._resource(nd, lambda: self._mesh_field(nd))
1682
+ return mf(P)
1683
+ if op in ("result", "grid", "image"):
1684
+ smp = self._resource(nd, lambda: self._samples(nd))
1685
+ return smp(P)
1686
+ if op == "remap":
1687
+ return _remap(v("input"), s, nd.name)
1688
+ if op == "expr":
1689
+ env = {"x": P[:, 0], "y": P[:, 1], "z": P[:, 2]}
1690
+ for k, ref in r["vars"].items():
1691
+ env[k] = self._val(ref, P, memo)
1692
+ return np.asarray(r["fn"](env), float)
1693
+ if op in ("union", "intersect", "subtract"):
1694
+ vals = [self._val(x, P, memo) for x in r["inputs"]]
1695
+ k = v("blend", 0.0)
1696
+ if op == "union":
1697
+ out = vals[0]
1698
+ for b in vals[1:]:
1699
+ out = _smin(out, b, k)
1700
+ return out
1701
+ if op == "intersect":
1702
+ out = vals[0]
1703
+ for b in vals[1:]:
1704
+ out = -_smin(-out, -b, k)
1705
+ return out
1706
+ out = vals[0]
1707
+ for b in vals[1:]:
1708
+ out = -_smin(-out, b, k)
1709
+ return out
1710
+ if op == "shell":
1711
+ f = v("input")
1712
+ t = np.asarray(v("thickness"), float)
1713
+ side = s.get("side", "inside")
1714
+ if side == "inside":
1715
+ return np.maximum(f, -(f + t))
1716
+ if side == "outside":
1717
+ return np.maximum(-f, f - t)
1718
+ if side == "center":
1719
+ return np.abs(f) - 0.5 * t
1720
+ raise ValueError(f"node {nd.name!r}: shell side must be inside, outside or center")
1721
+ if op == "offset":
1722
+ return v("input") - v("distance")
1723
+ if op == "lattice":
1724
+ return self._lattice(nd, P, memo)
1725
+ if op == "transform":
1726
+ return self._transform(nd, P, memo)
1727
+ raise AssertionError(op)
1728
+
1729
+ def _distance(self, nd, P, memo):
1730
+ s = nd.spec
1731
+ if "input" in nd.refs:
1732
+ d = np.abs(self._val(nd.refs["input"], P, memo))
1733
+ elif "points" in s:
1734
+ pts = np.asarray(s["points"], float).reshape(-1, 3)
1735
+ from scipy.spatial import cKDTree
1736
+
1737
+ d = cKDTree(pts).query(P)[0]
1738
+ elif "segment" in s:
1739
+ a, b = (np.asarray(x, float) for x in s["segment"])
1740
+ ab = b - a
1741
+ t = np.clip(((P - a) @ ab) / max(ab @ ab, 1e-300), 0.0, 1.0)
1742
+ d = np.linalg.norm(P - (a + t[:, None] * ab), axis=1)
1743
+ elif "plane" in s:
1744
+ n = np.asarray(s["plane"]["normal"], float)
1745
+ n = n / np.linalg.norm(n)
1746
+ d = np.abs((P - np.asarray(s["plane"]["point"], float)) @ n)
1747
+ elif "shape" in s:
1748
+ from .shapes import shape_from_spec
1749
+
1750
+ shp = self._resource(nd, lambda: shape_from_spec(s["shape"], self.base_dir))
1751
+ d = np.abs(shp.sdf(P))
1752
+ else:
1753
+ raise ValueError(f"node {nd.name!r}: distance needs input, points, segment, plane or shape")
1754
+ if s.get("signed") and "input" in nd.refs:
1755
+ d = self._val(nd.refs["input"], P, memo)
1756
+ return d
1757
+
1758
+ def _mesh_field(self, nd):
1759
+ import trimesh
1760
+
1761
+ s = nd.spec
1762
+ if "source" in s:
1763
+ if s["source"] not in self.meshes:
1764
+ raise ValueError(f"node {nd.name!r}: no in-memory mesh named {s['source']!r}")
1765
+ mesh = self.meshes[s["source"]]
1766
+ else:
1767
+ if "path" not in s:
1768
+ raise ValueError(f"node {nd.name!r}: mesh needs a 'path' (STL/3MF/OBJ) or a 'source'")
1769
+ mesh = trimesh.load(self._path(s["path"]), force="mesh")
1770
+ return MeshField(mesh, pitch=s.get("pitch"), band=s.get("band"))
1771
+
1772
+ _PLANES = {"xy": (0, 1, 2), "xz": (0, 2, 1), "yz": (1, 2, 0)}
1773
+
1774
+ def _image(self, nd):
1775
+ """A picture as a scalar field: `path` (PNG, JPEG, ...) laid on
1776
+ `plane` (xy, xz or yz) with its lower-left corner at `origin` and
1777
+ `size` [w, h] mm, and carried straight through along the third axis.
1778
+ Values are the `channel` (luma, r, g, b or a) from 0 to 1, `invert`
1779
+ flips them; bilinear between pixels, the edge pixel carries on past
1780
+ the picture. Pictures over 1024 pixels a side are resized to it."""
1781
+ from PIL import Image
1782
+
1783
+ s = nd.spec
1784
+ if "path" not in s or "size" not in s:
1785
+ raise ValueError(f"node {nd.name!r}: an image needs a 'path' and a 'size' [w, h] in mm")
1786
+ plane = s.get("plane", "xy")
1787
+ if plane not in self._PLANES:
1788
+ raise ValueError(f"node {nd.name!r}: plane must be xy, xz or yz")
1789
+ im = Image.open(self._path(s["path"]))
1790
+ im.load()
1791
+ im = im.convert("RGBA")
1792
+ if max(im.size) > 1024:
1793
+ k = 1024 / max(im.size)
1794
+ im = im.resize((max(2, round(im.size[0] * k)), max(2, round(im.size[1] * k))), Image.BOX)
1795
+ a = np.asarray(im, float) / 255.0 # rows top to bottom
1796
+ ch = s.get("channel", "luma")
1797
+ if ch == "luma":
1798
+ v = a[..., 0] * 0.2126 + a[..., 1] * 0.7152 + a[..., 2] * 0.0722
1799
+ elif ch in ("r", "g", "b", "a"):
1800
+ v = a[..., "rgba".index(ch)]
1801
+ else:
1802
+ raise ValueError(f"node {nd.name!r}: channel must be luma, r, g, b or a")
1803
+ if s.get("invert"):
1804
+ v = 1.0 - v
1805
+ v = v[::-1].T # [u, v]: u right, v up
1806
+ H, W = v.shape[1], v.shape[0]
1807
+ w, h = (float(x) for x in s["size"])
1808
+ if w <= 0 or h <= 0:
1809
+ raise ValueError(f"node {nd.name!r}: size must be positive")
1810
+ u0, v0 = (float(x) for x in s.get("origin", (0.0, 0.0)))
1811
+ iu, iv, ia = self._PLANES[plane]
1812
+ # a regular grid in world axes, two samples deep along the projection axis (the same everywhere)
1813
+ grid = np.repeat(v[:, :, None], 2, axis=2)
1814
+ order = np.argsort([iu, iv, ia])
1815
+ grid = np.transpose(grid, order)
1816
+ origin, pitch = np.zeros(3), np.zeros(3)
1817
+ origin[iu], pitch[iu] = u0 + 0.5 * w / W, w / W
1818
+ origin[iv], pitch[iv] = v0 + 0.5 * h / H, h / H
1819
+ origin[ia], pitch[ia] = -5e5, 1e6
1820
+ return _Samples(grid=grid, origin=origin, pitch=pitch)
1821
+
1822
+ def _samples(self, nd):
1823
+ s = nd.spec
1824
+ if nd.op == "image":
1825
+ return self._image(nd)
1826
+ if "source" in s:
1827
+ if s["source"] not in self.sources:
1828
+ raise ValueError(f"node {nd.name!r}: no in-memory source named {s['source']!r}")
1829
+ data = self.sources[s["source"]]
1830
+ elif "study" in s:
1831
+ data = self._analyze(nd)
1832
+ elif "path" in s:
1833
+ p = self._path(s["path"])
1834
+ data = dict(np.load(p)) if p.endswith(".npz") else json.load(open(p))
1835
+ else:
1836
+ raise ValueError(f"node {nd.name!r}: {nd.op} needs a 'path' (.npz or .json), a 'study' and "
1837
+ "'part' to analyze, or a 'source'")
1838
+ if isinstance(data, dict) and "cases" in data and "nodes" in data: # verify_part fields
1839
+ pts, vals = result_samples(data, s.get("quantity", "vonMises"), s.get("case"))
1840
+ return _Samples(pts, self._scaled(nd, vals))
1841
+ if isinstance(data, dict) and "grid" in data or nd.op == "grid":
1842
+ key = s.get("quantity", "values")
1843
+ if "origin" not in data or "pitch" not in data:
1844
+ raise ValueError(f"node {nd.name!r}: a grid needs origin, pitch and a 3D '{key}' array")
1845
+ return _Samples(grid=data.get("grid", data.get(key)), origin=data["origin"], pitch=data["pitch"])
1846
+ key = s.get("quantity", "values")
1847
+ if key not in data:
1848
+ raise ValueError(f"node {nd.name!r}: no {key!r} in the result (have {', '.join(data)})")
1849
+ return _Samples(data["points"], self._scaled(nd, np.asarray(data[key], float)))
1850
+
1851
+ @staticmethod
1852
+ def _scaled(nd, vals):
1853
+ """`scale`: a number to divide by, or "peak" for the 99.5th
1854
+ percentile of the samples, so a remap can be written in fractions of
1855
+ the part's own worst stress without knowing it in advance (1.0 = the
1856
+ peak, ignoring the few hottest nodes at a load or support)."""
1857
+ sc = nd.spec.get("scale")
1858
+ if sc is None:
1859
+ return vals
1860
+ if sc == "peak":
1861
+ sc = float(np.percentile(vals, 99.5))
1862
+ if not float(sc) > 0:
1863
+ raise ValueError(f"node {nd.name!r}: scale must be positive or 'peak'")
1864
+ return vals / float(sc)
1865
+
1866
+ def _analyze(self, nd):
1867
+ """verify_part fields of `part` under `study`: the stress a part
1868
+ actually carries, every load case. `study` is a JSON path or
1869
+ "example:<name>" (a packaged example); `part` a mesh path or the name
1870
+ of a node of this graph, meshed for the analysis (at `pitch`, default
1871
+ 1 mm), so a graph can grade itself from its own solid's stress."""
1872
+ import trimesh
1873
+
1874
+ from .verify import verify_part
1875
+
1876
+ s = nd.spec
1877
+ if "part" not in s:
1878
+ raise ValueError(f"node {nd.name!r}: a result from a study needs the 'part' it analyzes")
1879
+ ref = s["study"] if s["study"].startswith("example:") else self._path(s["study"])
1880
+ study = load_study(ref, self.base_dir, self.doc.get("material"))
1881
+ if s["part"] in self.nodes:
1882
+ mesh = self.mesh(pitch=float(s.get("pitch", 1.0)), name=s["part"], project=1)
1883
+ else:
1884
+ mesh = trimesh.load(self._path(s["part"]), force="mesh")
1885
+ from . import quick
1886
+
1887
+ rep = verify_part(study, mesh, max_cells=quick.result_cells(int(s.get("cells", 120_000))), fields=True,
1888
+ refine=quick.result_refine(3))
1889
+ self.analyses[nd.name] = {k: v for k, v in rep.items() if k != "fields"}
1890
+ return rep["fields"]
1891
+
1892
+ def _lattice(self, nd, P, memo):
1893
+ s, r = nd.spec, nd.refs
1894
+ if isinstance(s.get("orient"), dict) and "zones" in s["orient"]:
1895
+ return self._lattice_zones(nd, P, memo)
1896
+ kind = s.get("type", "gyroid")
1897
+ from .struts import canonical, is_strut
1898
+
1899
+ frame = s.get("frame") or {}
1900
+ ftype = frame.get("type", "cartesian")
1901
+ if is_strut(kind) or ftype in ("conformal", "spherical") or "aspect" in s:
1902
+ return self._lattice_general(nd, P, memo, canonical(kind) if is_strut(kind) else kind)
1903
+ cell = self._val(r["cell"], P, memo) if "cell" in r else 8.0
1904
+ Q = P
1905
+ if "origin" in s:
1906
+ Q = P - np.asarray(s["origin"], float)
1907
+ R = lattice_orientation(nd)
1908
+ if R is not None:
1909
+ Q = Q @ R
1910
+ if isinstance(r.get("cell"), str) and s.get("grading", "warp") == "warp":
1911
+ from .lattice import tpms
1912
+
1913
+ if s.get("frame"):
1914
+ raise ValueError(f"node {nd.name!r}: a graded cell (warp) works in the cartesian frame only; "
1915
+ "use grading: 'blend' with a cylindrical frame")
1916
+ warp = self._resource(nd, lambda: self._warp(nd))
1917
+ d = tpms(_TPMS_KIND[kind], warp(Q, cell), 1.0) * np.asarray(cell, float)
1918
+ else:
1919
+ d = _tpms_distance(kind, Q, cell, s.get("frame"))
1920
+ if "offset" in r:
1921
+ d = d - self._val(r["offset"], P, memo)
1922
+ if "thickness" in r:
1923
+ t = np.asarray(self._val(r["thickness"], P, memo), float)
1924
+ elif "density" in r:
1925
+ from .lattice import wall_for_density
1926
+
1927
+ rho = np.clip(np.asarray(self._val(r["density"], P, memo), float), 0.05, 0.95)
1928
+ rho, cl = np.broadcast_arrays(rho, np.asarray(cell, float))
1929
+ t = wall_for_density(_TPMS_KIND[kind], rho, cl)
1930
+ else:
1931
+ t = 1.0
1932
+ form = s.get("form", "sheet")
1933
+ if form == "sheet":
1934
+ return np.abs(d) - 0.5 * t
1935
+ if form in ("network", "_plus"):
1936
+ return d - 0.5 * t
1937
+ if form == "_minus": # internal (latticestep): the sheet's other side, d < -t/2 empty
1938
+ return -d - 0.5 * t
1939
+ raise ValueError(f"node {nd.name!r}: lattice form must be 'sheet' or 'network'")
1940
+
1941
+ def _lattice_zones(self, nd, P, memo):
1942
+ """A lattice whose cells turn from zone to zone (orient.zones): each
1943
+ zone's lattice, turned by its own rotation; where zones meet (their
1944
+ weights, orient_weights) both lattices are there, each worn away
1945
+ as its share falls."""
1946
+ import copy as _copy
1947
+
1948
+ zones = nd.spec["orient"]["zones"]
1949
+ seam = float(nd.spec["orient"].get("seam") or 0.0)
1950
+ plate = None
1951
+ if seam > 0:
1952
+ # a solid plate `seam` mm thick on each face between zones: the
1953
+ # cells of both zones end on it the way they end on a skin.
1954
+ # Without one, two directions of thin struts meet at a few small
1955
+ # joints, and that seam read 1.5-4x too stiff (lattice_seams bench)
1956
+ plate = seam_distance([z["at"] for z in zones], P) - 0.5 * seam
1957
+ if nd.spec.get("_seamOnly"):
1958
+ return plate
1959
+ w = orient_weights(nd, P)
1960
+ wmax = w.max(axis=1)
1961
+ cell = nd.spec.get("cell", 8.0)
1962
+ reach = 0.5 * float(cell if isinstance(cell, (int, float)) else 8.0)
1963
+ out = np.full(len(P), np.inf)
1964
+ for i, z in enumerate(zones):
1965
+ share = w[:, i] / wmax
1966
+ near = share > 0.05
1967
+ if not near.any():
1968
+ continue
1969
+ sub = _copy.copy(nd)
1970
+ sub.spec = {**nd.spec, "orient": list(z.get("rotate", (0.0, 0.0, 0.0)))}
1971
+ # each zone's lattice, worn away (by up to half a cell) as its
1972
+ # share falls: where two zones meet both are there (a union, so
1973
+ # the struts on either side stay joined), away from it only one.
1974
+ # Averaging the two fields instead left gaps in the seam
1975
+ f = np.asarray(self._lattice(sub, P, memo), float)
1976
+ out = np.minimum(out, np.where(near, f + reach * (1.0 - share), np.inf))
1977
+ return out if plate is None else np.minimum(out, plate)
1978
+
1979
+ def lattice_coords(self, nd, P, memo=None):
1980
+ """(U, cell): a lattice node's coordinates at P (period 1 a cell)
1981
+ and its local cell (mm, per point), whatever its frame and grading.
1982
+ Voronoi foams have no coordinates (None)."""
1983
+ memo = {} if memo is None else memo
1984
+ s, r = nd.spec, nd.refs
1985
+ cell = self._val(r["cell"], P, memo) if "cell" in r else 8.0
1986
+ cell = np.broadcast_to(np.asarray(cell, float), (len(P),))
1987
+ Q = P - np.asarray(s["origin"], float) if "origin" in s else P
1988
+ R = lattice_orientation(nd)
1989
+ if R is not None:
1990
+ Q = Q @ R # the cell's axes are R's columns
1991
+ frame = s.get("frame") or {}
1992
+ ftype = frame.get("type", "cartesian")
1993
+ aspect = np.asarray(s.get("aspect", (1, 1, 1)), float)
1994
+ if ftype == "conformal":
1995
+ cmap = self._resource(nd, lambda: self._conformal(nd))
1996
+ U, coh = cmap(Q, cell, surface=self._val(r["surface"], P, memo), coherence=True)
1997
+ memo[("_defect_fill", nd.name)] = _ConformalMap.defect_fill(coh)
1998
+ elif ftype in ("cylindrical", "spherical"):
1999
+ if isinstance(r.get("cell"), str):
2000
+ raise ValueError(f"node {nd.name!r}: a {ftype} frame takes a constant cell; grade the struts' "
2001
+ "thickness instead, or use a conformal frame")
2002
+ U = _frame_coords(Q, frame, cell[:1])
2003
+ elif isinstance(r.get("cell"), str):
2004
+ warp = self._resource(nd, lambda: self._warp(nd))
2005
+ U = warp(Q, cell)
2006
+ else:
2007
+ U = Q / cell[:, None]
2008
+ if "aspect" in s:
2009
+ U = U / aspect
2010
+ return U, cell
2011
+
2012
+ def _lattice_general(self, nd, P, memo, kind):
2013
+ """Strut lattices (any frame), and TPMS in the conformal and
2014
+ spherical frames or with a stretched cell: distance in cell units
2015
+ through lattice_coords, scaled back to mm by the local cell."""
2016
+ from . import struts
2017
+
2018
+ s, r = nd.spec, nd.refs
2019
+ strut = struts.is_strut(kind)
2020
+ if not strut and _TPMS_KIND.get(kind) is None:
2021
+ raise ValueError(f"node {nd.name!r}: lattice type must be a TPMS ({', '.join(sorted(set(_TPMS_KIND.values())))})"
2022
+ f" or a strut cell ({', '.join(struts.STRUT_KINDS)})")
2023
+ form = s.get("form", "beam" if strut else "sheet")
2024
+ if strut and form != "beam":
2025
+ raise ValueError(f"node {nd.name!r}: a strut lattice's form is 'beam' (got {form!r})")
2026
+ blend = np.asarray(self._val(r["blend"], P, memo), float) if "blend" in r else 0.0
2027
+ if kind == "voronoi":
2028
+ if (s.get("frame") or {}).get("type", "cartesian") != "cartesian" or "aspect" in s or "orient" in s:
2029
+ raise ValueError(f"node {nd.name!r}: a voronoi foam is cartesian (no frame, aspect or orient)")
2030
+ vor = self._resource(nd, lambda: self._voronoi(nd))
2031
+ Q = P - np.asarray(s["origin"], float) if "origin" in s else P
2032
+ cell = np.broadcast_to(np.asarray(self._val(r["cell"], P, memo) if "cell" in r else 8.0, float),
2033
+ (len(P),))
2034
+ d_c = vor.distance(Q, blend) # mm
2035
+ scale = 1.0
2036
+ else:
2037
+ U, cell = self.lattice_coords(nd, P, memo)
2038
+ if strut:
2039
+ aspect = np.asarray(s.get("aspect", (1, 1, 1)), float) if "aspect" in s else None
2040
+ d_c = struts.unit_cell_distance(kind, U, np.asarray(blend, float) / cell, aspect=aspect)
2041
+ else:
2042
+ from .lattice import tpms
2043
+
2044
+ d_c = tpms(_TPMS_KIND[kind], U, 1.0)
2045
+ if "aspect" in s:
2046
+ d_c = d_c * float(np.min(s["aspect"]))
2047
+ fill = memo.pop(("_defect_fill", nd.name), None)
2048
+ if fill is not None:
2049
+ d_c = d_c * fill
2050
+ scale = cell
2051
+ d = d_c * scale
2052
+ if "offset" in r:
2053
+ d = d - self._val(r["offset"], P, memo)
2054
+ bl = float(np.mean(blend)) if np.ndim(blend) else float(blend)
2055
+ if "thickness" in r:
2056
+ t = np.asarray(self._val(r["thickness"], P, memo), float)
2057
+ elif "density" in r:
2058
+ rho = np.clip(np.asarray(self._val(r["density"], P, memo), float), 0.02 if strut else 0.05, 0.95)
2059
+ rho, cl = np.broadcast_arrays(rho, np.asarray(cell, float))
2060
+ if strut:
2061
+ t = struts.strut_diameter_for_density(kind, rho, cl, blend=bl / float(np.mean(cl)))
2062
+ else:
2063
+ from .lattice import wall_for_density
2064
+
2065
+ t = wall_for_density(_TPMS_KIND[kind], rho, cl)
2066
+ else:
2067
+ t = 1.0
2068
+ if strut:
2069
+ return d - 0.5 * t
2070
+ if form == "sheet":
2071
+ return np.abs(d) - 0.5 * t
2072
+ if form in ("network", "_plus"):
2073
+ return d - 0.5 * t
2074
+ if form == "_minus": # internal (latticestep): the sheet's other side, d < -t/2 empty
2075
+ return -d - 0.5 * t
2076
+ raise ValueError(f"node {nd.name!r}: lattice form must be 'sheet' or 'network'")
2077
+
2078
+ def _conformal(self, nd, max_cells=40_000):
2079
+ """The surface-following map over the lattice's domain."""
2080
+ frame = nd.spec["frame"]
2081
+ cell_fn, lo, hi = self._warp_domain(nd)
2082
+ off = np.asarray(nd.spec.get("origin", (0, 0, 0)), float)
2083
+ surf = nd.refs["surface"]
2084
+ mode = frame.get("frameField", "auto")
2085
+
2086
+ def build(ff):
2087
+ return _ConformalMap(lambda G: self.evaluate(G + off, surf), cell_fn, lo, hi, axis=frame.get("axis"),
2088
+ normal_cell=frame.get("normalCell"), max_cells=max_cells,
2089
+ layer_smoothing=frame.get("layerSmoothing", 0.0), frame_field=ff)
2090
+
2091
+ if mode != "auto":
2092
+ return build(bool(mode))
2093
+ # auto: the frame field turns a box's edges and corners (sheared
2094
+ # cells 13-46% -> 0.1% on boxes, turned, rounded or slabs), but on a
2095
+ # rounded part its cross field puts singular points on flat faces
2096
+ # the two stripes ran straight across (the tutorial forearm's skin:
2097
+ # 10% sheared with stripes, 14% with the field). Both are built and
2098
+ # the one with fewer sheared cells in the first cell under the skin
2099
+ # is kept
2100
+ maps = [build(True), build(False)]
2101
+ scores = [m.skin_shear(cell_fn) for m in maps]
2102
+ best = maps[int(np.argmin([sc + (0.01 if k else 0.0) for k, sc in enumerate(scores)]))]
2103
+ best.shear_scores = {"frameField": round(scores[0], 4), "stripes": round(scores[1], 4)}
2104
+ return best
2105
+
2106
+ def _voronoi(self, nd):
2107
+ from .struts import VoronoiStruts
2108
+
2109
+ cell_fn, lo, hi = self._warp_domain(nd)
2110
+ return VoronoiStruts(lo, hi, cell_fn, seed=int(nd.spec.get("seed", 0)))
2111
+
2112
+ def _warp(self, nd, max_cells=400_000):
2113
+ """The cell-grading map over the lattice's domain: its own bounds,
2114
+ else the graph's, else the output solid's box."""
2115
+ cell_fn, lo, hi = self._warp_domain(nd)
2116
+ return _CellWarp(cell_fn, lo, hi, max_cells=max_cells)
2117
+
2118
+ def _warp_domain(self, nd):
2119
+ """(cell field, lo, hi) the warp is solved over, in the lattice's own
2120
+ coordinates (less its origin)."""
2121
+ if "bounds" in nd.spec:
2122
+ lo, hi = nd.spec["bounds"]["min"], nd.spec["bounds"]["max"]
2123
+ elif "bounds" in self.doc:
2124
+ lo, hi = self.doc["bounds"]["min"], self.doc["bounds"]["max"]
2125
+ else:
2126
+ b = self.bounds()
2127
+ if b is None:
2128
+ raise ValueError(f"node {nd.name!r}: a graded, conformal or voronoi lattice needs a domain: give the lattice or the "
2129
+ "graph 'bounds'")
2130
+ lo, hi = b
2131
+ off = np.asarray(nd.spec.get("origin", (0, 0, 0)), float)
2132
+ cell_ref = nd.refs.get("cell", 8.0)
2133
+ if isinstance(cell_ref, float):
2134
+ return (lambda G, c=cell_ref: np.full(len(G), c)), np.asarray(lo, float) - off, np.asarray(hi, float) - off
2135
+ return lambda G: self.evaluate(G + off, cell_ref), np.asarray(lo, float) - off, np.asarray(hi, float) - off
2136
+
2137
+ def _transform(self, nd, P, memo):
2138
+ Q = self.transform_points(nd, P)
2139
+ scale = float(nd.spec.get("scale", 1.0))
2140
+ out = self._val(nd.refs["input"], Q, memo)
2141
+ return out * scale
2142
+
2143
+ def transform_points(self, nd, P):
2144
+ """Where a transform node samples its input for points P."""
2145
+ s = nd.spec
2146
+ Q = P
2147
+ if "translate" in s:
2148
+ Q = Q - np.asarray(s["translate"], float)
2149
+ if "rotate" in s:
2150
+ rot = s["rotate"]
2151
+ ax = rot.get("axis", "z")
2152
+ a = np.asarray({"x": (1, 0, 0), "y": (0, 1, 0), "z": (0, 0, 1)}[ax] if isinstance(ax, str) else ax,
2153
+ float)
2154
+ a = a / np.linalg.norm(a)
2155
+ c = np.asarray(rot.get("center", (0, 0, 0)), float)
2156
+ th = -math.radians(float(rot["degrees"]))
2157
+ K = np.array([[0, -a[2], a[1]], [a[2], 0, -a[0]], [-a[1], a[0], 0]])
2158
+ R = np.eye(3) + math.sin(th) * K + (1 - math.cos(th)) * K @ K
2159
+ Q = (Q - c) @ R.T + c
2160
+ scale = float(s.get("scale", 1.0))
2161
+ if scale != 1.0:
2162
+ Q = Q / scale
2163
+ return Q
2164
+
2165
+ # -- bounds --
2166
+
2167
+ def bounds(self, name=None):
2168
+ """(lo, hi) of a solid node, or None when it is unbounded (a bare
2169
+ lattice). Booleans combine their inputs' boxes; shells and offsets
2170
+ grow them by the largest thickness they could have (a field one is
2171
+ estimated from its values over the input's box)."""
2172
+ b = self._bounds(name or self.output, set())
2173
+ return None if b is None else (np.asarray(b[0], float), np.asarray(b[1], float))
2174
+
2175
+ def _bounds(self, name, seen):
2176
+ if name in seen:
2177
+ return None
2178
+ seen = seen | {name}
2179
+ nd = self.nodes[name]
2180
+ s, r = nd.spec, nd.refs
2181
+ if "bounds" in s:
2182
+ return np.asarray(s["bounds"]["min"], float), np.asarray(s["bounds"]["max"], float)
2183
+ if nd.op == "shape":
2184
+ from .shapes import shape_from_spec
2185
+
2186
+ return self._resource(nd, lambda: shape_from_spec(s["shape"], self.base_dir)).bounds()
2187
+ if nd.op == "mesh":
2188
+ mf = self._resource(nd, lambda: self._mesh_field(nd))
2189
+ return mf.lo.copy(), mf.hi.copy()
2190
+ sub = [self._bounds(x, seen) for x in r.get("inputs", [])]
2191
+ if nd.op == "union":
2192
+ if any(b is None for b in sub):
2193
+ return None
2194
+ lo, hi = np.min([b[0] for b in sub], axis=0), np.max([b[1] for b in sub], axis=0)
2195
+ # a smooth union bulges past both inputs by up to blend / 4
2196
+ g = 0.25 * max(self._max_over(r.get("blend"), (lo, hi)), 0.0)
2197
+ return lo - g, hi + g
2198
+ if nd.op == "intersect":
2199
+ sub = [b for b in sub if b is not None]
2200
+ if not sub:
2201
+ return None
2202
+ lo, hi = np.max([b[0] for b in sub], axis=0), np.min([b[1] for b in sub], axis=0)
2203
+ return lo, np.maximum(hi, lo)
2204
+ if nd.op == "subtract":
2205
+ return sub[0]
2206
+ if nd.op in ("shell", "offset") and isinstance(r.get("input"), str):
2207
+ b = self._bounds(r["input"], seen)
2208
+ if b is None:
2209
+ return None
2210
+ grow = self._max_over(r.get("thickness" if nd.op == "shell" else "distance"), b)
2211
+ if nd.op == "shell" and s.get("side", "inside") == "inside":
2212
+ grow = 0.0
2213
+ g = max(grow, 0.0) + 1e-6
2214
+ return b[0] - g, b[1] + g
2215
+ if nd.op == "transform" and isinstance(r.get("input"), str):
2216
+ b = self._bounds(r["input"], seen)
2217
+ if b is None:
2218
+ return None
2219
+ # a rotated box's box: transform its corners
2220
+ corners = np.array([[b[i][0], b[j][1], b[k][2]] for i in (0, 1) for j in (0, 1) for k in (0, 1)])
2221
+ sc = float(s.get("scale", 1.0))
2222
+ Q = corners * sc
2223
+ if "rotate" in s:
2224
+ rot = s["rotate"]
2225
+ ax = rot.get("axis", "z")
2226
+ a = np.asarray({"x": (1, 0, 0), "y": (0, 1, 0), "z": (0, 0, 1)}[ax] if isinstance(ax, str)
2227
+ else ax, float)
2228
+ a = a / np.linalg.norm(a)
2229
+ c = np.asarray(rot.get("center", (0, 0, 0)), float)
2230
+ th = math.radians(float(rot["degrees"]))
2231
+ K = np.array([[0, -a[2], a[1]], [a[2], 0, -a[0]], [-a[1], a[0], 0]])
2232
+ R = np.eye(3) + math.sin(th) * K + (1 - math.cos(th)) * K @ K
2233
+ Q = (Q - c) @ R.T + c
2234
+ Q = Q + np.asarray(s.get("translate", (0, 0, 0)), float)
2235
+ return Q.min(axis=0), Q.max(axis=0)
2236
+ return None
2237
+
2238
+ def _max_over(self, ref, b, n=24):
2239
+ if ref is None:
2240
+ return 0.0
2241
+ if isinstance(ref, float):
2242
+ return ref
2243
+ axes = [np.linspace(b[0][i], b[1][i], n) for i in range(3)]
2244
+ G = np.stack(np.meshgrid(*axes, indexing="ij"), -1).reshape(-1, 3)
2245
+ return float(np.max(self.evaluate(G, ref))) * 1.1
2246
+
2247
+ # -- meshing --
2248
+
2249
+ def mesh(self, pitch=None, bounds=None, project=None, name=None, progress=None, sharp=None, creases=None):
2250
+ """`sharp` (default the graph's mesh.sharp, else true): hard booleans
2251
+ with plain shapes and meshes are done exactly (sharp.py), so bores,
2252
+ flat faces and keep-outs keep their edges; false meshes the whole
2253
+ field, every crease rounded over about one pitch.
2254
+
2255
+ `creases` (default the graph's mesh.creases, else true): hard
2256
+ booleans between field surfaces (a lattice clipped by a body, two
2257
+ bodies unioned without a blend, a shell's rim) get their creases back
2258
+ on the meshed field (crease.py)."""
2259
+ m = self.doc.get("mesh") or {}
2260
+ pitch = pitch or m.get("pitch")
2261
+ project = m.get("project", 3) if project is None else project
2262
+ sharp = m.get("sharp", True) if sharp is None else sharp
2263
+ creases = m.get("creases", True) if creases is None else creases
2264
+ if bounds is None and "bounds" in self.doc:
2265
+ bounds = (self.doc["bounds"]["min"], self.doc["bounds"]["max"])
2266
+ if bounds is None:
2267
+ bounds = self.bounds(name)
2268
+ if bounds is None:
2269
+ raise ValueError(f"{name or self.output!r} is unbounded (a lattice with nothing to clip it): "
2270
+ "intersect it with a solid or give the graph 'bounds'")
2271
+ from . import quick
2272
+ pitch = quick.mesh_pitch(pitch, bounds)
2273
+ fallback = None
2274
+ if sharp:
2275
+ from .sharp import has_exact, mesh_plan, plan
2276
+
2277
+ p = plan(self, name)
2278
+ if has_exact(p):
2279
+ # set after meshing: a result node meshes another node on the way
2280
+ try:
2281
+ mesh = mesh_plan(p, bounds[0], bounds[1], pitch=pitch, project=project, progress=progress,
2282
+ decimate=m.get("decimate"), creases=self if creases else None)
2283
+ except ValueError as exc:
2284
+ # a field leaf whose mesh the exact booleans can't take
2285
+ # (a fine lattice's mesh that isn't a valid manifold even
2286
+ # after repair): mesh the whole field instead, the hard
2287
+ # edges rounded over a pitch, rather than fail the part
2288
+ if "not a valid manifold" not in str(exc):
2289
+ raise
2290
+ fallback = str(exc)
2291
+ else:
2292
+ self.plan = p
2293
+ return mesh
2294
+ tree = None
2295
+ if creases:
2296
+ from .crease import CreaseTree
2297
+
2298
+ tree = CreaseTree.from_graph(self, name)
2299
+ mesh = field_mesh(self.field(name), bounds[0], bounds[1], pitch=pitch, project=project, progress=progress,
2300
+ decimate=m.get("decimate"), creases=tree)
2301
+ if fallback:
2302
+ mesh.metadata["sharpFallback"] = fallback
2303
+ self.plan = None
2304
+ return mesh
2305
+
2306
+
2307
+ def _run_streamed(doc, g, out_dir, drains, process, progress, t0):
2308
+ """run_field with mesh.stream: the STL goes to disk slab by slab
2309
+ (field_stl) and is never held, so the checks that need the whole mesh
2310
+ (printability, drains' mesh escape, bodies, the render) are skipped."""
2311
+ import time
2312
+
2313
+ if not out_dir:
2314
+ raise ValueError("mesh.stream writes the STL as it goes: give an output folder")
2315
+ m = doc.get("mesh") or {}
2316
+ bounds = (doc["bounds"]["min"], doc["bounds"]["max"]) if "bounds" in doc else g.bounds()
2317
+ if bounds is None:
2318
+ raise ValueError(f"{g.output!r} is unbounded (a lattice with nothing to clip it): "
2319
+ "intersect it with a solid or give the graph 'bounds'")
2320
+ os.makedirs(out_dir, exist_ok=True)
2321
+ name = doc.get("name", "field")
2322
+ path = os.path.join(out_dir, f"{name}.stl")
2323
+ st = field_stl(g.field(), bounds[0], bounds[1], path, pitch=m.get("pitch"), project=m.get("project", 3),
2324
+ progress=progress, decimate=m.get("decimate"))
2325
+ rep = {"name": name, "output": g.output, "nodes": len(g.nodes), "streamed": True,
2326
+ **{k: st[k] for k in ("pitch", "faces", "watertight", "volume", "area", "slabs", "maxSlabFaces",
2327
+ "droppedShells", "sampling")}, "stl": path}
2328
+ if m.get("decimate"):
2329
+ rep["decimation"] = {"tolerance": float(m["decimate"]), "facesBefore": st["facesBeforeDecimation"]}
2330
+ if process is not None:
2331
+ rep["process"] = process.kind
2332
+ if drains is not None:
2333
+ rep["drains"] = drains
2334
+ if "material" in doc:
2335
+ from .materials import material_from_spec
2336
+
2337
+ mat = material_from_spec(doc["material"])
2338
+ rep["mass"] = rep["volume"] * mat.density / 1000.0
2339
+ rep["material"] = mat.name
2340
+ rep["skipped"] = ["printability", "bodies", "render"]
2341
+ rep["seconds"] = round(time.time() - t0, 1)
2342
+ return rep
2343
+
2344
+
2345
+ def _remap(x, s, name):
2346
+ """`from` [a, b] -> `to` [c, d] through `curve` (a name, or points
2347
+ [[u, v], ...] over the unit square drawn in the editor); clamped unless
2348
+ clamp: false. With more than two points, `from` (ascending) and `to`
2349
+ are a piecewise-linear table (always clamped at its ends), e.g. a
2350
+ density per layer from impact.grade_for_impact."""
2351
+ if len(s.get("from", ())) > 2 or len(s.get("to", ())) > 2:
2352
+ xs, ys = np.asarray(s["from"], float), np.asarray(s["to"], float)
2353
+ if len(xs) != len(ys) or np.any(np.diff(xs) <= 0):
2354
+ raise ValueError(f"node {name!r}: a remap table needs equal-length 'from' (ascending) and 'to'")
2355
+ return np.interp(np.asarray(x, float), xs, ys)
2356
+ a, b = (float(u) for u in s.get("from", (0.0, 1.0)))
2357
+ c, d = (float(u) for u in s.get("to", (0.0, 1.0)))
2358
+ if a == b:
2359
+ raise ValueError(f"node {name!r}: remap 'from' needs two different values")
2360
+ u = (np.asarray(x, float) - a) / (b - a)
2361
+ if s.get("clamp", True):
2362
+ u = np.clip(u, 0.0, 1.0)
2363
+ curve = s.get("curve", "linear")
2364
+ if isinstance(curve, (list, tuple)):
2365
+ # a drawn curve: points (u, v) over the unit square, u ascending from 0 to 1, straight between them
2366
+ pts = np.asarray(curve, float)
2367
+ if pts.ndim != 2 or pts.shape[1] != 2 or len(pts) < 2 or np.any(np.diff(pts[:, 0]) <= 0):
2368
+ raise ValueError(f"node {name!r}: a remap curve as points needs [[u, v], ...] with u ascending")
2369
+ return c + (d - c) * np.interp(np.clip(u, pts[0, 0], pts[-1, 0]), pts[:, 0], pts[:, 1])
2370
+ if curve == "smoothstep":
2371
+ uc = np.clip(u, 0.0, 1.0)
2372
+ u = uc * uc * (3 - 2 * uc)
2373
+ elif curve == "smootherstep":
2374
+ uc = np.clip(u, 0.0, 1.0)
2375
+ u = uc ** 3 * (uc * (6 * uc - 15) + 10)
2376
+ elif curve == "sqrt":
2377
+ u = np.sqrt(np.clip(u, 0.0, None))
2378
+ elif curve == "square":
2379
+ u = u * u
2380
+ elif curve != "linear":
2381
+ raise ValueError(f"node {name!r}: remap curve must be linear, smoothstep, smootherstep, sqrt or square")
2382
+ return c + (d - c) * u
2383
+
2384
+
2385
+ # ---- meshing ---------------------------------------------------------------------
2386
+
2387
+ def field_mesh(field, lo, hi, pitch=None, project=3, pad=2, progress=None, sparse=True, decimate=None, creases=None):
2388
+ """Smooth, watertight trimesh of field(P) < 0 over [lo, hi] (mm).
2389
+
2390
+ Marching cubes on a grid at `pitch` (default: the box over 4M cells,
2391
+ and at most the 60M budget), then `project` rounds of relaxing the
2392
+ vertices along the surface and Newton-projecting them onto the field's
2393
+ zero level (see _relax). Marching cubes alone puts vertices on the
2394
+ grid's edges, up to ~pitch/3 off a curved surface; after the
2395
+ projection every vertex is on the surface to 1e-5 mm or so, which is
2396
+ what removes the grid from the part.
2397
+
2398
+ `sparse` (default) samples the field only in blocks near its zero
2399
+ level (narrowband.py): the same marching-cubes mesh as the dense grid,
2400
+ with far fewer field evaluations and memory for one batch of blocks,
2401
+ so the cell budget applies to the sampled cells, not the box.
2402
+
2403
+ `decimate` (mm) thins the triangles wherever the mesh stays that close
2404
+ to the surface (decimate.py): flat and gently curved regions lose most
2405
+ of theirs, creases and small features keep them.
2406
+
2407
+ `creases` (a crease.CreaseTree of the field: its hard min/max over
2408
+ smooth leaves) puts the creases between field surfaces back after the
2409
+ projection: the edges that cross from one leaf's surface to another's
2410
+ are split on the true intersection curve (crease.py)."""
2411
+ import trimesh
2412
+ from skimage.measure import marching_cubes
2413
+
2414
+ lo = np.asarray(lo, float)
2415
+ hi = np.asarray(hi, float)
2416
+ ext = hi - lo
2417
+ if pitch is None:
2418
+ pitch = float((np.prod(ext) / 4e6) ** (1 / 3))
2419
+ pitch = float(pitch)
2420
+ lo = lo - pad * pitch - pitch * 0.013717 # off-grid, as sdf_kit.to_mesh
2421
+ dims = np.ceil((hi + pad * pitch - lo) / pitch).astype(int) + 1
2422
+ cells = int(np.prod(dims))
2423
+ if sparse:
2424
+ from .narrowband import narrowband_mc, watertight
2425
+
2426
+ verts, faces, stats = narrowband_mc(field, lo, dims, pitch, budget=_CELL_BUDGET, progress=progress)
2427
+ if not len(faces):
2428
+ raise ValueError("the field has no inside over these bounds (nothing is below zero)")
2429
+ if not watertight(faces, len(verts)) and stats.get("lipschitz"):
2430
+ # the per-leaf Lipschitz bounds let a crossing through: the
2431
+ # global one (the steepest slope anywhere) for every leaf
2432
+ verts, faces, stats = narrowband_mc(field, lo, dims, pitch, lipschitz=stats["lipschitz"],
2433
+ budget=_CELL_BUDGET, progress=progress)
2434
+ stats["fallback"] = "global"
2435
+ if not watertight(faces, len(verts)):
2436
+ # and if even that let one through: sample every block
2437
+ verts, faces, stats = narrowband_mc(field, lo, dims, pitch, lipschitz=np.inf, budget=_CELL_BUDGET,
2438
+ progress=progress)
2439
+ stats["fallback"] = True
2440
+ return _finish(verts, faces[:, ::-1], field, pitch, project, stats, decimate, creases)
2441
+ if cells > _CELL_BUDGET:
2442
+ p = float((np.prod(ext + 2 * pad * pitch) / _CELL_BUDGET) ** (1 / 3))
2443
+ raise ValueError(f"grid {dims[0]}x{dims[1]}x{dims[2]} = {cells / 1e6:.1f}M cells exceeds the "
2444
+ f"{_CELL_BUDGET // 1_000_000}M budget: use pitch >= {math.ceil(p * 100) / 100:g}")
2445
+ F = np.empty(tuple(dims), np.float32)
2446
+ ys = lo[1] + np.arange(dims[1]) * pitch
2447
+ zs = lo[2] + np.arange(dims[2]) * pitch
2448
+ Y, Z = np.meshgrid(ys, zs, indexing="ij")
2449
+ per = max(1, _CHUNK // (dims[1] * dims[2]))
2450
+ for i0 in range(0, dims[0], per):
2451
+ i1 = min(dims[0], i0 + per)
2452
+ xs = lo[0] + np.arange(i0, i1) * pitch
2453
+ P = np.stack([np.repeat(xs, Y.size), np.tile(Y.ravel(), len(xs)), np.tile(Z.ravel(), len(xs))], axis=1)
2454
+ F[i0:i1] = np.asarray(field(P), np.float32).reshape(i1 - i0, dims[1], dims[2])
2455
+ if progress:
2456
+ progress({"stage": "field", "done": i1, "of": int(dims[0])})
2457
+ F[0], F[-1], F[:, 0], F[:, -1], F[:, :, 0], F[:, :, -1] = (pitch,) * 6
2458
+ F[F == 0.0] = -1e-4 * pitch
2459
+ if F.min() >= 0:
2460
+ raise ValueError("the field has no inside over these bounds (nothing is below zero)")
2461
+ verts, faces, _, _ = marching_cubes(F, 0.0, spacing=(pitch,) * 3)
2462
+ del F
2463
+ m = trimesh.Trimesh(verts + lo, faces[:, ::-1], process=True)
2464
+ return _finish(np.asarray(m.vertices), np.asarray(m.faces), field, pitch, project, None, decimate, creases)
2465
+
2466
+
2467
+ def field_stl(field, lo, hi, path, pitch=None, project=3, pad=2, progress=None, block=32, lipschitz=None,
2468
+ decimate=None):
2469
+ """field(P) < 0 straight to a binary STL at `path`, one x slab of
2470
+ narrow-band blocks at a time, for parts whose mesh does not fit in
2471
+ memory (lattices at fine pitch: tens of millions of triangles). Memory
2472
+ is one slab (`block` cells thick), whatever the part; there is no cell
2473
+ budget. Returns the report: faces, volume, area, watertight, sampling.
2474
+
2475
+ The same marching cubes, relaxation and projection as field_mesh, slab
2476
+ by slab. A vertex on the plane two slabs share is relaxed along the
2477
+ seam only and projected by the first slab, and reused, bit for bit, by the second, so the STL
2478
+ closes across the seam; the open edges each slab leaves on its two
2479
+ planes are matched against its neighbours', which checks the whole
2480
+ file is watertight without ever holding it. Small closed shells inside
2481
+ one slab, under (2 pitch)^3, are dropped as field_mesh drops debris. A
2482
+ crossing the Lipschitz skip missed shows as an unmatched edge, and the
2483
+ export starts over with every block sampled. `decimate` (mm) thins each
2484
+ slab as field_mesh does, its seam vertices left where they are."""
2485
+ lo, dims, pitch = _grid(lo, hi, pitch, pad)
2486
+ try:
2487
+ return _stream_stl(field, lo, dims, pitch, path, project, progress, block, lipschitz, decimate)
2488
+ except _Unmatched:
2489
+ rep = _stream_stl(field, lo, dims, pitch, path, project, progress, block, np.inf, decimate)
2490
+ rep["sampling"]["fallback"] = True
2491
+ return rep
2492
+
2493
+
2494
+ class _Unmatched(Exception):
2495
+ pass
2496
+
2497
+
2498
+ _STL_DTYPE = np.dtype([("n", "<f4", (3,)), ("v", "<f4", (3, 3)), ("a", "<u2")])
2499
+
2500
+
2501
+ def _stream_stl(field, lo, dims, pitch, path, project, progress, block, lipschitz, decimate=None):
2502
+ import time
2503
+
2504
+ from .narrowband import march_blocks, merge_blocks, shell_volumes
2505
+
2506
+ t0 = time.time()
2507
+ stats = {}
2508
+ rep = {"faces": 0, "volume": 0.0, "area": 0.0, "slabs": 0, "maxSlabFaces": 0, "droppedShells": 0,
2509
+ "facesBeforeDecimation": 0}
2510
+ carry, carry_edges, carry_x = {}, set(), None
2511
+ tol = (2.0 * pitch) ** 3
2512
+
2513
+ def keys(V):
2514
+ return [r.tobytes() for r in np.ascontiguousarray(V)]
2515
+
2516
+ def slab(blocks, fh):
2517
+ nonlocal carry, carry_edges, carry_x
2518
+ x0 = int(blocks[0][0][0])
2519
+ x1 = x0 + int(blocks[0][1][0]) - 1
2520
+ if carry_x != x0 and carry_edges:
2521
+ raise _Unmatched() # the last slab left edges open and nothing meets them
2522
+ nv, Vs, Fs = 0, [], []
2523
+ for _, _, v, fa in blocks:
2524
+ Vs.append(v)
2525
+ Fs.append(fa + nv)
2526
+ nv += len(v)
2527
+ V, F = merge_blocks(np.concatenate(Vs), np.concatenate(Fs), block)
2528
+ del Vs, Fs
2529
+ F = F[:, ::-1]
2530
+ start = V[:, 0] == x0
2531
+ end = V[:, 0] == x1
2532
+ # open edges: allowed only on the two planes, and matched across them
2533
+ n = len(V)
2534
+ a, b = F.ravel(), np.roll(F, -1, axis=1).ravel()
2535
+ e = np.minimum(a, b) * n + np.maximum(a, b)
2536
+ u, cnt = np.unique(e, return_counts=True)
2537
+ if (cnt > 2).any():
2538
+ raise _Unmatched()
2539
+ op = u[cnt == 1]
2540
+ ea, eb = op // n, op % n
2541
+ at_start = start[ea] & start[eb]
2542
+ at_end = end[ea] & end[eb]
2543
+ if not (at_start | at_end).all():
2544
+ raise _Unmatched()
2545
+ kv = {}
2546
+
2547
+ def key(i):
2548
+ if i not in kv:
2549
+ kv[i] = V[i].tobytes()
2550
+ return kv[i]
2551
+
2552
+ mine = {frozenset((key(i), key(j))) for i, j in zip(ea[at_start], eb[at_start])}
2553
+ if mine != (carry_edges if carry_x == x0 else set()):
2554
+ raise _Unmatched()
2555
+ carry_edges = {frozenset((key(i), key(j))) for i, j in zip(ea[at_end], eb[at_end])}
2556
+ # debris: a small shell that touches neither plane is whole and here
2557
+ lab, vol = shell_volumes(V, F)
2558
+ seam = np.zeros(len(vol), bool)
2559
+ seam[np.unique(lab[np.flatnonzero((start | end)[F].any(axis=1))])] = True
2560
+ drop = ~seam & (np.abs(vol) * pitch ** 3 <= tol)
2561
+ if drop.any():
2562
+ rep["droppedShells"] += int(drop.sum())
2563
+ F = F[~drop[lab]]
2564
+ Vmm = lo + V * pitch
2565
+ si = np.flatnonzero(start)
2566
+ if len(si):
2567
+ got = [carry.get(k) for k in keys(V[si])]
2568
+ if any(g is None for g in got):
2569
+ raise _Unmatched()
2570
+ Vmm[si] = np.array(got)
2571
+ end_keys = keys(V[end])
2572
+ if project:
2573
+ V0 = Vmm
2574
+ Vmm = _relax(Vmm, F, field, pitch, int(project), pinned=start, seam=end)
2575
+ if _UNFOLD:
2576
+ # the previous slab's seam is fixed: never sent back
2577
+ Vmm, nb = _unfold(V0, Vmm, F, pitch, fixed=start, field=field)
2578
+ rep["unfolded"] = rep.get("unfolded", 0) + int(nb)
2579
+ carry = dict(zip(end_keys, Vmm[end]))
2580
+ if decimate:
2581
+ from .decimate import decimate as thin
2582
+
2583
+ Vmm, F, dst, _ = thin(Vmm, F, field, float(decimate), pitch, locked=start | end)
2584
+ rep["facesBeforeDecimation"] += dst["facesBefore"]
2585
+ carry_x = x1
2586
+ # write
2587
+ tri = Vmm[F]
2588
+ nrm = np.cross(tri[:, 1] - tri[:, 0], tri[:, 2] - tri[:, 0])
2589
+ ln = np.linalg.norm(nrm, axis=1)
2590
+ rep["area"] += float(ln.sum() / 2)
2591
+ rep["volume"] += float(np.einsum("ij,ij->i", tri[:, 0], np.cross(tri[:, 1], tri[:, 2])).sum() / 6)
2592
+ out = np.zeros(len(F), _STL_DTYPE)
2593
+ out["n"] = nrm / np.maximum(ln, 1e-300)[:, None]
2594
+ out["v"] = tri
2595
+ fh.write(out.tobytes())
2596
+ rep["faces"] += len(F)
2597
+ rep["slabs"] += 1
2598
+ rep["maxSlabFaces"] = max(rep["maxSlabFaces"], len(F))
2599
+
2600
+ with open(path, "wb") as fh:
2601
+ fh.write(b"loadpath field_stl".ljust(80, b" ") + b"\0\0\0\0")
2602
+ cur = []
2603
+ for blk in march_blocks(field, lo, dims, pitch, block=block, lipschitz=lipschitz, progress=progress,
2604
+ stats=stats):
2605
+ if cur and blk[0][0] != cur[0][0][0]:
2606
+ slab(cur, fh)
2607
+ cur = []
2608
+ cur.append(blk)
2609
+ if cur:
2610
+ slab(cur, fh)
2611
+ if carry_edges:
2612
+ raise _Unmatched()
2613
+ if not rep["faces"]:
2614
+ raise ValueError("the field has no inside over these bounds (nothing is below zero)")
2615
+ fh.seek(80)
2616
+ fh.write(np.uint32(rep["faces"]).tobytes())
2617
+ if rep["volume"] < 0:
2618
+ # wound inside out: flip every triangle in place
2619
+ mm = np.memmap(path, dtype=_STL_DTYPE, mode="r+", offset=84, shape=(rep["faces"],))
2620
+ for i in range(0, len(mm), 1_000_000):
2621
+ c = mm[i:i + 1_000_000]
2622
+ c["v"] = c["v"][:, ::-1]
2623
+ c["n"] = -c["n"]
2624
+ mm.flush()
2625
+ del mm
2626
+ rep["volume"] = -rep["volume"]
2627
+ rep.update({"watertight": True, "pitch": pitch, "sampling": stats, "path": path,
2628
+ "seconds": round(time.time() - t0, 1)})
2629
+ return rep
2630
+
2631
+
2632
+ def _grid(lo, hi, pitch, pad):
2633
+ """field_mesh's sampling grid: (lo, dims, pitch), off-grid by a hair."""
2634
+ lo = np.asarray(lo, float)
2635
+ hi = np.asarray(hi, float)
2636
+ if pitch is None:
2637
+ pitch = float((np.prod(hi - lo) / 4e6) ** (1 / 3))
2638
+ pitch = float(pitch)
2639
+ lo = lo - pad * pitch - pitch * 0.013717 # off-grid, as sdf_kit.to_mesh
2640
+ dims = np.ceil((hi + pad * pitch - lo) / pitch).astype(int) + 1
2641
+ return lo, dims, pitch
2642
+
2643
+
2644
+ def _finish(V, F, field, pitch, project, stats=None, decimate=None, creases=None):
2645
+ """Relax and project the marching-cubes mesh, orient it, drop debris
2646
+ (shells under (2 pitch)^3), as a trimesh. The bookkeeping is numpy on
2647
+ the arrays: trimesh's own (process, split, volume) took longer than
2648
+ the meshing on a lattice of a few million faces."""
2649
+ import trimesh
2650
+
2651
+ from .narrowband import shell_volumes, signed_volume
2652
+
2653
+ V = np.ascontiguousarray(V, float)
2654
+ F = np.ascontiguousarray(F, np.int64) # (marching cubes' faces come reversed, as a view)
2655
+ unfolded = None
2656
+ if project:
2657
+ V0 = V
2658
+ V = _relax(V, F, field, pitch, int(project))
2659
+ if _UNFOLD:
2660
+ V, unfolded = _unfold(V0, V, F, pitch, field=field)
2661
+ locked, sharp_st = None, None
2662
+ if creases is not None and project:
2663
+ from .crease import sharpen
2664
+
2665
+ sharp_st = {}
2666
+ if signed_volume(V, F) < 0: # sharpen checks faces against the surface normals
2667
+ F = F[:, ::-1].copy()
2668
+ V, F, locked = sharpen(V, F, creases, pitch, field=field, stats=sharp_st)
2669
+ dec = None
2670
+ if decimate:
2671
+ from .decimate import decimate as thin
2672
+
2673
+ V, F, dec, kept = thin(V, F, field, float(decimate), pitch, locked=locked)
2674
+ try:
2675
+ lab, vol = shell_volumes(V, F)
2676
+ tol = (2.0 * pitch) ** 3
2677
+ if vol.sum() < 0:
2678
+ F, vol = F[:, ::-1].copy(), -vol
2679
+ small = np.abs(vol) <= tol
2680
+ if len(vol) > 1 and small.any() and not small.all():
2681
+ F = F[~small[lab]]
2682
+ used = np.zeros(len(V), bool)
2683
+ used[F] = True
2684
+ new = np.cumsum(used) - 1
2685
+ V, F = V[used], new[F]
2686
+ except Exception: # noqa: BLE001 - debris filtering is best-effort
2687
+ pass
2688
+ if signed_volume(V, F) < 0:
2689
+ F = F[:, ::-1].copy()
2690
+ mesh = trimesh.Trimesh(V, F, process=False)
2691
+ mesh.metadata["pitch"] = pitch
2692
+ if unfolded is not None:
2693
+ mesh.metadata["unfolded"] = int(unfolded)
2694
+ if stats:
2695
+ mesh.metadata["sampling"] = stats
2696
+ if dec:
2697
+ mesh.metadata["decimation"] = dec
2698
+ if sharp_st is not None:
2699
+ mesh.metadata["creases"] = sharp_st
2700
+ return mesh
2701
+
2702
+
2703
+ def _gradient(field, V, h):
2704
+ """(f, grad f) at V from a tetrahedral stencil (five evaluations)."""
2705
+ from .narrowband import peval
2706
+
2707
+ tet = np.array([[1, -1, -1], [-1, -1, 1], [-1, 1, -1], [1, 1, 1]], float) * h
2708
+ f0 = np.empty(len(V))
2709
+ g = np.empty((len(V), 3))
2710
+ chunk = 400_000
2711
+ for i in range(0, len(V), chunk):
2712
+ v = V[i:i + chunk]
2713
+ n = len(v)
2714
+ f = peval(field, np.concatenate([v] + [v + t for t in tet]))
2715
+ ft = f[n:].reshape(4, n)
2716
+ f0[i:i + chunk] = f[:n]
2717
+ g[i:i + chunk] = (tet[:, :, None] * ft[:, None, :]).sum(axis=0).T / (4 * h * h)
2718
+ return f0, g
2719
+
2720
+
2721
+ def _project(field, V, pitch, f0=None, g=None, backtrack=0):
2722
+ """One Newton step of every vertex onto field = 0, step capped at
2723
+ pitch / 2 and kept only where |f| drops (halved up to `backtrack`
2724
+ times where it does not)."""
2725
+ from .narrowband import peval
2726
+
2727
+ if f0 is None:
2728
+ f0, g = _gradient(field, V, 0.05 * pitch)
2729
+ gg = np.maximum((g * g).sum(axis=1), 1e-12)
2730
+ step = -(f0 / gg)[:, None] * g
2731
+ ln = np.linalg.norm(step, axis=1)
2732
+ step *= np.minimum(1.0, 0.5 * pitch / np.maximum(ln, 1e-300))[:, None]
2733
+ cand = V + step
2734
+ f1 = peval(field, cand)
2735
+ ok = np.abs(f1) < np.abs(f0)
2736
+ out = np.where(ok[:, None], cand, V)
2737
+ for _ in range(backtrack):
2738
+ bad = np.flatnonzero(~ok)
2739
+ if not len(bad):
2740
+ break
2741
+ step[bad] *= 0.5
2742
+ c = V[bad] + step[bad]
2743
+ f1 = peval(field, c)
2744
+ good = np.abs(f1) < np.abs(f0[bad])
2745
+ out[bad[good]] = c[good]
2746
+ ok[bad[good]] = True
2747
+ return out
2748
+
2749
+
2750
+ _RELAX_LINE = True
2751
+ _CLOSING = 6 # closing passes: two, then up to four more on a small tail
2752
+ _CLOSING_TAIL = 5000
2753
+ _SHARP_COS = float(np.cos(np.radians(25.0)))
2754
+
2755
+
2756
+ class _Normals:
2757
+ """Area-weighted unit vertex normals of a mesh whose faces stay put
2758
+ while its vertices move (the incidence is built once), and a mask of
2759
+ the vertices where some face round them leans further than `sharp` (a
2760
+ cosine) from the normal: edges and corners, where a mesh normal is
2761
+ not the field's gradient."""
2762
+
2763
+ def __init__(self, F, n, vf=None):
2764
+ from .meshops import vertex_faces
2765
+
2766
+ self.F, self.n = F, n
2767
+ # vertex -> faces (CSR), each face once per corner
2768
+ self.vf = vertex_faces(F, n) if vf is None else vf
2769
+ self.edge = None
2770
+
2771
+ def __call__(self, V, sharp):
2772
+ from .meshops import vertex_sum
2773
+
2774
+ F = self.F
2775
+ fn = face_cross(V, F)
2776
+ ptr, idx = self.vf
2777
+ N = vertex_sum(ptr, idx, fn)
2778
+ N /= np.maximum(np.sqrt(np.einsum("ij,ij->i", N, N))[:, None], 1e-300)
2779
+ if self.edge is None:
2780
+ # the edges and corners are found once: a relax round moves a
2781
+ # vertex a fraction of a pitch, not off its edge
2782
+ fn /= np.maximum(np.sqrt(np.einsum("ij,ij->i", fn, fn))[:, None], 1e-300)
2783
+ from .meshops import min_cos
2784
+
2785
+ self.edge = min_cos(self.vf, fn, N) < sharp
2786
+ return N, self.edge
2787
+
2788
+
2789
+ def _line_newton(field, V, N, pitch, h):
2790
+ """One Newton step of each vertex along its line V + t N onto field = 0
2791
+ (central differences: three evaluations), step capped at pitch / 2 and
2792
+ kept only where |f| drops (a fourth)."""
2793
+ from .narrowband import peval
2794
+
2795
+ n = len(V)
2796
+ f = peval(field, np.concatenate([V, V + h * N, V - h * N]))
2797
+ f0, fp, fm = f[:n], f[n:2 * n], f[2 * n:]
2798
+ df = (fp - fm) / (2 * h)
2799
+ ok = np.abs(df) > 1e-6
2800
+ t = np.where(ok, -f0 / np.where(ok, df, 1.0), 0.0)
2801
+ t = np.clip(t, -0.5 * pitch, 0.5 * pitch)
2802
+ cand = V + t[:, None] * N
2803
+ ok = np.abs(peval(field, cand)) < np.abs(f0)
2804
+ return np.where(ok[:, None], cand, V)
2805
+
2806
+
2807
+ def _relax(V, F, field, pitch, rounds, pinned=None, seam=None):
2808
+ """Even out the marching-cubes vertices on the surface: each round moves
2809
+ every vertex halfway to its neighbours' centroid, within the tangent
2810
+ plane of the field (so the surface does not shrink), then projects it
2811
+ back onto the zero level. Marching cubes leaves a sawtooth along every
2812
+ crease (a vertex lands on one face or the other at random); relaxed and
2813
+ projected, a crease comes out as a clean edge rounded over about one
2814
+ pitch. Exact sharp features are mesh booleans' job (sdf_kit.mesh_subtract).
2815
+
2816
+ Two closing Newton passes take every vertex the rest of the way; a
2817
+ vertex already within 1e-6 pitch of the zero level is left alone
2818
+ there, so those passes cost about one evaluation per vertex instead of
2819
+ six. `pinned` vertices do not move at all, and `seam` ones slide only
2820
+ along the seam they form (toward their seam neighbours' centroid): the
2821
+ streamed export's planes between slabs, which the next slab reuses
2822
+ as they are, so they must not lean on one side's vertices."""
2823
+ import scipy.sparse as sp
2824
+
2825
+ from .meshops import neighbors, vertex_faces
2826
+ from .narrowband import peval
2827
+
2828
+ V = np.array(V, float)
2829
+ n = len(V)
2830
+ vf = vertex_faces(F, n)
2831
+ # the edge adjacency: an edge counts once, from either face; a seam
2832
+ # vertex leans on its seam neighbours only
2833
+ indptr, indices = neighbors(F, n, seam=seam, vf=vf)
2834
+ A = sp.csr_matrix((np.ones(len(indices)), indices, indptr), shape=(n, n))
2835
+ deg = np.maximum(np.diff(indptr), 1)
2836
+ move = np.ones(n, bool) if pinned is None else ~np.asarray(pinned, bool)
2837
+ h = 0.05 * pitch
2838
+ line = getattr(field, "relax_line", _RELAX_LINE)
2839
+ normals = _Normals(F, n, vf) if line else None
2840
+
2841
+ def newton(idx):
2842
+ """One Newton step of the vertices idx onto the zero level; the
2843
+ unit normals it used (rows idx; the rest zero)."""
2844
+ if not line:
2845
+ f0, g = _gradient(field, V[idx], h)
2846
+ V[idx] = _project(field, V[idx], pitch, f0, g)
2847
+ nrm = np.zeros((n, 3))
2848
+ nrm[idx] = g / np.maximum(np.linalg.norm(g, axis=1, keepdims=True), 1e-12)
2849
+ return nrm
2850
+ # the mesh's own vertex normals stand in for the field's gradient
2851
+ # direction (the mesh lies on the surface to a fraction of a
2852
+ # pitch), and Newton runs along that line: four evaluations a
2853
+ # vertex instead of seven. Edges and corners (a face round the
2854
+ # vertex 25 degrees off its normal) take the true gradient: there
2855
+ # the mesh normal bisects the faces, and smoothing in its plane
2856
+ # rounds the edge.
2857
+ nrm, edge = normals(V, _SHARP_COS)
2858
+ ln, ed = idx[~edge[idx]], idx[edge[idx]]
2859
+ V[ln] = _line_newton(field, V[ln], nrm[ln], pitch, h)
2860
+ if len(ed):
2861
+ f0, g = _gradient(field, V[ed], h)
2862
+ V[ed] = _project(field, V[ed], pitch, f0, g)
2863
+ nrm[ed] = g / np.maximum(np.linalg.norm(g, axis=1, keepdims=True), 1e-12)
2864
+ return nrm
2865
+
2866
+ from .lattice import single_precision
2867
+
2868
+ for _ in range(rounds):
2869
+ # these rounds move a vertex a fraction of a pitch toward the zero
2870
+ # level: single-precision lattice trig (~1e-6 mm) is plenty; the
2871
+ # closing passes below, to 1e-6 pitch, take it in double
2872
+ with single_precision():
2873
+ nrm = newton(np.flatnonzero(move))
2874
+ d = (A @ V) / deg[:, None] - V
2875
+ d -= (d * nrm).sum(axis=1, keepdims=True) * nrm
2876
+ V[move] += 0.5 * d[move]
2877
+ # closing passes: the vertices off the zero level by more than 1e-6
2878
+ # pitch, first as the rounds did, then by the true gradient (step
2879
+ # halved where it does not help) while few are left
2880
+ tol = 1e-6 * pitch
2881
+ idx = np.flatnonzero(move)
2882
+ for k in range(_CLOSING):
2883
+ f0 = peval(field, V[idx])
2884
+ idx = idx[np.abs(f0) > tol]
2885
+ if not len(idx) or (k >= 2 and len(idx) > _CLOSING_TAIL):
2886
+ break
2887
+ if k == 0 and line:
2888
+ newton(idx)
2889
+ else:
2890
+ V[idx] = _project(field, V[idx], pitch, backtrack=4 if k >= 2 else 0)
2891
+ return V
2892
+
2893
+
2894
+ _UNFOLD = True
2895
+ _UNFOLD_RINGS = 0 # rings round the folded edges' faces' vertices
2896
+ _FLAT_FOLD = 1e-3 # an edge folded back within this * pitch of flat is a crossing
2897
+
2898
+
2899
+ def _sliver(V, F, sin_min=float(np.sin(np.radians(5.0)))):
2900
+ """Faces with an angle under ~5 degrees."""
2901
+ a, b, c = V[F[:, 0]], V[F[:, 1]], V[F[:, 2]]
2902
+ e1, e2 = b - a, c - a
2903
+ n = face_cross(V, F)
2904
+ e3 = c - b
2905
+ l2 = np.sort(np.stack([(e1 * e1).sum(1), (e2 * e2).sum(1), (e3 * e3).sum(1)], 1), axis=1)
2906
+ return np.sqrt((n * n).sum(1)) < sin_min * np.sqrt(l2[:, 1] * l2[:, 2])
2907
+
2908
+
2909
+ def _unfold(V0, V, F, pitch, rounds=8, chunk=200_000, fixed=None, field=None):
2910
+ """Undo the relax where it folded the mesh through itself. Marching
2911
+ cubes' own mesh (V0) never crosses itself, but relaxing and projecting
2912
+ across a wall or a wedge thinner than a pitch can push one sheet
2913
+ through another. Faces that cross another face (sharing no vertex)
2914
+ send their vertices back toward where marching cubes put them (half
2915
+ way, then a quarter of the way from it, then onto it), a few rounds,
2916
+ each looking only round what the last one moved.
2917
+
2918
+ Only faces round an edge folded past 90 degrees (and a ring round
2919
+ them) are searched in the first round: on the arm-link gyroid that
2920
+ holds every folded face, at 6% of the mesh. `fixed` vertices (a
2921
+ streamed slab's seam with the last one) are never sent back.
2922
+ Returns (V, number of vertices sent back)."""
2923
+ from . import selfx
2924
+ from .crease import _Centroids, crossing_faces
2925
+
2926
+ if selfx.available():
2927
+ chunk = 1 << 62 # the compiled search takes any number at once
2928
+
2929
+ def normals(X):
2930
+ n = face_cross(X, F)
2931
+ return n / np.maximum(np.sqrt(np.einsum("ij,ij->i", n, n))[:, None], 1e-300)
2932
+
2933
+ V = np.array(V, float)
2934
+ from . import meshops
2935
+
2936
+ # the faces round every vertex, and the two faces of every edge (as
2937
+ # half-edges h1, h2: corner h % 3 of face h // 3 to the next corner)
2938
+ vf = meshops.vertex_faces(F, len(V))
2939
+ nf = len(F)
2940
+ h1, h2 = meshops.edge_pairs(F, vf)
2941
+ # a fold shows as an edge whose two faces meet at over 90 degrees
2942
+ N = normals(V)
2943
+ f1, f2 = h1 // 3, h2 // 3
2944
+ dots = meshops.row_dots(N, N, f1, f2)
2945
+ sharp = dots < 0.0
2946
+ # an edge folded flat back on itself (the two faces overlapping in one
2947
+ # plane, as projecting onto a flat cut can leave them): no segment
2948
+ # crosses a face there, but manifold3d swaps such edges and the swap
2949
+ # crosses. Candidates are the edges folded past ~150 degrees anywhere.
2950
+ c2 = F[f2, (h2 % 3 + 2) % 3]
2951
+ tol = _FLAT_FOLD * pitch
2952
+ pair = h1
2953
+
2954
+ # the edge pair of every half-edge (-1 on an open edge)
2955
+ he = np.full(3 * len(F), -1, np.int64)
2956
+ he[h1] = np.arange(len(h1))
2957
+ he[h2] = np.arange(len(h1))
2958
+
2959
+ def faces_of(verts):
2960
+ return meshops.faces_of(vf, verts, nf)
2961
+
2962
+ def unique_verts(faces):
2963
+ m = np.zeros(len(V), bool)
2964
+ m[F[faces].ravel()] = True
2965
+ return np.flatnonzero(m)
2966
+
2967
+ def rings(verts, k):
2968
+ for _r in range(k):
2969
+ verts = unique_verts(faces_of(verts))
2970
+ return verts
2971
+
2972
+ def flat(X, faces, k=None):
2973
+ if k is None:
2974
+ k = he[(3 * np.asarray(faces)[:, None] + np.arange(3)).ravel()]
2975
+ m = np.zeros(len(pair), bool)
2976
+ m[k[k >= 0]] = True
2977
+ m &= ~flat0
2978
+ k = np.flatnonzero(m)
2979
+ g1, g2 = f1[k], f2[k]
2980
+ n1 = face_cross(X, F[g1])
2981
+ n2 = face_cross(X, F[g2])
2982
+ l1 = np.sqrt(np.einsum("ij,ij->i", n1, n1))
2983
+ l2 = np.sqrt(np.einsum("ij,ij->i", n2, n2))
2984
+ d = np.einsum("ij,ij->i", n1, n2) < -0.9 * np.maximum(l1 * l2, 1e-300)
2985
+ d &= np.abs(np.einsum("ij,ij->i", X[c2[k]] - X[F[g1, 0]], n1)) < tol * np.maximum(l1, 1e-300)
2986
+ return np.concatenate([g1[d], g2[d]])
2987
+
2988
+ # folded flat in marching cubes' own mesh too: sending vertices back
2989
+ # cannot undo those, so they are left alone
2990
+ flat0 = np.zeros(len(pair), bool)
2991
+ k0 = np.flatnonzero(dots < -0.9)
2992
+ if len(k0):
2993
+ hit = flat(V0, None, k0)
2994
+ if len(hit):
2995
+ fm = np.zeros(len(F), bool)
2996
+ fm[hit] = True
2997
+ flat0[k0[fm[f1[k0]] & fm[f2[k0]]]] = True
2998
+
2999
+ near = np.zeros(len(V), bool)
3000
+ near[F[f1[sharp]].ravel()] = True
3001
+ near[F[f2[sharp]].ravel()] = True
3002
+ for _r in range(_UNFOLD_RINGS):
3003
+ near[F[near[F].any(axis=1)].ravel()] = True
3004
+ near[F[f1[dots < -0.9]].ravel()] = True
3005
+ q = np.flatnonzero(near[F].any(axis=1))
3006
+ if not len(q):
3007
+ return V, 0
3008
+ cache = _Centroids(V, F)
3009
+ back = 0
3010
+ V1 = V.copy()
3011
+ level = np.zeros(len(V), np.int64)
3012
+ for _ in range(rounds):
3013
+ x = [crossing_faces(V, F, q[i:i + chunk], cache) for i in range(0, len(q), chunk)]
3014
+ x = np.unique(np.concatenate(x + [flat(V, q)]))
3015
+ if not len(x):
3016
+ break
3017
+ verts = unique_verts(x)
3018
+ verts = verts[np.any(V[verts] != V0[verts], axis=1)]
3019
+ if fixed is not None:
3020
+ verts = verts[~fixed[verts]]
3021
+ if not len(verts):
3022
+ break
3023
+ # halfway back first (closer to the surface than marching cubes'
3024
+ # point), then a quarter, then all the way
3025
+ back += int((level[verts] == 0).sum())
3026
+ level[verts] += 1
3027
+ s = np.where(level[verts] >= 3, 0.0, 0.5 ** level[verts])[:, None]
3028
+ V[verts] = V0[verts] + s * (V1[verts] - V0[verts])
3029
+ cache.update(V, F, faces_of(verts))
3030
+ q = faces_of(rings(verts, 2))
3031
+ if field is not None and back:
3032
+ # those vertices sit off the zero level: project them along the
3033
+ # true gradient, and keep the projection wherever it folds nothing
3034
+ moved = np.flatnonzero(level > 0)
3035
+ safe = V[moved].copy()
3036
+ for _ in range(2):
3037
+ V[moved] = _project(field, V[moved], pitch)
3038
+ mf = np.zeros(len(V), bool)
3039
+ mf[moved] = True
3040
+ faces = faces_of(moved)
3041
+ # nor onto another vertex (two pulled to the same point of an edge,
3042
+ # which an STL's vertex merge would fuse)
3043
+ ring = unique_verts(faces)
3044
+ _, inv, cnt = np.unique(np.round(V[ring] / (1e-9 * pitch)), axis=0, return_inverse=True,
3045
+ return_counts=True)
3046
+ dup = ring[cnt[inv.ravel()] > 1]
3047
+ dup = dup[mf[dup]]
3048
+ if len(dup):
3049
+ j = np.searchsorted(moved, dup)
3050
+ V[dup] = safe[j]
3051
+ cache.update(V, F, faces)
3052
+ for _ in range(8):
3053
+ x = [crossing_faces(V, F, faces[i:i + chunk], cache) for i in range(0, len(faces), chunk)]
3054
+ x = np.unique(np.concatenate(x + [flat(V, faces)]))
3055
+ bad = np.zeros(len(V), bool)
3056
+ bad[F[x].ravel()] = True
3057
+ # and no sliver (manifold3d flips those, and a flip can cross)
3058
+ bad[F[faces[_sliver(V, F[faces])]].ravel()] = True
3059
+ back_to = np.flatnonzero(bad[moved] & np.any(V[moved] != safe, axis=1))
3060
+ if not len(back_to):
3061
+ break
3062
+ V[moved[back_to]] = safe[back_to]
3063
+ cache.update(V, F, faces_of(moved[back_to]))
3064
+ faces = faces_of(rings(moved[back_to], 2))
3065
+ else:
3066
+ # not settled: no projection at all round what is still moving
3067
+ vm = np.zeros(len(V), bool)
3068
+ vm[F[faces].ravel()] = True
3069
+ k = vm[moved]
3070
+ V[moved[k]] = safe[k]
3071
+ return V, back
3072
+
3073
+
3074
+ # ---- material bodies -------------------------------------------------------------
3075
+
3076
+ class _Cutter:
3077
+ """A material body's partition region (its solid, if it is not the
3078
+ part, and its where/above/below thresholds, intersected), carried on
3079
+ outside the part so its surface never runs along the part's own: inside
3080
+ the part within `eps` of its surface, and outside it, the region reads
3081
+ its value at the point `eps` deep along the part's normal (as sharp.py's
3082
+ extended field), and from 3 * eps outside it is solid. Only where the
3083
+ region's own surface crosses the part's interior does it draw one, and
3084
+ that is the interface. Clipped to `box` so it is closed."""
3085
+
3086
+ # its plane meets the part's faces in manifold's split: projected by the
3087
+ # true gradient it lands on the plane exactly, as the interface needs
3088
+ relax_line = False
3089
+
3090
+ def __init__(self, g, terms, part, eps, box):
3091
+ self.g, self.terms, self.part = g, terms, part
3092
+ self.eps = float(eps)
3093
+ self.lo, self.hi = (np.asarray(b, float) for b in box)
3094
+
3095
+ def raw(self, P):
3096
+ vals = []
3097
+ for kind, a, b in self.terms:
3098
+ if kind == "solid":
3099
+ vals.append(self.g.evaluate(P, a))
3100
+ else:
3101
+ w = self.g.evaluate(P, a)
3102
+ t = b if isinstance(b, float) else self.g.evaluate(P, b)
3103
+ vals.append(t - w if kind == "above" else w - t)
3104
+ return np.max(vals, axis=0)
3105
+
3106
+ def __call__(self, P):
3107
+ P = np.asarray(P, float).reshape(-1, 3)
3108
+ u = self.part(P)
3109
+ e, far = self.eps, 3 * self.eps
3110
+ # far outside: solid from `far` out, so the crease where the region's
3111
+ # surface meets that (rounded over a pitch by the mesher) stays clear of the part
3112
+ out = far - u
3113
+ near = u > -e
3114
+ mid = near & (u < far + e)
3115
+ inner = ~near
3116
+ if inner.any():
3117
+ out[inner] = self.raw(P[inner])
3118
+ if mid.any():
3119
+ Pn = P[mid]
3120
+ h = 1e-3
3121
+ grad = np.stack([(self.part(Pn + h * e) - self.part(Pn - h * e)) / (2 * h) for e in np.eye(3)], axis=1)
3122
+ nrm = grad / np.maximum(np.linalg.norm(grad, axis=1, keepdims=True), 1e-12)
3123
+ Q = Pn - (u[mid] + self.eps)[:, None] * nrm
3124
+ v = np.maximum(self.raw(Q), -e)
3125
+ out[mid] = np.minimum(v, far - u[mid])
3126
+ if inner.any():
3127
+ out[inner] = np.maximum(out[inner], -self.eps)
3128
+ box = np.max(np.concatenate([self.lo - P, P - self.hi], axis=1), axis=1)
3129
+ return np.maximum(out, box)
3130
+
3131
+
3132
+ def _manifold(mesh):
3133
+ from .shapes import manifold_from_trimesh
3134
+
3135
+ try:
3136
+ return manifold_from_trimesh(mesh)
3137
+ except ValueError:
3138
+ import trimesh
3139
+
3140
+ m = mesh.copy()
3141
+ trimesh.repair.fix_winding(m)
3142
+ trimesh.repair.fill_holes(m)
3143
+ return manifold_from_trimesh(m)
3144
+
3145
+
3146
+ def _body_terms(g, b):
3147
+ """[(kind, a, b)] that bound body `b` inside the part, or [] when it
3148
+ takes all that the bodies before it left."""
3149
+ terms = []
3150
+ sol = b.get("solid")
3151
+ if isinstance(sol, float):
3152
+ raise ValueError(f"material body {b['name']!r}: solid must be a solid node, not a number")
3153
+ if sol is not None and sol not in (g.output, g.part_node):
3154
+ terms.append(("solid", sol, None))
3155
+ for key in ("above", "below"):
3156
+ if key in b:
3157
+ terms.append((key, b["where"], b[key]))
3158
+ if terms and isinstance(terms[0][1], float):
3159
+ raise ValueError(f"material body {b['name']!r}: where must be a field, not a number")
3160
+ return terms
3161
+
3162
+
3163
+ def material_bodies(g, part, progress=None):
3164
+ """Split the meshed part into the graph's material bodies (g.bodies),
3165
+ earlier bodies first: body i is what of the part is inside its region
3166
+ and not in an earlier body's, so bodies never overlap and together they
3167
+ are the part (less `rest`, what no body claimed).
3168
+
3169
+ The part is meshed once (it keeps its sharp edges); each region is
3170
+ meshed from its field (_Cutter: carried on outside the part, so its
3171
+ surface is only the interface) and manifold3d splits what is left of
3172
+ the part by it: one exact boolean gives both sides, so the two bodies
3173
+ on an interface share that surface exactly, and each is watertight.
3174
+ Returns ([(body, trimesh or None)], rest manifold, stats)."""
3175
+ import manifold3d as m3d
3176
+
3177
+ from .shapes import trimesh_from_manifold
3178
+
3179
+ pitch = float(part.metadata.get("pitch") or (np.prod(part.extents) / 4e6) ** (1 / 3))
3180
+ lo, hi = np.asarray(part.bounds[0], float), np.asarray(part.bounds[1], float)
3181
+ box = (lo - 3 * pitch, hi + 3 * pitch)
3182
+ rest = _manifold(part)
3183
+ pf = g.field()
3184
+ out, stats = [], {"pitch": pitch, "regions": 0}
3185
+ for b in g.bodies:
3186
+ terms = _body_terms(g, b)
3187
+ if rest.is_empty():
3188
+ out.append((b, None))
3189
+ continue
3190
+ if not terms:
3191
+ piece, rest = rest, m3d.Manifold()
3192
+ else:
3193
+ cut = _Cutter(g, terms, pf, pitch, box)
3194
+ dims = np.ceil((box[1] - box[0]) / pitch + 5)
3195
+ # thresholds of an arbitrary field (a stress) have no Lipschitz
3196
+ # bound for the narrow band to lean on: sample densely while it fits
3197
+ sparse = float(np.prod(dims)) > 16e6
3198
+ cm = field_mesh(cut, box[0] - pitch, box[1] + pitch, pitch=pitch, project=3, sparse=sparse,
3199
+ progress=progress)
3200
+ stats["regions"] += 1
3201
+ piece, rest = rest.split(_manifold(cm))
3202
+ out.append((b, None if piece.is_empty() else trimesh_from_manifold(piece)))
3203
+ return out, rest, stats
3204
+
3205
+
3206
+ def _mix_job(g, b, mesh, mats, out_dir, stem, opts):
3207
+ """A `mix` body as a voxel jetting job: the fraction of material B on a
3208
+ voxel grid of the body (voxelprint.export_voxel_slices) and the mass it
3209
+ gives. Returns (mix report, mean density g/cm^3)."""
3210
+ from .voxels import mesh_solid
3211
+
3212
+ pitch = float(opts.get("pitch") or mesh.metadata.get("pitch") or 0.25)
3213
+ occ, origin, _ = mesh_solid(mesh, pitch, pad=0)
3214
+ dims = occ.shape
3215
+ idx = np.argwhere(occ)
3216
+ frac = np.zeros(dims, np.float32)
3217
+ if len(idx):
3218
+ P = origin + idx * pitch
3219
+ mix = b["mix"]
3220
+ f = np.full(len(P), mix) if isinstance(mix, float) else g.evaluate(P, mix)
3221
+ frac[tuple(idx.T)] = np.clip(f, 0.0, 1.0)
3222
+ rho = np.array([m.density for m in mats])
3223
+ mean_f = float(frac[occ].mean()) if occ.any() else 0.0
3224
+ rep = {"materials": [m.name for m in mats], "voxelPitch": pitch, "voxels": int(occ.sum()),
3225
+ "meanFraction": mean_f, "fractionRange": [float(frac[occ].min()), float(frac[occ].max())]
3226
+ if occ.any() else [0.0, 0.0]}
3227
+ if out_dir:
3228
+ try:
3229
+ from .voxelprint import export_voxel_slices
3230
+ except ImportError as exc:
3231
+ rep["voxelJob"] = {"skipped": f"loadpath.voxelprint.export_voxel_slices is not available ({exc})"}
3232
+ else:
3233
+ try:
3234
+ job = export_voxel_slices(os.path.join(out_dir, f"{stem}-voxels"), frac, origin - pitch / 2, pitch,
3235
+ _jetting_names(mats, opts), printer=opts.get("printer", "j850"),
3236
+ solid=occ, max_layers=opts.get("maxLayers"))
3237
+ if isinstance(job, dict):
3238
+ files = job.get("files") or []
3239
+ job = {**{k: v for k, v in job.items() if k != "files"}, "fileCount": len(files)}
3240
+ rep["voxelJob"] = {"dir": os.path.join(out_dir, f"{stem}-voxels"),
3241
+ **(job if isinstance(job, dict) else {"result": str(job)})}
3242
+ except Exception as exc: # noqa: BLE001 - the meshes are the deliverable
3243
+ rep["voxelJob"] = {"error": f"{type(exc).__name__}: {exc}"}
3244
+ return rep, float(rho[0] * (1 - mean_f) + rho[1] * mean_f)
3245
+
3246
+
3247
+ # loadpath's jetting cards as GrabCAD Voxel Print resins; other materials get a colour each
3248
+ _JETTING = {"vero": "VeroCyan", "agilus30": "Agilus30Blk", "resin": "VeroPureWht"}
3249
+ _PALETTE = ("#005a9e", "#f0f0f0", "#a62162", "#c8bd03", "#284d6c", "#1a1a1d")
3250
+
3251
+
3252
+ def _jetting_names(mats, opts):
3253
+ if opts.get("materials"):
3254
+ return list(opts["materials"])
3255
+ from .voxelprint import STRATASYS_COLORS
3256
+
3257
+ out = []
3258
+ for i, m in enumerate(mats):
3259
+ name = _JETTING.get(m.name, m.name)
3260
+ out.append(name if name.lower() in STRATASYS_COLORS else (name, _PALETTE[i % len(_PALETTE)]))
3261
+ return out
3262
+
3263
+
3264
+ def _safe(name):
3265
+ import re
3266
+
3267
+ return re.sub(r"[^A-Za-z0-9_.-]+", "-", name).strip("-") or "body"
3268
+
3269
+
3270
+ def _material_report(g, doc, part, out_dir, stem, progress):
3271
+ """run_field's `materials`: each body's mesh, volume and mass, the
3272
+ interface area between each pair, the checks that they tile the part,
3273
+ one STL per body and a multi-material 3MF."""
3274
+ from .materials import material_from_spec
3275
+ from .narrowband import signed_volume, watertight
3276
+
3277
+ pieces, rest, stats = material_bodies(g, part, progress=progress)
3278
+ V0 = signed_volume(np.asarray(part.vertices), np.asarray(part.faces))
3279
+ bodies, mans, extruders = [], [], {}
3280
+ for b, mesh in pieces:
3281
+ mix = "mix" in b
3282
+ mats = [material_from_spec(m) for m in (b["material"] if mix else [b["material"]])]
3283
+ key = "+".join(m.name for m in mats)
3284
+ ext = b.get("extruder") or extruders.setdefault(key, len(extruders) + 1)
3285
+ r = {"name": b["name"], "material": key if not mix else [m.name for m in mats], "extruder": ext}
3286
+ if mesh is None:
3287
+ r.update(volume=0.0, mass=0.0, empty=True)
3288
+ bodies.append((b, r, None, mats))
3289
+ mans.append(None)
3290
+ continue
3291
+ Vb, Fb = np.asarray(mesh.vertices), np.asarray(mesh.faces)
3292
+ r.update(volume=signed_volume(Vb, Fb), area=float(mesh.area), faces=int(len(Fb)),
3293
+ watertight=watertight(Fb, len(Vb)))
3294
+ r["share"] = r["volume"] / V0 if V0 > 0 else 0.0
3295
+ rho = mats[0].density
3296
+ if mix:
3297
+ r["mix"], rho = _mix_job(g, b, mesh, mats, out_dir, f"{stem}-{_safe(b['name'])}", b.get("voxel") or {})
3298
+ r["density"] = rho
3299
+ r["mass"] = r["volume"] * rho / 1000.0
3300
+ bodies.append((b, r, mesh, mats))
3301
+ mans.append(_manifold(mesh))
3302
+ inter, overlap = [], 0.0
3303
+ for i in range(len(mans)):
3304
+ for j in range(i + 1, len(mans)):
3305
+ if mans[i] is None or mans[j] is None:
3306
+ continue
3307
+ both = mans[i] + mans[j]
3308
+ area = 0.5 * (mans[i].surface_area() + mans[j].surface_area() - both.surface_area())
3309
+ ov = (mans[i] ^ mans[j]).volume()
3310
+ overlap = max(overlap, ov)
3311
+ if area > 1e-6:
3312
+ inter.append({"bodies": [bodies[i][1]["name"], bodies[j][1]["name"]], "area": float(area)})
3313
+ total = sum(r["volume"] for _, r, _, _ in bodies)
3314
+ left = 0.0 if rest.is_empty() else float(rest.volume())
3315
+ rep = {"bodies": [r for _, r, _, _ in bodies], "interfaces": inter,
3316
+ "check": {"partVolume": V0, "bodyVolume": total, "unassignedVolume": left,
3317
+ "volumeError": (total + left - V0) / V0 if V0 else 0.0, "maxOverlap": float(overlap),
3318
+ "watertight": all(r.get("watertight", True) for _, r, _, _ in bodies)},
3319
+ "regionsMeshed": stats["regions"], "mass": sum(r["mass"] for _, r, _, _ in bodies)}
3320
+ warn = []
3321
+ if left > 1e-4 * max(V0, 1e-9):
3322
+ warn.append(f"{left:.1f} mm^3 of the part is in no material body: end with a body that has no "
3323
+ "solid or where, to take the rest")
3324
+ warn += [f"material body {r['name']!r} is empty (earlier bodies took all of its region)"
3325
+ for _, r, _, _ in bodies if r.get("empty")]
3326
+ if warn:
3327
+ rep["warnings"] = warn
3328
+ if out_dir:
3329
+ for _, r, mesh, _ in bodies:
3330
+ if mesh is not None:
3331
+ path = os.path.join(out_dir, f"{stem}-{_safe(r['name'])}.stl")
3332
+ mesh.export(path)
3333
+ r["stl"] = path
3334
+ try:
3335
+ from .threemf import write_multimaterial_3mf
3336
+ except ImportError as exc:
3337
+ rep["3mf"] = {"skipped": f"loadpath.threemf.write_multimaterial_3mf is not available ({exc})"}
3338
+ else:
3339
+ spec = []
3340
+ for b, r, mesh, mats in bodies:
3341
+ if mesh is None:
3342
+ continue
3343
+ d = {"name": r["name"], "mesh": mesh, "material": mats[0].name, "extruder": r["extruder"]}
3344
+ if "color" in b:
3345
+ d["color"] = b["color"]
3346
+ spec.append(d)
3347
+ path = os.path.join(out_dir, f"{stem}.3mf")
3348
+ try:
3349
+ res = write_multimaterial_3mf(path, spec, direction=doc.get("build"), name=stem,
3350
+ process=doc.get("process"))
3351
+ rep["3mf"] = path
3352
+ if isinstance(res, dict):
3353
+ rep["3mfReport"] = res
3354
+ except Exception as exc: # noqa: BLE001 - the STLs are written; say why the 3MF is not
3355
+ rep["3mf"] = {"error": f"{type(exc).__name__}: {exc}"}
3356
+ return rep
3357
+
3358
+
3359
+ # ---- the job ---------------------------------------------------------------------
3360
+
3361
+ def load_study(ref, base_dir=None, material=None):
3362
+ """The study a graph analyzes its part under: a JSON path (relative to
3363
+ base_dir) or "example:<name>". The graph's own `material`, when it has
3364
+ one, replaces the study's, so the part is checked in what it will be
3365
+ printed in (and its process takes that card's across-layer values)."""
3366
+ from .spec import study_from_dict
3367
+
3368
+ if ref.startswith("example:"):
3369
+ from .tasks import example
3370
+
3371
+ d, sdir = example(ref.split(":", 1)[1]), base_dir
3372
+ else:
3373
+ sp = ref if os.path.isabs(ref) else os.path.join(base_dir or ".", ref)
3374
+ with open(sp) as fh:
3375
+ d = json.load(fh)
3376
+ sdir = os.path.dirname(sp)
3377
+ if material is not None:
3378
+ d = {**d, "material": material}
3379
+ return study_from_dict(d, base_dir=sdir)
3380
+
3381
+
3382
+ def node_doc(doc, node):
3383
+ """The graph made to mesh one of its nodes instead of its output: groups
3384
+ expanded (so a node inside a copy is named <copy>__<node>), `output`
3385
+ set to `node` and the name suffixed with it, so its files don't
3386
+ overwrite the whole part's."""
3387
+ full = expand_groups(doc)
3388
+ if node not in (full.get("nodes") or {}):
3389
+ raise ValueError(f"no node {node!r} to mesh (have {', '.join(full.get('nodes') or {})})")
3390
+ return {**full, "output": node, "name": f"{doc.get('name', 'field')}-{node}"}
3391
+
3392
+
3393
+
3394
+
3395
+ def _lattice_variant(doc, g, solid, empty, base_dir, sources, meshes, progress, mesh_opts=None):
3396
+ """The part as meshed (drain holes and all: drain_graph added them to
3397
+ g) with the lattice nodes in `solid` made solid and those in `empty`
3398
+ gone: the envelope a STEP uses, one lattice's clip region, or the body
3399
+ beside the lattices. A drain bore is an exact cylinder through the
3400
+ skin, blind where its void is solid."""
3401
+ return _lattice_variant_graph(doc, g, solid, empty, base_dir, sources, meshes, mesh_opts).mesh(progress=progress)
3402
+
3403
+
3404
+ def _lattice_variant_graph(doc, g, solid, empty, base_dir, sources, meshes, mesh_opts=None, extra=None):
3405
+ """The FieldGraph behind _lattice_variant (and `extra` nodes added or
3406
+ replacing nodes)."""
3407
+ full = expand_groups(doc)
3408
+ box = {"op": "shape", "shape": {"type": "box", "min": [-1e4] * 3, "max": [1e4] * 3}}
3409
+ nodes = {}
3410
+ for k, nd in g.nodes.items():
3411
+ nodes[k] = dict(box) if k in solid else {"op": "constant", "value": 1e3} if k in empty else nd.spec
3412
+ nodes.update(extra or {})
3413
+ env = {**full, "nodes": nodes, "output": g.output}
3414
+ env.pop("drains", None)
3415
+ if mesh_opts:
3416
+ env["mesh"] = {**(env.get("mesh") or {}), **mesh_opts}
3417
+ pre = {k: (g._built[k], g.analyses.get(k)) for k, nd in g.nodes.items()
3418
+ if nd.op == "result" and k in g._built}
3419
+ return FieldGraph(env, base_dir=base_dir, sources={**(sources or {}), **g.sources}, meshes=meshes).seed(pre)
3420
+
3421
+
3422
+ def _lattice_3mf(doc, g, lattices, out_dir, name, opts, base_dir, sources, meshes, progress):
3423
+ """<name>-lattice.3mf: the part's lattices as lattices (lattice3mf.py),
3424
+ each clipped by its own region (the part with that lattice solid and
3425
+ the others gone), beside the rest of the part as a mesh. A lattice the
3426
+ graph blends into the body carries the blend (the level set is the
3427
+ smooth union of body and lattice). Meshes are decimated to `decimate`
3428
+ mm (default 0.02) of the true surface."""
3429
+ from .lattice3mf import (
3430
+ _grid,
3431
+ _quantize,
3432
+ blend_of,
3433
+ image,
3434
+ lattice_from_graph,
3435
+ write_lattice_3mf,
3436
+ )
3437
+
3438
+ mo = {"decimate": float(opts.get("decimate", 0.02))}
3439
+ body = _lattice_variant(doc, g, (), lattices, base_dir, sources, meshes, progress, mo)
3440
+ lat = []
3441
+ for k in lattices:
3442
+ clip = _lattice_variant(doc, g, (k,), [x for x in lattices if x != k], base_dir, sources, meshes,
3443
+ progress, mo)
3444
+ if not len(clip.faces):
3445
+ continue
3446
+ lo, hi = clip.bounds
3447
+ x = lattice_from_graph(g, k, lo, hi, opts.get("grid"))
3448
+ x["clip"] = clip
3449
+ b = blend_of(g, k)
3450
+ if isinstance(b, str): # a graded blend radius: sampled like the wall
3451
+ h = float(opts.get("grid") or 1.0)
3452
+ lo_, hi_ = lo - h, hi + h
3453
+ P, shape, size = _grid(lo_, hi_, h)
3454
+ v = np.maximum(np.broadcast_to(np.asarray(g._val(b, P, {}), float), (len(P),)), 1e-3)
3455
+ q, off, span = _quantize(v.reshape(shape))
3456
+ x.setdefault("images", {})[f"{k}-blend"] = (q, off, span)
3457
+ x["blend"] = image(f"{k}-blend", lo_, size)
3458
+ elif b is not None and float(b) > 0:
3459
+ x["blend"] = float(b)
3460
+ lat.append(x)
3461
+ path = os.path.join(out_dir, f"{name}-lattice.3mf")
3462
+ rep = write_lattice_3mf(path, body if len(body.faces) else None, lat, name=name,
3463
+ mesh_readers=bool(opts.get("meshReaders", False)))
3464
+ rep["note"] = ("lattices as lattices (implicit level sets, beam lattices), each clipped by its region; "
3465
+ "the body is a mesh; TPMS-to-body blend fillets are in the level set (mesh distance "
3466
+ "nodes), strut lattices meet the body sharp")
3467
+ for x, l_ in zip(rep["lattices"], lat):
3468
+ for k in ("cell", "thickness", "coordinates"):
3469
+ if k in l_:
3470
+ x[k] = l_[k]
3471
+ if "blend" in l_:
3472
+ x["blend"] = l_["blend"] if isinstance(l_["blend"], float) else "graded"
3473
+ return rep
3474
+
3475
+ def _envelope_step(doc, g, mesh, lattices, out_dir, name, opts, base_dir, sources, meshes, progress):
3476
+ """STEP for a part with lattices: the part with every lattice solid (a
3477
+ sandwich's skin, bosses and bores exactly as printed; the lattice is in
3478
+ the STL/3MF), since CAD has no use for a few hundred thousand lattice
3479
+ facets and they do not decimate. The report says how much of that
3480
+ outside is the printed part's own surface (all of it under a closed
3481
+ skin; less where the lattice is open)."""
3482
+ from .sharp import mesh_distance
3483
+ from .step import DEFAULT_FACES, DEFAULT_PATCHES, export_step
3484
+
3485
+ em = _lattice_variant(doc, g, lattices, (), base_dir, sources, meshes, progress)
3486
+ st = export_step(em, os.path.join(out_dir, f"{name}.step"), faces=int(opts.get("faces", DEFAULT_FACES)),
3487
+ name=name, surface=opts.get("surface", "facets"),
3488
+ patches=int(opts.get("patches", DEFAULT_PATCHES)))
3489
+ st.pop("shape", None)
3490
+ d = mesh_distance(mesh, em.sample(20000), cap=1.0)
3491
+ tol = max(0.05, 0.5 * float(mesh.metadata.get("pitch") or 0.1))
3492
+ st["lattice"] = {"export": "solid envelope", "lattices": lattices,
3493
+ "trueSurface": round(float((d < tol).mean()), 4),
3494
+ "envelopeVolume": round(float(em.volume), 1), "partVolume": round(float(mesh.volume), 1),
3495
+ "note": "lattices solid in the STEP (outside, bores and faces as printed); "
3496
+ "the printed part, lattice and all, is the STL"
3497
+ + ("; drain bores are blind there (their voids are solid)"
3498
+ if any(k.startswith("drain-bore-") for k in g.nodes) else "")}
3499
+ return st
3500
+
3501
+ def _write_graded(doc, rep, out_dir):
3502
+ """With the impact block's `grade`: the graph again with its density node
3503
+ replaced by the graded table (<name>-graded.json beside the STL), ready
3504
+ to mesh and re-check with `loadpath field`. File paths in it resolve
3505
+ against the graph's folder: pass --base-dir if it reads any."""
3506
+ imp, spec = rep.get("impact") or {}, doc.get("impact") or {}
3507
+ dens = spec.get("density")
3508
+ if not imp.get("graded") or not isinstance(dens, str) or dens not in (doc.get("nodes") or {}):
3509
+ return
3510
+ gd = json.loads(json.dumps(doc))
3511
+ gd["name"] = f"{rep['name']}-graded"
3512
+ gd["nodes"][dens] = imp["graded"]["density"]
3513
+ gd["impact"] = {**spec, "grade": False}
3514
+ path = os.path.join(out_dir, f"{gd['name']}.json")
3515
+ with open(path, "w") as fh:
3516
+ json.dump(gd, fh, indent=2)
3517
+ imp["gradedGraph"] = path
3518
+
3519
+
3520
+ def run_field(doc, out_dir=None, base_dir=None, sources=None, meshes=None, progress=None, render=True,
3521
+ prebuilt=None, node=None):
3522
+ """Build a field graph, mesh its output and report on it: volume,
3523
+ area, mass (with `material`), watertightness, bodies, and the printability
3524
+ check on the field itself (walls, overhangs, trapped voids) unless
3525
+ `check` is false. Writes <name>.stl (and <name>.step with `step`) to
3526
+ `out_dir`; with sharp booleans, `sharpEdges` says how exactly the mesh
3527
+ keeps the plain shapes' surfaces (sharp.edge_accuracy).
3528
+ `prebuilt` reuses resources another graph built (FieldGraph.seed);
3529
+ `node` meshes that node instead of the output (node_doc)."""
3530
+ import time
3531
+
3532
+ t0 = time.time()
3533
+ if node is not None:
3534
+ doc = node_doc(doc, node)
3535
+ g = FieldGraph(doc, base_dir=base_dir, sources=sources, meshes=meshes).seed(prebuilt)
3536
+ process = None
3537
+ if doc.get("process") is not None:
3538
+ from .spec import _process
3539
+
3540
+ process = _process(doc["process"])
3541
+ drains = None
3542
+ from .drains import DrainOptions, drain_graph, drains_wanted
3543
+
3544
+ if drains_wanted(doc.get("drains", "auto"), process):
3545
+ opts = DrainOptions.from_spec(doc.get("drains"))
3546
+ from . import quick
3547
+
3548
+ pitch = (doc.get("mesh") or {}).get("pitch")
3549
+ drains = drain_graph(g, process, opts, build=doc.get("build"), min_wall=doc.get("minWall"),
3550
+ pitch=pitch and quick.mesh_pitch(pitch, None), progress=progress)
3551
+ if (doc.get("mesh") or {}).get("stream"):
3552
+ return None, _run_streamed(doc, g, out_dir, drains, process, progress, t0)
3553
+ mesh = g.mesh(progress=progress)
3554
+ from .narrowband import shell_volumes, signed_volume, watertight
3555
+
3556
+ V, F = np.asarray(mesh.vertices), np.asarray(mesh.faces)
3557
+ rep = {"name": doc.get("name", "field"), "output": g.output, "nodes": len(g.nodes),
3558
+ "pitch": mesh.metadata.get("pitch"), "faces": int(len(mesh.faces)), "watertight": watertight(F, len(V)),
3559
+ "volume": signed_volume(V, F), "area": float(mesh.area),
3560
+ "bounds": [mesh.bounds[0].tolist(), mesh.bounds[1].tolist()]}
3561
+ if "decimation" in mesh.metadata:
3562
+ rep["decimation"] = mesh.metadata["decimation"]
3563
+ # shells: a body's outside, or the wall of a void sealed inside it (normals in)
3564
+ shells = shell_volumes(V, F)[1].tolist()
3565
+ rep["bodies"] = sum(v > 0 for v in shells)
3566
+ rep["enclosedVoids"] = sum(v < 0 for v in shells)
3567
+ rep["enclosedVoidVolume"] = -sum(v for v in shells if v < 0)
3568
+ if process is not None:
3569
+ rep["process"] = process.kind
3570
+ if drains is not None:
3571
+ # the mesh is the last word on whether anything is still sealed, and
3572
+ # for powder and resin processcheck's escape ball says whether it
3573
+ # gets out (LPBF has no card there: sealed shells only)
3574
+ ok = rep["enclosedVoids"] == 0
3575
+ if drains["holes"] or drains["filledVolume"]:
3576
+ from .processcheck import RULES, check_process
3577
+
3578
+ if process is not None and process.kind in RULES:
3579
+ try:
3580
+ esc = check_process(mesh, process, slicer=False)["escape"]
3581
+ drains["meshEscape"] = {k: esc[k] for k in ("enclosedCount", "enclosedVolumeMm3",
3582
+ "narrowExitCount", "narrowExitVolumeMm3")}
3583
+ ok = ok and esc["enclosedCount"] == 0 and esc["narrowExitCount"] == 0
3584
+ except Exception as exc: # noqa: BLE001 - a check never fails the job
3585
+ drains["meshEscape"] = {"error": str(exc)}
3586
+ drains["ok"] = bool(drains["ok"] and ok)
3587
+ rep["drains"] = drains
3588
+ if "material" in doc:
3589
+ from .materials import material_from_spec
3590
+
3591
+ mat = material_from_spec(doc["material"])
3592
+ rep["mass"] = rep["volume"] * mat.density / 1000.0
3593
+ rep["material"] = mat.name
3594
+ for key, mod, fn in (("impact", "impact", "impact_report"), ("vibration", "modal", "vibration_report"),
3595
+ ("largeDeflection", "nonlinear", "large_deflection_report")):
3596
+ if doc.get(key) is not None:
3597
+ if "material" not in doc:
3598
+ raise ValueError(f"field graph: '{key}' needs a material")
3599
+ import importlib
3600
+
3601
+ run = getattr(importlib.import_module(f".{mod}", __package__), fn)
3602
+ rep[key] = run(g, doc[key], mat)
3603
+ if g.analyses:
3604
+ # what each `result` node's study said of the part it analyzed
3605
+ rep["analyses"] = {k: {"part": g.nodes[k].spec.get("part"), "safetyFactor": a.get("safetyFactor"),
3606
+ "deflection": max((c["deflection"] for c in a.get("cases") or []), default=None),
3607
+ "cells": a.get("analysisCells")}
3608
+ for k, a in g.analyses.items() if k in g.nodes}
3609
+ if g.plan is not None:
3610
+ from .sharp import edge_accuracy, plan_refs
3611
+
3612
+ Fg = g.field()
3613
+ try:
3614
+ rep["sharpEdges"] = edge_accuracy(mesh, plan_refs(g.plan), boundary=lambda P: np.abs(Fg(P)) < 1e-6)
3615
+ except Exception as exc: # noqa: BLE001 - a measurement never fails the job
3616
+ rep["sharpEdges"] = {"error": str(exc)}
3617
+ if doc.get("check", True):
3618
+ from .checks.dfm import check_dfm
3619
+
3620
+ spec = {"minWall": float(doc.get("minWall", process.min_feature if process is not None else 0.8)),
3621
+ "build": doc.get("build", (0, 0, 1))}
3622
+ try:
3623
+ d = check_dfm(mesh, spec)
3624
+ rep["printability"] = d
3625
+ except Exception as exc: # noqa: BLE001 - a check never fails the job
3626
+ rep["printability"] = {"error": str(exc)}
3627
+ # strut lattices: how much strut length bridges at the build
3628
+ # direction (FDM prints a strut flatter than 45 degrees as a bridge;
3629
+ # powder and resin processes support them, so it is information there)
3630
+ from .homogenized import lattice_nodes
3631
+ from .struts import is_strut, lattice_printability
3632
+
3633
+ for lat in lattice_nodes(g, g.output):
3634
+ if not is_strut(g.nodes[lat].spec.get("type", "gyroid")):
3635
+ continue
3636
+ try:
3637
+ lp = lattice_printability(g, lat, build=doc.get("build", (0, 0, 1)))
3638
+ lp["matters"] = process is None or process.kind == "fdm"
3639
+ except Exception as exc: # noqa: BLE001
3640
+ lp = {"error": str(exc)}
3641
+ rep.setdefault("lattices", {})[lat] = lp
3642
+ if g.bodies and node is None:
3643
+ if out_dir:
3644
+ os.makedirs(out_dir, exist_ok=True)
3645
+ rep["materials"] = _material_report(g, doc, mesh, out_dir, rep["name"], progress)
3646
+ rep["mass"] = rep["materials"]["mass"] # the bodies' own materials, not `material`
3647
+ rep["seconds"] = round(time.time() - t0, 1)
3648
+ if out_dir:
3649
+ os.makedirs(out_dir, exist_ok=True)
3650
+ path = os.path.join(out_dir, f"{rep['name']}.stl")
3651
+ mesh.export(path)
3652
+ rep["stl"] = path
3653
+ _write_graded(doc, rep, out_dir)
3654
+ if doc.get("step"):
3655
+ from .step import DEFAULT_FACES, DEFAULT_PATCHES, export_step
3656
+
3657
+ opts = doc["step"] if isinstance(doc["step"], dict) else {}
3658
+ lat = [k for k, nd in g.nodes.items() if nd.op == "lattice"]
3659
+ try:
3660
+ if lat and opts.get("lattice") in ("exact", "cavities"):
3661
+ from .latticestep import export_cavity_step, export_lattice_step
3662
+
3663
+ fn = export_cavity_step if opts["lattice"] == "cavities" else export_lattice_step
3664
+ st, _ = fn(doc, g, out_dir, rep["name"], base_dir, sources, meshes,
3665
+ h=opts.get("latticeGrid"),
3666
+ surface=opts.get("surface", "smooth"), part_mesh=mesh,
3667
+ progress=progress, faces=opts.get("faces"),
3668
+ patches=opts.get("patches"))
3669
+ elif lat:
3670
+ st = _envelope_step(doc, g, mesh, lat, out_dir, rep["name"], opts, base_dir, sources, meshes, progress)
3671
+ else:
3672
+ st = export_step(mesh, os.path.join(out_dir, f"{rep['name']}.step"),
3673
+ faces=int(opts.get("faces", DEFAULT_FACES)), name=rep["name"],
3674
+ surface=opts.get("surface", "facets"),
3675
+ patches=int(opts.get("patches", DEFAULT_PATCHES)))
3676
+ st.pop("shape", None)
3677
+ rep["step"] = st
3678
+ except Exception as exc: # noqa: BLE001 - the STL is the deliverable; say why STEP is missing
3679
+ rep["step"] = {"error": f"{type(exc).__name__}: {exc}"}
3680
+ lat = [k for k, nd in g.nodes.items() if nd.op == "lattice"]
3681
+ if doc.get("lattice3mf") and lat:
3682
+ opts = doc["lattice3mf"] if isinstance(doc["lattice3mf"], dict) else {}
3683
+ try:
3684
+ rep["lattice3mf"] = _lattice_3mf(doc, g, lat, out_dir, rep["name"], opts, base_dir, sources,
3685
+ meshes, progress)
3686
+ except Exception as exc: # noqa: BLE001 - the STL is the deliverable; say why this is missing
3687
+ rep["lattice3mf"] = {"error": f"{type(exc).__name__}: {exc}"}
3688
+ if render:
3689
+ try:
3690
+ from .render import render_views
3691
+
3692
+ img = os.path.join(out_dir, f"{rep['name']}.png")
3693
+ render_views(mesh, img, title=rep["name"])
3694
+ rep["render"] = img
3695
+ except Exception: # noqa: BLE001 - renders are optional
3696
+ pass
3697
+ return mesh, rep