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/schema.py ADDED
@@ -0,0 +1,1701 @@
1
+ """
2
+ JSON Schemas for everything loadpath reads, and a linter that reports every
3
+ problem in a document at once, by path, with a suggestion where it can make
4
+ one.
5
+
6
+ python -m loadpath schema study # the study, as JSON Schema 2020-12
7
+ python -m loadpath schema heatExchanger # a heat exchanger job
8
+ python -m loadpath schema lattice # lattice infill options
9
+ python -m loadpath schema field # a field graph (fields.py)
10
+
11
+ The schemas are what an agent (or an editor) validates against before it
12
+ asks for a solve; `lint` is what `check` runs first, so a study with three
13
+ mistakes gets three messages instead of one per round trip. Unknown keys are
14
+ errors when they look like a misspelling of a known one (`keepout`,
15
+ `radius`) and warnings otherwise: a misspelt `keepOut` silently dropping
16
+ every keep-out is the worst way a study can go wrong.
17
+
18
+ The parsers (spec.py, heatx.py, lattice.py) stay the authority on meaning;
19
+ these schemas describe the same fields, and tests/test_schema.py checks every
20
+ example and eval case against them. Add a field here when you add it there.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import copy
25
+ import difflib
26
+ import json
27
+ import re
28
+
29
+ from .material_library import LIBRARY as _LIBRARY
30
+
31
+ __all__ = ["STUDY_SCHEMA", "HEAT_EXCHANGER_SCHEMA", "LATTICE_SCHEMA", "FIELD_SCHEMA", "SCHEMAS", "schema", "lint", "format_issues"]
32
+
33
+ _DIALECT = "https://json-schema.org/draft/2020-12/schema"
34
+
35
+ _VEC3 = {"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3,
36
+ "description": "[x, y, z]"}
37
+ _POS = {"type": "number", "exclusiveMinimum": 0}
38
+ _AXIS = {"anyOf": [{"type": "string", "enum": ["x", "y", "z"]}, _VEC3],
39
+ "description": "'x', 'y', 'z' or a direction vector"}
40
+ _SIGNED_AXIS = {"anyOf": [{"type": "string", "pattern": "^[+-]?[xyz]$", "description": "like '-x' or '+z'"}, _VEC3],
41
+ "description": "'+x' ... '-z' or a direction vector"}
42
+
43
+ _SHAPES = {
44
+ "box": {
45
+ "type": "object",
46
+ "description": "Axis-aligned box: min/max corners, or center/size.",
47
+ "properties": {"type": {"const": "box"}, "min": _VEC3, "max": _VEC3, "center": _VEC3, "size": _VEC3},
48
+ "required": ["type"],
49
+ "x-requireOne": [["min", "max"], ["center", "size"]],
50
+ "additionalProperties": False,
51
+ },
52
+ "cylinder": {
53
+ "type": "object",
54
+ "description": "Cylinder or tube: end points p0/p1, or center/axis/length; radius r, optional rInner.",
55
+ "properties": {"type": {"enum": ["cylinder", "tube"]}, "p0": _VEC3, "p1": _VEC3, "center": _VEC3,
56
+ "axis": _AXIS, "length": _POS, "r": _POS,
57
+ "rInner": {"type": "number", "minimum": 0}},
58
+ "required": ["type", "r"],
59
+ "x-requireOne": [["p0", "p1"], ["center", "length"]],
60
+ "additionalProperties": False,
61
+ },
62
+ "sphere": {
63
+ "type": "object",
64
+ "properties": {"type": {"const": "sphere"}, "center": _VEC3, "r": _POS},
65
+ "required": ["type", "center", "r"],
66
+ "additionalProperties": False,
67
+ },
68
+ "mesh": {
69
+ "type": "object",
70
+ "description": "A closed mesh: an STL/3MF/OBJ path relative to the study's folder (or a STEP file, "
71
+ ".step/.stp, tessellated and healed; needs loadpath[step]), or inline stlBase64.",
72
+ "properties": {"type": {"const": "mesh"}, "path": {"type": "string"}, "stlBase64": {"type": "string"}},
73
+ "required": ["type"],
74
+ "x-requireOne": [["path"], ["stlBase64"]],
75
+ "additionalProperties": False,
76
+ },
77
+ "union": {
78
+ "type": "object",
79
+ "description": "Several shapes acting as one region (a servo's four screw bosses).",
80
+ "properties": {"type": {"const": "union"},
81
+ "shapes": {"type": "array", "items": {"$ref": "#/$defs/shape"}, "minItems": 1}},
82
+ "required": ["type", "shapes"],
83
+ "additionalProperties": False,
84
+ },
85
+ "intersect": {
86
+ "type": "object",
87
+ "description": "Where all the shapes overlap: a half ring is a tube intersected with a box.",
88
+ "properties": {"type": {"const": "intersect"},
89
+ "shapes": {"type": "array", "items": {"$ref": "#/$defs/shape"}, "minItems": 2}},
90
+ "required": ["type", "shapes"],
91
+ "additionalProperties": False,
92
+ },
93
+ "subtract": {
94
+ "type": "object",
95
+ "description": "The first shape with the others cut away: a ring with a gap is a tube minus a box.",
96
+ "properties": {"type": {"const": "subtract"},
97
+ "shapes": {"type": "array", "items": {"$ref": "#/$defs/shape"}, "minItems": 2}},
98
+ "required": ["type", "shapes"],
99
+ "additionalProperties": False,
100
+ },
101
+ }
102
+ _SHAPE_TYPES = {"box": "box", "cylinder": "cylinder", "tube": "cylinder", "sphere": "sphere",
103
+ "mesh": "mesh", "union": "union", "intersect": "intersect", "subtract": "subtract"}
104
+
105
+ _SHAPE = {
106
+ "title": "a shape object (box, cylinder, tube, sphere, mesh, union, intersect, subtract)",
107
+ "description": "A shape: box, cylinder (tube), sphere, mesh, union, intersect or subtract. Units mm.",
108
+ "oneOf": [{"$ref": f"#/$defs/{k}"} for k in _SHAPES],
109
+ "x-discriminator": {"property": "type", "mapping": {k: f"#/$defs/{v}" for k, v in _SHAPE_TYPES.items()}},
110
+ }
111
+
112
+ _REGION = {
113
+ "type": "object",
114
+ "properties": {"id": {"type": "string", "description": "Name loads and supports refer to."},
115
+ "shape": {"$ref": "#/$defs/shape"}},
116
+ "required": ["shape"],
117
+ "additionalProperties": False,
118
+ }
119
+
120
+ _KEEP_OUT = {
121
+ "type": "object",
122
+ "properties": {**_REGION["properties"],
123
+ "bore": {"type": "boolean",
124
+ "description": "false: a clearance volume (a mating part, a sweep) that may end "
125
+ "against material; it is not checked for closed ends. Default true "
126
+ "(false for a pressurized cavity)."},
127
+ "wall": {**_POS, "description": "mm of material kept round it, pinned solid: a duct's or a "
128
+ "plenum's skin, with only what is behind it designed (ribs). "
129
+ "A pressurized keep-out gets minMember if not set."}},
130
+ "required": ["shape"],
131
+ "additionalProperties": False,
132
+ }
133
+
134
+ _TARGET = ("A 'region' (a preserve or keepOut id, or one a fastener adds such as 'w1-bore') "
135
+ "or an inline 'shape'.")
136
+
137
+ _SUPPORT = {
138
+ "type": "object",
139
+ "description": "Where the part is held. " + _TARGET,
140
+ "properties": {
141
+ "region": {"type": "string"},
142
+ "shape": {"$ref": "#/$defs/shape"},
143
+ "fix": {"anyOf": [{"type": "string", "pattern": "^(all|pin|[xyz]{1,3})$",
144
+ "description": "'all', 'pin', or the axes held, like 'xz'"},
145
+ {"type": "array", "items": {"type": "boolean"}, "minItems": 3, "maxItems": 3}],
146
+ "description": "'all' (default), 'pin' (a cylinder: turns freely), axes like 'xz', or 3 booleans"},
147
+ },
148
+ "x-requireOne": [["region"], ["shape"]],
149
+ "additionalProperties": False,
150
+ }
151
+
152
+ _LOAD = {
153
+ "type": "object",
154
+ "description": "A force (N) and/or a moment (N·mm, about the region's centroid), or a pressure (MPa) "
155
+ "in a keepOut. " + _TARGET,
156
+ "properties": {
157
+ "region": {"type": "string"},
158
+ "shape": {"$ref": "#/$defs/shape"},
159
+ "force": _VEC3,
160
+ "moment": _VEC3,
161
+ "torque": {**_VEC3, "description": "Alias of moment."},
162
+ "distribution": {"enum": ["uniform", "bearing"],
163
+ "description": "'bearing' spreads a force over the half of a bore it pushes on"},
164
+ "pressure": {"type": "number",
165
+ "description": "MPa gauge (1 bar = 0.1 MPa) inside the keepOut named by 'region' (the fluid's "
166
+ "volume), on every wall lining it; negative is a vacuum. A study whose only "
167
+ "loads are pressures on sealed cavities needs no supports."},
168
+
169
+ "direction": {"enum": ["any", "xy", "xz", "yz"],
170
+ "description": "'any': the force's size may point in any direction (contact, bumps, "
171
+ "handling); the part is designed and checked for the worst one, found "
172
+ "exactly from three unit-force solves. At most one per load case; a bearing "
173
+ "load's uncertain force is spread evenly over the region. 'xy', 'xz' or "
174
+ "'yz': any direction in that plane (a shaft's radial load, a swinging "
175
+ "strap, a sideways crash): the case becomes cases 22.5 degrees "
176
+ "apart (within 2% of the worst), named case@angle: eight over half "
177
+ "the plane, or sixteen over all of it when -F is not the mirror of F "
178
+ "(bearing distribution, other loads, a moment, a layered process); a moment on the same "
179
+ "load turns with the force (right when its lever arm is along the "
180
+ "plane's normal)."},
181
+ "cone": {"type": "number", "exclusiveMinimum": 0, "maximum": 180,
182
+ "description": "Degrees: the force may tilt up to this far from its written direction, and the "
183
+ "worst tilt is designed for (180 is the same as direction 'any')."},
184
+ "mass": {**_POS, "description": "kg carried through this region (a payload, the next link, a camera): "
185
+ "adds mass x 9.81 x dynamic N along 'gravity', and with 'at' the moment "
186
+ "of that force about the region (N·mm), so you never convert by hand."},
187
+ "at": {**_VEC3, "description": "mm: where the force acts or the mass's centre sits (default: on the "
188
+ "region): a pulley in front of a motor face, a tool's centre of mass. "
189
+ "Its offset from the region's centre adds the moment; `check` echoes "
190
+ "the resulting force and moment per case."},
191
+ "dynamic": {"type": "number", "minimum": 1,
192
+ "description": "Multiplier on the mass's weight: 1 static, 2 a moving robot joint, "
193
+ "3 anything that can be dropped or crash (default 1)."},
194
+ "gravity": {**_SIGNED_AXIS, "description": "Which way the mass pulls: '-z' (default), or a vector."},
195
+ },
196
+ "x-requireOne": [["region"], ["shape"]],
197
+ "additionalProperties": False,
198
+ }
199
+
200
+ _LOAD_CASE = {
201
+ "type": "object",
202
+ "properties": {"name": {"type": "string"},
203
+ "loads": {"type": "array", "items": {"$ref": "#/$defs/load"}, "minItems": 1},
204
+ "weight": {"type": "number", "minimum": 0},
205
+ "cycles": {**_POS, "description": "Times this whole case is applied over the part's life (set on the case, not a load): adds a "
206
+ "fatigue check (e.g. 1e6 for a servo-driven arm)."},
207
+ "hours": {**_POS, "description": "Hours this load is held over the part's life: adds creep "
208
+ "rupture and crept deflection."},
209
+ "temperature": {"type": "number",
210
+ "description": "C while this load acts (default durability.temperature)."}},
211
+ "required": ["loads"],
212
+ "additionalProperties": False,
213
+ }
214
+
215
+ _MATERIAL_NAMES = sorted(_LIBRARY)
216
+ _CUSTOM_MATERIAL = {
217
+ "type": "object",
218
+ "description": "A custom material: E (MPa), nu, strength (MPa), density (g/cm³); or \"base\": a library "
219
+ "name and only the values that differ. EZ and strengthZ are across-layer values, used when "
220
+ "the study's process is the one named in \"measured\".",
221
+ "properties": {"name": {"type": "string"}, "base": {"type": "string"},
222
+ "E": _POS, "nu": {"type": "number", "minimum": 0, "maximum": 0.4999},
223
+ "strength": _POS, "density": _POS, "EZ": _POS, "strengthZ": _POS,
224
+ "measured": {"type": "string"}, "note": {"type": "string"}},
225
+ "x-requireOne": [["E", "strength", "density"], ["base"]],
226
+ "additionalProperties": False,
227
+ }
228
+ _ONE_MATERIAL = {"anyOf": [{"type": "string", "description": "A name: " + ", ".join(_MATERIAL_NAMES)
229
+ + " (aliases such as 'nylon', 'aluminium', 'steel' work; every value is cited in "
230
+ "material_library.py, `loadpath materials`), or a calibrated card measured on a "
231
+ "printer (loadpath/calibrations/, calibration.py), which replaces a library card "
232
+ "of the same name."},
233
+ _CUSTOM_MATERIAL]}
234
+
235
+ _FASTENER = {
236
+ "type": "object",
237
+ "description": "A bolted joint as an interface: bearing rings or a thread sleeve are preserved, the bore "
238
+ "and head path kept clear, and a preload case added. Adds regions <id>-bore, <id>-head, "
239
+ "<id>-seat, <id>-face or <id>-thread (and <id>-tap).",
240
+ "properties": {
241
+ "id": {"type": "string"},
242
+ "size": {"enum": ["M2", "M2.5", "M3", "M4", "M5", "M6", "M8", "M10"]},
243
+ "seat": {**_VEC3, "description": "Centre of the face the head (or the mating part) bears on."},
244
+ "direction": {**_SIGNED_AXIS, "description": "From the seat into the part, the way the screw goes."},
245
+ "grip": {"type": "number", "minimum": 0, "description": "mm from the seat to the far end (0 for a thread or insert starting right at the seat)."},
246
+ "end": {"enum": ["face", "thread", "none"]},
247
+ "headOn": {"enum": ["this", "mating"]},
248
+ "threadLength": {**_POS, "description": "end 'thread': engagement length past the grip (default 2x the "
249
+ "screw's diameter)."},
250
+ "insert": {**_POS, "description": "Heat-set insert or nut outer diameter, mm; else a tapped hole."},
251
+ "preload": {"type": "number", "minimum": 0,
252
+ "description": "N. Default: a quarter of what the seat can bear in this material, at most "
253
+ "what a class 8.8 screw is tightened to."},
254
+ "hole": {**_POS, "description": "Clearance hole diameter, mm (default from size)."},
255
+ "head": {**_POS, "description": "Head (or washer) diameter, mm (default from size: the ISO 4762 socket "
256
+ "head, e.g. M3 5.5, M4 7, M5 8.5)."},
257
+ "counterbore": {**_POS, "description": "Diameter of the head and driver keep-out, mm: the counterbore "
258
+ "the head sits in (default the seat ring's width, at least head + 1)."},
259
+ "diameter": {**_POS, "description": "The screw's nominal diameter, mm, when not given as size."},
260
+ "wall": {**_POS, "description": "mm of seat ring beyond the head's radius (default 15% of the "
261
+ "diameter, at least 1). The head and driver keep-out is as wide as "
262
+ "that ring, at least the head's radius + 0.5 mm."},
263
+ "ring": {**_POS, "description": "Seat and face ring thickness along the screw, mm (default 40% of the "
264
+ "diameter, at least 2). Bosses a brief asks for go in preserve."},
265
+ "sleeveWall": {**_POS, "description": "end 'thread': wall of the sleeve round the tap, mm."},
266
+ "driver": {"type": "number", "minimum": 0,
267
+ "description": "Length of the head and driver keep-out back from the seat, mm (default 60). "
268
+ "Shorten it where it would cut through another interface."},
269
+ },
270
+ "required": ["seat", "direction", "grip"],
271
+ "x-requireOne": [["size"], ["diameter", "head"], ["hole", "head"]],
272
+ "additionalProperties": False,
273
+ }
274
+
275
+ _GOAL = {
276
+ "type": "object",
277
+ "description": "volumeFraction keeps that share of the design space; safetyFactor and/or maxDeflection "
278
+ "search for the lightest part that meets them in every load case.",
279
+ "properties": {
280
+ "volumeFraction": {"type": "number", "minimum": 0.02, "maximum": 0.95},
281
+ "safetyFactor": _POS,
282
+ "maxDeflection": {**_POS, "description": "mm, at the loads"},
283
+ "maxMass": {**_POS, "description": "g: a cap the delivered part is checked against (deliver.passes is "
284
+ "false over it). The search already returns the lightest part that "
285
+ "meets the other limits, so over the cap means a lighter material, "
286
+ "smaller preserves or a lower safety factor."},
287
+ "method": {"enum": ["search", "constrained", "auto"]},
288
+ "confirm": {"enum": ["auto", "voxel", "tet"],
289
+ "description": "How the lightest passing part is confirmed: voxel (a finer voxel grid), tet "
290
+ "(a body-fitted tetrahedral mesh, which also calibrates the search's voxel "
291
+ "stress so it steps down to the real target), or auto (tet when installed and "
292
+ "applicable)."},
293
+ "outcomes": {"type": "array", "items": {"type": "number", "minimum": 0.02, "maximum": 0.95},
294
+ "description": "Extra fixed volume fractions for a trade-off gallery."},
295
+ "restarts": {"type": "integer", "minimum": 1, "maximum": 8,
296
+ "description": "Run the safety-factor/deflection search this many times from different "
297
+ "starting fractions and deliver the lightest confirmed part (default 1). One "
298
+ "search lands in the local optimum its start leads to (7-17% apart in "
299
+ "delivered mass on the eval parts); worth it for a final production part, at "
300
+ "N times the time."},
301
+ "minimize": {"enum": ["mass", "printTime", "filament"],
302
+ "description": "Which passing outcome is delivered: the lightest (default), or (FDM, with a "
303
+ "slicer installed) the one that prints fastest or with the least filament, "
304
+ "support included, as sliced (graded, with slicer.grade)."},
305
+ "buckling": {"type": "boolean",
306
+ "description": "Whether the safety factor must hold against buckling too (default true): "
307
+ "the part's linear buckling load factor, on a grid of at most 12k cells, "
308
+ "must reach safetyFactor. false still reports it (report.buckling)."},
309
+ },
310
+ "x-requireOne": [["volumeFraction"], ["safetyFactor"], ["maxDeflection"]],
311
+ "additionalProperties": False,
312
+ }
313
+
314
+ _DURABILITY = {
315
+ "type": "object",
316
+ "description": "Fatigue (load cases with cycles), creep (with hours) and bolt-preload relaxation, reported "
317
+ "under 'durability' in verification. Estimates from published printed-polymer data; see "
318
+ "loadpath/durability.py for sources and uncertainty.",
319
+ "properties": {
320
+ "temperature": {"type": "number", "description": "Service temperature, C (default 23)."},
321
+ "safetyFactor": {**_POS, "description": "Target fatigue/creep safety factor, on the loads (default 1.5; "
322
+ "the fatigue curve is already a >90% survival design curve)."},
323
+ "constrain": {"type": "boolean", "description": "true: the safety-factor search also sizes the part for "
324
+ "fatigue and creep (default false: report only)."},
325
+ "preloadHours": {**_POS, "description": "Hours fasteners hold their preload, for relaxation (default 8760)."},
326
+ "fatigueStrength": {**_POS, "description": "Override: max stress survived for 2e6 cycles, as a share of "
327
+ "the material's strength."},
328
+ "fatigueSlope": {**_POS, "description": "Override: S-N inverse slope k (life ~ stress^-k)."},
329
+ "creepRatio": {"type": "number", "minimum": 0,
330
+ "description": "Override: creep strain / elastic strain after 1000 h at 23 C."},
331
+ "creepExponent": {**_POS, "description": "Override: n in creep ~ time^n."},
332
+ },
333
+ "additionalProperties": False,
334
+ }
335
+
336
+ _THERMAL_REGION = {
337
+ "region": {"type": "string"},
338
+ "shape": {"$ref": "#/$defs/shape"},
339
+ }
340
+
341
+ _THERMAL = {
342
+ "type": "object",
343
+ "description": "Heat in the part, applied in every load case: the temperature is solved on the part itself "
344
+ "(conduction through the material, convection off its surface), each cell softens by the "
345
+ "material's modulus retention at its temperature, and expansion that the supports or the "
346
+ "part's own cooler material stop becomes stress. The optimizer designs for it and "
347
+ "verification checks it (stress with the free expansion taken off, strength derated as the "
348
+ "modulus is). Needs a way for heat out: a fixed temperature or convection.",
349
+ "properties": {
350
+ "ambient": {"type": "number", "description": "C of the surroundings (default 25)."},
351
+ "stressFree": {"type": "number",
352
+ "description": "C at which the part fits without stress, e.g. when bolted up (default "
353
+ "ambient)."},
354
+ "temperatures": {"type": "array", "description": "Surfaces held at a temperature: a motor face, a "
355
+ "heated bed, a cold plate. " + _TARGET,
356
+ "items": {"type": "object", "properties": {**_THERMAL_REGION, "t": {
357
+ "type": "number", "description": "C"}},
358
+ "required": ["t"], "x-requireOne": [["region"], ["shape"]],
359
+ "additionalProperties": False}},
360
+ "heat": {"type": "array", "description": "Heat flowing in. " + _TARGET,
361
+ "items": {"type": "object", "properties": {
362
+ **_THERMAL_REGION,
363
+ "watts": {**_POS, "description": "W into the region"},
364
+ "spread": {"enum": ["surface", "volume"],
365
+ "description": "'surface' (default): through the region's surface nodes; "
366
+ "'volume': generated in the region's cells (a coil, a "
367
+ "resistor potted in)."}},
368
+ "required": ["watts"], "x-requireOne": [["region"], ["shape"]],
369
+ "additionalProperties": False}},
370
+ "convection": {"anyOf": [
371
+ {"type": "number", "minimum": 0, "description": "W/(m² K) off every exposed surface"},
372
+ {"type": "array", "items": {"type": "object", "properties": {
373
+ **_THERMAL_REGION,
374
+ "h": {"type": "number", "minimum": 0,
375
+ "description": "W/(m² K): ~5-10 still air, 20-50 a fan, 500+ water"},
376
+ "fluid": {"type": "number", "description": "C of the fluid (default ambient)"}},
377
+ "required": ["h"], "additionalProperties": False}}],
378
+ "description": "Heat carried off the surface the design exposes (default 8 W/(m² K), still air). "
379
+ "An entry without region or shape covers every surface."},
380
+ "conductivity": {**_POS, "description": "W/(m K) override (default the material card's)."},
381
+ "expansion": {"anyOf": [{"type": "boolean"}, {"type": "number", "minimum": 0}],
382
+ "description": "true (default): the material card's expansion coefficient; a number: 1/K "
383
+ "(e.g. 7e-5); false: temperature softens the part but does not grow it."},
384
+ "soften": {"type": "boolean",
385
+ "description": "true (default): moduli and strength fall with temperature (the material "
386
+ "card's retention curve)."},
387
+ "specificHeat": {**_POS, "description": "J/(kg K) override (default the material card's); with the "
388
+ "density it sets how fast the part heats and cools in a cycle."},
389
+ "cycle": {"type": "object",
390
+ "description": "A duty cycle instead of steady heat: the heat inputs are on for the first 'on' "
391
+ "seconds of every 'period'. The repeating temperature cycle is solved; the part "
392
+ "is designed for its hot moment, checked at its hot and cold moments, and each "
393
+ "cycle counts toward thermal-cycling fatigue (durability).",
394
+ "properties": {
395
+ "period": {**_POS, "description": "s per cycle"},
396
+ "on": {**_POS, "description": "s the heat is on in each cycle (less than period)"},
397
+ "cycles": {**_POS, "description": "cycles over the part's life, for fatigue (e.g. 3e4: "
398
+ "ten a day for ten years)"},
399
+ "steps": {"type": "integer", "minimum": 8,
400
+ "description": "time steps per period (default 48)"},
401
+ "fatigueWeight": {"type": "number", "minimum": 0,
402
+ "description": "How much the optimizer also designs against the "
403
+ "hot-to-cold swing (thermal-cycling fatigue): a p-norm of "
404
+ "the swing's stress over strength retention, weighted "
405
+ "against the hot moment's objective at the start. 0 "
406
+ "(default): hot moment only. Experimental: it helped the "
407
+ "motor mount at 12k cells but not at 45k, so verify the "
408
+ "result. Adding material does not help "
409
+ "thermal cycling (stiffer parts hold more expansion)."},
410
+ "fatigueSafetyFactor": {"type": "number", "exclusiveMinimum": 0,
411
+ "description": "Design against thermal-cycling fatigue as a "
412
+ "constraint: the optimizer (MMA) holds the "
413
+ "hot-to-cold swing's stress under the material's "
414
+ "fatigue strength at `cycles`, over this safety "
415
+ "factor, while it minimizes the hot objective. "
416
+ "Needs cycles; not with fatigueWeight. "
417
+ "Experimental: motor mount at 12k cells, SF 3.5 "
418
+ "took the verified fatigue SF 2.67 -> 3.00 at equal "
419
+ "hot SF, but at 45k it came out lower (2.45 -> "
420
+ "2.05), so verify the result."}},
421
+ "required": ["period", "on"], "additionalProperties": False},
422
+ },
423
+ "additionalProperties": False,
424
+ }
425
+
426
+ _GRADE_LEVEL = {"anyOf": [
427
+ {"type": "array", "prefixItems": [{"type": "number", "exclusiveMinimum": 0, "exclusiveMaximum": 100},
428
+ {"type": "integer", "minimum": 1}], "minItems": 2, "maxItems": 2,
429
+ "description": "[infill %, perimeters]"},
430
+ {"type": "object", "properties": {"infill": {"type": "number", "exclusiveMinimum": 0, "exclusiveMaximum": 100},
431
+ "perimeters": {"type": "integer", "minimum": 1}},
432
+ "required": ["infill"], "additionalProperties": False}]}
433
+
434
+ _SLICER = {"anyOf": [
435
+ {"type": "boolean"},
436
+ {"type": "object",
437
+ "properties": {
438
+ "enabled": {"anyOf": [{"type": "boolean"}, {"const": "auto"}],
439
+ "description": "auto (default): slice when a slicer is installed; true: warn when none is"},
440
+ "executable": {"type": "string", "description": "PrusaSlicer (or SuperSlicer) executable (default: "
441
+ "$LOADPATH_SLICER, else found on PATH)"},
442
+ "profile": {"type": "string", "description": "PrusaSlicer config .ini (File > Export > Export Config) for "
443
+ "your printer's real speeds; relative to the study file"},
444
+ "settings": {"type": "object", "description": "PrusaSlicer config keys over the profile, like "
445
+ "{\"layer_height\": 0.25}"},
446
+ "supports": {"anyOf": [{"type": "boolean"}, {"const": "auto"}],
447
+ "description": "auto/true (default): the slicer's automatic supports; false: none"},
448
+ "infill": {"type": "number", "minimum": 0, "maximum": 100,
449
+ "description": "% infill of the plain slice (default 100: the part is verified solid)"},
450
+ "timeout": _POS,
451
+ "grade": {"anyOf": [{"type": "boolean"}, {"type": "object", "properties": {
452
+ "levels": {"type": "array", "items": _GRADE_LEVEL, "minItems": 1,
453
+ "description": "infill ladder, sparsest first; solid is always the level above "
454
+ "(default [[10,3],[15,3],[25,3],[40,3],[60,3]]: walls that change per region make the slicer run perimeters round each region, inside the part)"},
455
+ "safetyFactor": {**_POS, "description": "default: the goal's, else 2"},
456
+ "lineWidth": {**_POS, "description": "mm per perimeter (default 0.45)"},
457
+ "collar": {"type": "number", "minimum": 0,
458
+ "description": "mm kept solid round preserves, keep-outs, loads, supports (default 2)"},
459
+ "knockdown": {"type": "number", "exclusiveMinimum": 0, "maximum": 1,
460
+ "description": "infill wall strength / solid (default 0.8)"},
461
+ "analysisCells": {"type": "integer", "minimum": 1000}},
462
+ "additionalProperties": False}],
463
+ "description": "Size per-region infill density and perimeters from the stress, verified on a "
464
+ "homogenized model, and write them as slicer modifiers in a graded 3MF"}},
465
+ "additionalProperties": False}],
466
+ "description": "FDM: slice each outcome headless (PrusaSlicer) for print time, filament and support "
467
+ "(report 'slicer'); optionally grade infill by stress (report 'graded', <name>-graded.3mf)."}
468
+
469
+ _BUILD_SIM = {"anyOf": [
470
+ {"type": "boolean"},
471
+ {"type": "object", "properties": {
472
+ "layer": {**_POS, "description": "mm per analysis layer (lumped real layers; default one voxel)"},
473
+ "plate": {"type": "number", "minimum": 0, "description": "lpbf: mm of build plate modelled under the part "
474
+ "(default 10; 0 = rigid)"},
475
+ "scale": {**_POS, "description": "multiplies the inherent strain (a calibration for your machine)"},
476
+ "strain": {**_VEC3, "description": "inherent strain (exx, eyy, ezz) in the build frame, overriding "
477
+ "the material model (negative = shrink)"},
478
+ "release": {"enum": ["full", "none"],
479
+ "description": "full: cut off the plate / peeled off the bed and free (default); none: as built"},
480
+ "bedTemperature": {"type": "number", "description": "fdm: C (default per material)"},
481
+ "chamberTemperature": {"type": "number", "description": "fdm: C, air round the part while printing"},
482
+ "bedStiffness": {"type": "number", "minimum": 0,
483
+ "description": "fdm: N/mm^3 the bed holds the first layer with (default: the value fitted "
484
+ "to Armillotta 2018; omit for that)"},
485
+ "relax": {"type": "number", "exclusiveMinimum": 0, "maximum": 1,
486
+ "description": "fdm: share of the shrink below Tg kept as stress"},
487
+ "bedAdhesion": {"type": "number", "exclusiveMinimum": 0,
488
+ "description": "fdm: MPa of bond tension the first layer holds before it lets go of the "
489
+ "bed (corners lift and the part re-balances; default: the value fitted to "
490
+ "Armillotta 2018)"},
491
+ "printLayer": {"type": "number", "minimum": 0,
492
+ "description": "fdm: the printed layer height, mm (default 0.2). Warp grows as layers "
493
+ "thin, so builds simulated at thicker voxel layers are extrapolated to "
494
+ "it; 0 turns that off"},
495
+ "plateCells": {"type": "integer", "minimum": 1,
496
+ "description": "lpbf: voxel layers modelling the build plate, its stiffness scaled to the "
497
+ "full plate (default 2; the default k is fitted with it)"},
498
+ "recoaterGap": {**_POS, "description": "lpbf: mm of lift at the build plane that counts as recoater "
499
+ "contact risk (default 0.05)"},
500
+ "compensate": {"type": "boolean", "description": "also write <name>-compensated.stl, pre-distorted so "
501
+ "the print lands on the design"},
502
+ "iterations": {"type": "integer", "minimum": 1, "description": "compensation rounds (default 2)"},
503
+ "cells": {"type": "integer", "minimum": 1000, "description": "voxels in the simulated part (default 60000)"},
504
+ "warpWeight": {"type": "number", "minimum": 0,
505
+ "description": "lpbf/fdm: design against build distortion too. The optimizer minimizes "
506
+ "compliance * (1 + warpWeight * D / D0), D the mean squared as-built "
507
+ "displacement of the layer-by-layer build on the plate (D0 at the start); "
508
+ "0 (default) only verifies. Needs a grid-axis buildDirection. About 1 trades "
509
+ "stiffness for a large cut in distortion and recoater lift"},
510
+ "warpLayer": {**_POS, "description": "mm per analysis layer of the warp term (default 4 cells)"},
511
+ "maxDistortion": {**_POS, "description": "lpbf/fdm: a limit, mm, on the largest as-built displacement "
512
+ "(on the plate, end of the build). The optimizer holds it as a "
513
+ "constraint and the search only passes designs that meet it and "
514
+ "the safety factor; report 'warp'. Needs a grid-axis "
515
+ "buildDirection"},
516
+ "maxRecoaterLift": {**_POS, "description": "lpbf: a limit, mm, on how far the part rises at the build "
517
+ "plane as each layer goes down (the recoater's clearance, "
518
+ "~0.05 mm); held like maxDistortion"},
519
+ "brim": {"type": "number", "minimum": 0, "description": "fdm: brim width, mm (default 0): more bed hold under the footprint's "
520
+ "edge (an estimate, not validated)"},
521
+ "printSetup": {"oneOf": [{"type": "boolean"}, {"type": "object", "properties": {
522
+ "materials": {"type": "array", "items": {"type": "string"},
523
+ "description": "materials to try (default the study's)"},
524
+ "orientations": {"type": "array", "items": {"type": "string"},
525
+ "description": "build directions to try (default the six grid axes)"},
526
+ "bedTemperatures": {"type": "array", "items": {"type": "number"},
527
+ "description": "bed temperatures, C (default a few around each material's usual)"},
528
+ "brims": {"type": "array", "items": {"type": "number", "minimum": 0},
529
+ "description": "brim widths to try, mm (default [0, 5])"},
530
+ "maxBed": {"type": "number", "description": "the printer's hottest bed, C (default 110)"},
531
+ "reoptimize": {"type": "boolean", "description": "when no outcome passes a warp limit and no setup "
532
+ "rescues the shape as it is, re-run the study for "
533
+ "the best setup (default true)"}},
534
+ "additionalProperties": False}],
535
+ "description": "fdm: rank print setups (orientation, material, bed temperature, brim) for the "
536
+ "delivered part on strength and warp (report 'printSetup'; printsetup.py)"},
537
+ "warpInOptimizer": {"type": "boolean",
538
+ "description": "carry maxDistortion / maxRecoaterLift in the optimizer (default true "
539
+ "for lpbf; false for fdm, whose limits are checked on each outcome's "
540
+ "re-simulated part: in the optimizer they cost strength and did not "
541
+ "lower the warp)"},
542
+ "warpCoarsen": {"type": "integer", "minimum": 1,
543
+ "description": "the build model in the optimizer runs on a grid this many times "
544
+ "coarser (default 2 on grids of 4000 cells or more, else 1)"}},
545
+ "additionalProperties": False}],
546
+ "description": "Simulate the build of the delivered part (report 'buildSim'): layer-by-layer inherent-strain "
547
+ "residual stress and distortion for lpbf (validated on NIST AM-Bench 2018), fdm warp and bed "
548
+ "peel (ABS/ASA/PETG...), sls/mjf differential shrink; optionally a pre-distorted STL."}
549
+
550
+ _PROCESS_KINDS = ["fdm", "sls", "mjf", "sla", "dlp", "volumetric", "jetting", "lpbf"]
551
+ _PROCESS = {"anyOf": [
552
+ {"enum": _PROCESS_KINDS},
553
+ {"type": "object",
554
+ "properties": {"type": {"enum": _PROCESS_KINDS},
555
+ "buildDirection": {"anyOf": [
556
+ {"type": "string", "pattern": "^([+-]?[xyz]|auto)$", "description": "like '+z', or 'auto'"},
557
+ _VEC3], "description": "the direction layers stack: '+z', a vector, or 'auto'"},
558
+ "stiffnessAcrossLayers": {"type": "number", "exclusiveMinimum": 0, "maximum": 1},
559
+ "shearAcrossLayers": {"type": "number", "exclusiveMinimum": 0, "maximum": 1},
560
+ "latticeStiffness": {"anyOf": [{"type": "number", "exclusiveMinimum": 0, "maximum": 1.5},
561
+ {"enum": ["as-printed", "designed"]}],
562
+ "description": "printed lattice stiffness over the designed geometry's in "
563
+ "the homogenized check: 'as-printed' (default: fitted to 33 "
564
+ "published compression tests per process, lattice_print.py), "
565
+ "'designed' (1), or a measured factor"},
566
+ "anisotropic": {"anyOf": [{"type": "boolean"}, {"const": "bond"}]},
567
+ "interlayer": {"type": "number", "exclusiveMinimum": 0, "maximum": 1},
568
+ "minFeature": _POS, "overhangs": {"type": "boolean"},
569
+ "minHole": {**_POS, "description": "mm: narrowest hole or gap the optimizer may leave "
570
+ "(default FDM/SLS/MJF 2, SLA/DLP/LPBF 1, volumetric 0.6)"},
571
+ "bridgeSpan": {**_POS, "description": "mm: longest unsupported bridge the printer prints cleanly "
572
+ "(default: 15 FDM, 5 resin, 2 LPBF); longer spans count as needing support"},
573
+ "escapeHole": {**_POS, "description": "mm: smallest powder escape / resin drain hole "
574
+ "(default SLS 4, MJF 5, SLA/DLP 3.5)"}},
575
+ "required": ["type"], "additionalProperties": False},
576
+ ], "description": "How it is made: sets a minimum member, overhang control (FDM, LPBF metal) and the layer-bond check. "
577
+ "lpbf = laser powder-bed fusion metal (AlSi10Mg, Ti64, 316L). sls/mjf (PA12, PA11, PA12-GB, TPU) "
578
+ "and sla/dlp (resins) get process checks on the delivered part (report 'process': walls, trapped "
579
+ "powder or resin, warp-prone panels; resin supports, peel area and suction cups)."}
580
+
581
+ _LATTICE_PROPS = {
582
+ "type": {"enum": ["gyroid", "schwarz-p", "diamond"],
583
+ "description": "TPMS sheet (default gyroid). Per gram, diamond is the stiffest and strongest, "
584
+ "Schwarz P the weakest and has flat ceilings (FDM)."},
585
+ "cell": {**_POS, "description": "mm per TPMS period (at least 4 x maxWall)"},
586
+ "skin": {"type": "number", "minimum": 0, "description": "mm of solid shell; 0 leaves the lattice open. "
587
+ "Under a skin, SLS, MJF and resin parts get drain holes"},
588
+ "sizing": {"enum": ["homogenized", "solid-stress"],
589
+ "description": "homogenized (default): the core is analyzed as the lattice it will be and each "
590
+ "cell sized to safetyFactor; solid-stress: graded from the solid part's stress "
591
+ "(quick, but not a sizing)"},
592
+ "minWall": {**_POS, "description": "mm, where the part idles (at least the process's minFeature)"},
593
+ "maxWall": {**_POS, "description": "mm; homogenized cells that would need more turn solid"},
594
+ "solidAbove": {"type": "number", "exclusiveMinimum": 0, "maximum": 1,
595
+ "description": "(solid-stress) utilization (stress / allowable) above which material stays solid"},
596
+ "safetyFactor": {**_POS, "description": "target safety factor of the lattice walls and skin "
597
+ "(default: the study goal's, else 2)"},
598
+ "collar": {"type": "number", "minimum": 0,
599
+ "description": "mm of solid round preserved shapes, keep-outs, supports and loads (default 2)"},
600
+ "blend": {**_POS, "description": "mm, fillet radius where the lattice meets solid (default maxWall)"},
601
+ "drains": {"enum": ["auto", "yes", "no"],
602
+ "description": "drain holes into enclosed voids: auto = for powder and resin processes"},
603
+ "drainDiameter": {**_POS, "description": "mm (default the process's escape hole: SLS 4, MJF 5, SLA/DLP 3.5, "
604
+ "volumetric 3)"},
605
+ "fillPockets": {"type": "number", "minimum": 0,
606
+ "description": "mm^3: enclosed voids smaller than this are filled solid (default cell^3 / 8)"},
607
+ "step": {**_POS, "description": "field sampling, mm (default minWall / 2.5)"},
608
+ }
609
+
610
+ _FRACTIONS = {"type": "object", "additionalProperties": {"type": "number", "exclusiveMinimum": 0, "maximum": 1},
611
+ "description": "material name -> fraction of the part's volume"}
612
+ _MULTI_MATERIAL = {
613
+ "type": "object",
614
+ "description": "Multi-material topology optimization (multimat.py): the optimizer places each material as "
615
+ "well as the topology, for multi-material FDM (Bambu AMS, Prusa MMU/XL, IDEX) or jetting. "
616
+ "Delivers one body per material with shared interfaces, an STL each and a multi-material 3MF.",
617
+ "properties": {
618
+ "materials": {"type": "array", "items": {"$ref": "#/$defs/material"}, "minItems": 2, "maxItems": 4,
619
+ "description": "The materials to place (library names or custom objects)."},
620
+ "assign": {"type": "array", "items": {"type": "object", "properties": {
621
+ "material": {"type": "string"},
622
+ "region": {"type": "string", "description": "a preserve or keepOut id"},
623
+ "shape": {"$ref": "#/$defs/shape"},
624
+ "near": {"type": "object", "properties": {
625
+ "region": {"type": "string"}, "shape": {"$ref": "#/$defs/shape"},
626
+ "distance": {**_POS, "description": "mm from the region's surface"}},
627
+ "required": ["distance"], "additionalProperties": False},
628
+ "within": {"type": "string", "description": "near only: limit to inside this region"}},
629
+ "required": ["material"], "additionalProperties": False},
630
+ "description": "Cells the study fixes to one material (a pad, a bearing seat); the "
631
+ "optimizer places the rest. Earlier entries win."},
632
+ "maxFraction": _FRACTIONS,
633
+ "minFraction": _FRACTIONS,
634
+ "requirements": {"type": "array", "items": {"type": "object", "properties": {
635
+ "case": {"type": "string", "description": "the load case it reads"},
636
+ "knock": {"type": "object", "properties": {
637
+ "energy": {**_POS, "description": "J the knock brings"},
638
+ "maxForce": {**_POS, "description": "N: the peak force it may stop with"},
639
+ "safetyFactor": {**_POS, "description": "the knock's own safety factor: on its stored "
640
+ "energy, and on materials and bonds at its peak force "
641
+ "(an accidental load; default the goal's / "
642
+ "interfaceSafetyFactor)"}},
643
+ "required": ["energy", "maxForce"], "additionalProperties": False},
644
+ "indentation": {"type": "object", "properties": {
645
+ "relativeTo": {"type": "string", "description": "the same force spread out (a case)"},
646
+ "atLeast": {**_POS, "description": "mm the case's load sinks in beyond relativeTo's"}},
647
+ "required": ["relativeTo", "atLeast"], "additionalProperties": False}},
648
+ "required": ["case"], "additionalProperties": False},
649
+ "description": "Where the part must be soft: a knock that must stop under a force (a bumper), or "
650
+ "a patch that must give (a grip pad). These cases are not in the stiffness "
651
+ "objective."},
652
+ "interfaceSafetyFactor": {**_POS, "description": "for the bond between materials (default: the "
653
+ "goal's safetyFactor, else 2)"},
654
+ "interfaces": {"type": "object", "additionalProperties": {"type": "object", "properties": {
655
+ "tensile": _POS, "shear": _POS, "tensileZ": _POS, "shearZ": _POS},
656
+ "required": ["tensile"], "additionalProperties": False},
657
+ "description": "'a+b' -> bond strength (MPa), overriding interface_library.py's "
658
+ "sourced card (measure your printer's: it varies a lot)"},
659
+ "interlock": {"oneOf": [{"type": "boolean"}, {"type": "object", "properties": {
660
+ "pattern": {"enum": ["beams"]},
661
+ "depth": {**_POS, "description": "mm of interlocking band straddling the interface (default 2)"},
662
+ "beamWidth": {**_POS, "description": "mm (default two extrusion widths, 0.8)"},
663
+ "layers": {"type": "integer", "minimum": 1, "description": "print layers per beam (default 2)"},
664
+ "layerHeight": {**_POS, "description": "mm per beam layer, instead of layers"},
665
+ "gain": {"type": "number", "minimum": 1,
666
+ "description": "interlocked / flat bond strength credited (default 2.5 at 2 mm, sourced)"},
667
+ "pairs": {"type": "array", "items": {"type": "string"}, "description": "'a+b' pairs (default all)"}},
668
+ "additionalProperties": False}],
669
+ "description": "Interlock the materials where they meet (an interlaced beam lattice, as Cura's / "
670
+ "Bambu's interlocking beams): the delivered bodies interpenetrate and the bond is "
671
+ "credited with the measured gain, capped by what the lattice carries (interlock.py)"},
672
+ "bondInTopology": {"type": "boolean", "description": "experimental: constrain the bond while the "
673
+ "layout forms (from projection 4), not only in "
674
+ "the final bond stage"},
675
+ "interfacePenalty": {"type": "boolean", "description": "constrain the bond in the optimizer "
676
+ "(default true); verification always checks it"},
677
+ "export": {"type": "object", "properties": {
678
+ "slicer": {"enum": ["all", "generic", "prusa", "bambu"]},
679
+ "extruders": {"type": "object", "additionalProperties": {"type": "integer", "minimum": 1},
680
+ "description": "material name -> extruder / AMS slot (1-based)"}},
681
+ "additionalProperties": False}},
682
+ "required": ["materials"],
683
+ "additionalProperties": False,
684
+ }
685
+
686
+ STUDY_SCHEMA = {
687
+ "$schema": _DIALECT,
688
+ "$id": "https://github.com/connorwhite-online/loadpath/schema/study.schema.json",
689
+ "title": "loadpath study",
690
+ "description": "A generative design (topology optimization) problem. Units: mm, N, MPa, g/cm³.",
691
+ "type": "object",
692
+ "properties": {
693
+ "name": {"type": "string", "description": "Used to name the output files."},
694
+ "description": {"type": "string"},
695
+ "designSpace": {"anyOf": [{"$ref": "#/$defs/shape"},
696
+ {"type": "array", "items": {"$ref": "#/$defs/shape"}, "minItems": 1}],
697
+ "description": "Shapes material may occupy (their union)."},
698
+ "preserve": {"type": "array", "items": {"$ref": "#/$defs/region"},
699
+ "description": "Geometry kept exactly: bosses, mounting faces."},
700
+ "keepOut": {"type": "array", "items": {"$ref": "#/$defs/keepOut"},
701
+ "description": "Volume that must stay empty: bores, bolt access, cables, mating parts."},
702
+ "fasteners": {"type": "array", "items": {"$ref": "#/$defs/fastener"}},
703
+ "supports": {"type": "array", "items": {"$ref": "#/$defs/support"},
704
+ "description": "Where the part is held. Required unless every load is a pressure."},
705
+ "skin": {"anyOf": [_POS, {"type": "object", "properties": {
706
+ "thickness": _POS,
707
+ "open": {"type": "array", "items": {"type": "string", "pattern": "^[+-]?[xyz]$"},
708
+ "description": "design-space faces left without skin, like '-z' (where ribs "
709
+ "stand on a cover's inside)"}},
710
+ "required": ["thickness"], "additionalProperties": False}],
711
+ "description": "Skin-and-rib: mm of the design space's outer surface kept as a solid wall; "
712
+ "the optimizer designs the ribs behind it."},
713
+ "loadCases": {"type": "array", "items": {"$ref": "#/$defs/loadCase"}, "minItems": 1},
714
+ "loads": {"type": "array", "items": {"$ref": "#/$defs/load"},
715
+ "description": "Shorthand for a single load case."},
716
+ "material": {"anyOf": [{"$ref": "#/$defs/material"},
717
+ {"type": "array", "items": {"$ref": "#/$defs/material"}, "minItems": 1}],
718
+ "description": "A list solves the goal in each and pools the outcomes."},
719
+ "goal": {"$ref": "#/$defs/goal"},
720
+ "minMember": {**_POS, "description": "Thinnest member, mm (default: 2 x the process's minFeature, "
721
+ "else 1.4 for the filter)."},
722
+ "minHole": {**_POS, "description": "Narrowest hole or gap, mm (default: the process's minHole, else "
723
+ "none). The optimizer pays for the material a hole this narrow "
724
+ "would close up with, and the delivered part has it closed."},
725
+ "drains": {"anyOf": [
726
+ {"enum": ["auto"]}, {"type": "boolean"},
727
+ {"type": "object", "properties": {
728
+ "enabled": {"type": "boolean"},
729
+ "diameter": {**_POS, "description": "mm (default SLS 6, MJF 7.5, SLA/DLP 5.5, volumetric 5, "
730
+ "LPBF 6.8, else 6)"},
731
+ "count": {"type": "integer", "minimum": 1, "description": "holes per sealed void (default 2)"},
732
+ "maxPath": {"anyOf": [_POS, {"type": "null"}],
733
+ "description": "mm: more holes while any powder is farther than this from one, "
734
+ "through the void (default 60; null: off)"},
735
+ "iterations": {"type": "integer", "minimum": 0,
736
+ "description": "optimizer iterations after drilling, so the design carries "
737
+ "load round the holes (default 20)"}},
738
+ "additionalProperties": False}],
739
+ "description": "Escape holes into voids the optimizer sealed: 'auto' (default) for powder and resin "
740
+ "processes and LPBF, true, false, or options. The holes are drilled where the "
741
+ "wall is thin and the strain energy low, then the design re-converges round them."},
742
+ "symmetry": {"type": "array", "items": {"enum": ["x", "y", "z"]},
743
+ "description": "Planes through the design-space centre, by normal axis."},
744
+ "resolution": {"type": "object",
745
+ "properties": {"maxElements": {"type": "integer", "minimum": 1, "maximum": 4000000},
746
+ "pitch": _POS,
747
+ "keepThinMembers": {
748
+ "type": "boolean",
749
+ "description": "Mesh members one or two cells thick without "
750
+ "thinning them (volume-keeping smoothing there, "
751
+ "and a debiased stem-smoothing average). "
752
+ "Off by default: the default rounding thins them, "
753
+ "which the searches are tuned to, but a design "
754
+ "made of one-cell walls can come out several "
755
+ "times softer than the optimizer's model (the "
756
+ "camera boom: 16x)."},
757
+ "cutCells": {
758
+ "type": "boolean",
759
+ "description": "Verify on cut cells: each cell the part's surface "
760
+ "cuts gets the stiffness of its solid share, stress is "
761
+ "read on the true surface, and members under 3 cells "
762
+ "thick get a finer submodel. Member thickness then no "
763
+ "longer rounds to whole cells (a 2-cell member at 45 "
764
+ "degrees read bending 25% low), and a sub-cell strut "
765
+ "is seen at its true thickness (GRIP's wrist link: "
766
+ "whole cells read SF 2.28 where 0.9 mm tets read "
767
+ "0.66, cut cells 0.67). On by default, except for "
768
+ "studies with pressure loads; false for whole cells."},
769
+ "pitchStarts": {
770
+ "oneOf": [{"type": "integer", "minimum": 1, "maximum": 5},
771
+ {"const": "auto"}],
772
+ "description": "Searches run on this many grids 1% apart in "
773
+ "pitch, keeping the lightest passing part: one grid's "
774
+ "result can swing 25-140% with a 1% pitch change. "
775
+ "'auto' (default): 3 for safety-factor and deflection "
776
+ "searches up to 30k cells with no pitch given, else 1."}},
777
+ "additionalProperties": False},
778
+ "maxIterations": {"type": "integer", "minimum": 1},
779
+ "gravity": {**_VEC3, "description": "m/s², the part's own weight in every load case"},
780
+ "thermal": _THERMAL,
781
+ "printDirection": {"type": "string", "pattern": "^[+-]?[xyz]$",
782
+ "description": "The direction layers stack; turns on overhang control."},
783
+ "extrusion": {"type": "string", "pattern": "^[+-]?[xyz]$",
784
+ "description": "The part is a profile extruded along this axis (cut from plate)."},
785
+ "maxMember": {**_POS, "description": "Thickest member, mm (at least 2 x minMember): thick members "
786
+ "hollow out or split."},
787
+ "milling": {"type": "object", "properties": {
788
+ "axes": {"enum": [2.5, 3, 5], "description": "2.5: flat pocket floors every stepDown; "
789
+ "5: 3+2 positional, all six sides"},
790
+ "directions": {"type": "array", "items": {"type": "string", "pattern": "^[+-]?[xyz]$"},
791
+ "minItems": 1,
792
+ "description": "sides the cutter comes from, like '+z' (default +z; "
793
+ "all six for 5-axis)"},
794
+ "toolDiameter": {**_POS, "description": "mm, smallest cutter: narrower slots and "
795
+ "tighter inside corners are filled"},
796
+ "stepDown": {**_POS, "description": "mm between 2.5-axis pocket floors (default 2)"}},
797
+ "additionalProperties": False,
798
+ "description": "The part is machined: every void reachable by a cutter coming straight in."},
799
+ "casting": {"anyOf": [{"type": "string", "pattern": "^[+-]?[xyz]$"},
800
+ {"type": "object", "properties": {
801
+ "draw": {"type": "string", "pattern": "^[+-]?[xyz]$",
802
+ "description": "the direction the mold opens"},
803
+ "sides": {"enum": [1, 2], "description": "2 (default): halves pull both ways "
804
+ "along the draw; 1: one half pulls off "
805
+ "a flat parting plane"}},
806
+ "required": ["draw"], "additionalProperties": False}],
807
+ "description": "Cast or molded: no undercuts along the draw direction."},
808
+ "pattern": {"type": "object", "properties": {
809
+ "type": {"const": "circular"},
810
+ "axis": {"enum": ["x", "y", "z"]},
811
+ "count": {"type": "integer", "minimum": 2},
812
+ "center": {**_VEC3, "description": "a point on the axis (default: design-space centre)"}},
813
+ "required": ["count"], "additionalProperties": False,
814
+ "description": "Circular pattern: count identical sectors round the axis."},
815
+ "process": _PROCESS,
816
+ "slicer": _SLICER,
817
+ "buildSim": _BUILD_SIM,
818
+ "durability": _DURABILITY,
819
+ "lattice": {"type": "object", "properties": _LATTICE_PROPS, "additionalProperties": False,
820
+ "description": "Optional: fill the delivered part with a stress-graded TPMS lattice."},
821
+ "multiMaterial": _MULTI_MATERIAL,
822
+ "expect": {"type": "object", "description": "Eval-only pass criteria (loadpath.evals)."},
823
+ },
824
+ "required": ["designSpace"],
825
+ "x-requireOne": [["loadCases"], ["loads"]],
826
+ "additionalProperties": False,
827
+ "$defs": {**_SHAPES, "shape": _SHAPE, "region": _REGION, "keepOut": _KEEP_OUT, "support": _SUPPORT,
828
+ "load": _LOAD, "loadCase": _LOAD_CASE, "material": _ONE_MATERIAL, "fastener": _FASTENER, "goal": _GOAL},
829
+ }
830
+
831
+ _FLUID_NAMES = ["water", "water60", "air", "glycol50", "oil"]
832
+ _DUTY_SIDE = {
833
+ "type": "object",
834
+ "properties": {"fluid": {"enum": _FLUID_NAMES}, "lPerMin": _POS, "kgPerS": _POS,
835
+ "tIn": {"type": "number", "description": "°C"}},
836
+ "x-requireOne": [["lPerMin"], ["kgPerS"]],
837
+ "additionalProperties": False,
838
+ }
839
+
840
+ HEAT_EXCHANGER_SCHEMA = {
841
+ "$schema": _DIALECT,
842
+ "$id": "https://github.com/connorwhite-online/loadpath/schema/heat-exchanger.schema.json",
843
+ "title": "loadpath heat exchanger",
844
+ "description": "A two-fluid sheet-TPMS heat exchanger in any core shape, with headers and ports. "
845
+ "Units: mm; duty in L/min or kg/s and °C.",
846
+ "type": "object",
847
+ "properties": {
848
+ "name": {"type": "string"},
849
+ "description": {"type": "string"},
850
+ "core": {"$ref": "#/$defs/shape"},
851
+ "type": {"enum": ["gyroid", "diamond", "schwarz-p"]},
852
+ "cell": {**_POS, "description": "mm per TPMS period (at least 3 x wall)"},
853
+ "wall": {**_POS, "description": "mm, the sheet between the fluids"},
854
+ "shell": _POS, "header": _POS,
855
+ "cap": {**_POS, "description": "mm: how deep each plenum seals off the other fluid's channels at the "
856
+ "core's ends (default wall + 1); thinner leaves more core open to flow "
857
+ "and fewer dead pockets"},
858
+ "portLength": _POS,
859
+ "phase": {"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3,
860
+ "description": "mm: shift the TPMS against the core's low corner, which moves where the core's "
861
+ "faces cut the channels (a partial channel at an edge starves)"},
862
+ "portDiameter": {"anyOf": [_POS, {"type": "object", "additionalProperties": _POS}],
863
+ "description": "mm, every port; or one per fluid name, e.g. {\"water\": 8, \"air\": 14} "
864
+ "(a gas side's ports set most of its pressure drop)"},
865
+ "layout": {"enum": ["cross", "counter"]},
866
+ "axes": {"type": "string", "pattern": "^[xyz]{2}$",
867
+ "description": "cross: A along axes[0], B along axes[1]; counter: A through the ends along "
868
+ "axes[0], B's side ports along axes[1]"},
869
+ "fluids": {"type": "array", "items": {"type": "string"}, "minItems": 2, "maxItems": 2},
870
+ "build": {"anyOf": [{"enum": ["x", "y", "z", "none"]}, {"type": "null"}, {"const": False}],
871
+ "description": "The axis pointing up while printing (null: no print shaping)."},
872
+ "skirt": {"type": "boolean"},
873
+ "split": {"type": "number", "exclusiveMinimum": 0.05, "exclusiveMaximum": 0.95,
874
+ "description": "the first fluid's share of the void (default 0.5): the sheet moves toward the "
875
+ "second. An air side against a liquid wants more, e.g. 0.66"},
876
+ "process": {"enum": ["fdm", "lpbf"],
877
+ "description": "fdm (default) or lpbf: laser powder-bed metal (AlSi10Mg, Ti64, 316L). "
878
+ "lpbf drops the skirt (outside supports are cut off) and the report's "
879
+ "internalCeilings must be near zero: nothing inside can be supported."},
880
+ "cleanouts": {"type": "integer", "minimum": 0,
881
+ "description": "powder clean-out ports per fluid (default 0): each a bore through the outer "
882
+ "wall at the point of that fluid's space farthest from every opening, on a "
883
+ "boss to tap for a plug; the report lists each and its flat plug for "
884
+ "check_networks. With lpbf the report's powder entry says how far powder "
885
+ "must travel to get out."},
886
+ "cleanoutDiameter": {**_POS, "description": "mm (default 4.2: an M5 tap drill)"},
887
+ "closure": {"type": "boolean",
888
+ "description": "with build (default true): channels close gently under core surface that "
889
+ "looks up and flare open over plenums below, so no channel ends in a "
890
+ "flat ceiling"},
891
+ "zones": {"type": "array", "items": {
892
+ "type": "object",
893
+ "properties": {"fluid": {"anyOf": [{"type": "string"}, {"type": "integer"}]},
894
+ "end": {"enum": ["in", "out"]}, "direction": _SIGNED_AXIS,
895
+ "minCos": {"type": "number", "minimum": -1, "maximum": 1},
896
+ "window": {"type": "object",
897
+ "properties": {"axis": _SIGNED_AXIS, "from": {"type": "number"},
898
+ "to": {"type": "number"}},
899
+ "required": ["axis"], "additionalProperties": False}},
900
+ "required": ["fluid", "end", "direction"], "additionalProperties": False}},
901
+ "step": {**_POS, "description": "grid, mm (default wall / 3); coarser is faster for a first look"},
902
+ "expect": {"type": "object", "description": "Eval-only pass criteria (loadpath.evals)."},
903
+ "envelope": {"type": "array", "items": {"type": "number", "exclusiveMinimum": 0}, "minItems": 3,
904
+ "maxItems": 3,
905
+ "description": "Instead of a core: the box (mm, x y z) the whole part must fit, ports and "
906
+ "headers included. `loadpath heatx --size` then picks the core's size, cell, "
907
+ "wall, split, layout and each fluid's port for the duty (sizing.py)."},
908
+ "sizing": {"type": "object", "properties": {
909
+ "process": {"enum": ["fdm", "lpbf", "sls", "mjf", "sla", "dlp"]},
910
+ "maxPressureDrop": {"type": "object", "additionalProperties": _POS,
911
+ "description": "Pa per fluid name"},
912
+ "minEffectiveness": {"type": "number", "minimum": 0, "maximum": 1},
913
+ "targetDuty": {**_POS, "description": "W"},
914
+ "objective": {"enum": ["mass", "effectiveness", "pressure"]}},
915
+ "additionalProperties": False,
916
+ "description": "With envelope: what the sized exchanger must meet, and what to rank by."},
917
+ "simulate": {"anyOf": [{"type": "boolean"}, {
918
+ "type": "object",
919
+ "properties": {"voxel": {**_POS, "description": "mm (default min(cell/2, port/4, header/3))"},
920
+ "closures": {"enum": ["design", "lbm"],
921
+ "description": "design: the rating's laws (Savoldi pressure drop, "
922
+ "Reynolds 2023 Nu); lbm: both refitted to LBM gyroid "
923
+ "cells (better for thick walls, phi under 0.3)"}},
924
+ "additionalProperties": False}],
925
+ "description": "With duty: simulate the whole exchanger (porous core, open headers, both fluids "
926
+ "and the wall): flow uniformity, pressure field, temperatures and hot spots, "
927
+ "beside the epsilon-NTU rating. Seconds to a few minutes."},
928
+ "duty": {"type": "object", "description": "Optional: rate it. One entry per fluid name, plus 'wall' "
929
+ "(a material name or W/m·K).",
930
+ "properties": {"wall": {"anyOf": [{"type": "string"}, _POS]}},
931
+ "additionalProperties": {"$ref": "#/$defs/dutySide"}},
932
+ },
933
+ "x-requireOne": [["core"], ["envelope"]],
934
+ "additionalProperties": False,
935
+ "$defs": {**_SHAPES, "shape": _SHAPE, "dutySide": _DUTY_SIDE},
936
+ }
937
+
938
+ LATTICE_SCHEMA = {
939
+ "$schema": _DIALECT,
940
+ "$id": "https://github.com/connorwhite-online/loadpath/schema/lattice.schema.json",
941
+ "title": "loadpath lattice infill",
942
+ "description": "Fill a finished part with a TPMS sheet lattice whose wall follows the stress.",
943
+ "type": "object",
944
+ "properties": _LATTICE_PROPS,
945
+ "additionalProperties": False,
946
+ }
947
+
948
+ # --- field graphs (fields.py): nTop-style implicit modelling ---
949
+
950
+ _FIELD = {"title": "a number, a node name or a node object",
951
+ "description": "A number, the name of another node, or an inline node object: anywhere a "
952
+ "number goes, a field can drive it.",
953
+ "anyOf": [{"type": "number"}, {"type": "string", "description": "a node name"},
954
+ {"$ref": "#/$defs/node"}]}
955
+ _BOX = {"type": "object", "description": "Axis-aligned box (mm).",
956
+ "properties": {"min": _VEC3, "max": _VEC3}, "required": ["min", "max"], "additionalProperties": False}
957
+ _COMMON = {"description": {"type": "string", "description": "What the node is for (ignored)."},
958
+ "bounds": {**_BOX, "description": "Where this solid lives, when it cannot be worked out "
959
+ "(meshing needs the output's)."}}
960
+
961
+
962
+ def _op(name, desc, props, required=(), require_one=None):
963
+ d = {"type": "object", "description": desc,
964
+ "properties": {"op": {"const": name}, **props, **_COMMON},
965
+ "required": ["op", *required], "additionalProperties": False}
966
+ if require_one:
967
+ d["x-requireOne"] = require_one
968
+ return d
969
+
970
+
971
+ _FIELD_OPS = {
972
+ "constant": _op("constant", "A number everywhere.", {"value": {"type": "number"}}, ["value"]),
973
+ "coordinate": _op("coordinate", "Position along an axis (mm): 'x', 'y', 'z', or a direction from "
974
+ "`origin`.", {"axis": _AXIS, "origin": _VEC3}),
975
+ "distance": _op("distance", "Unsigned distance (mm) to a solid's surface (`input`), to points, a "
976
+ "segment, a plane or a shape. The usual driver of a ramp: thicker near the "
977
+ "bolts, finer cells near the load.",
978
+ {"input": _FIELD, "points": {"type": "array", "items": _VEC3, "minItems": 1},
979
+ "segment": {"type": "array", "items": _VEC3, "minItems": 2, "maxItems": 2},
980
+ "plane": {"type": "object", "properties": {"point": _VEC3, "normal": _VEC3},
981
+ "required": ["point", "normal"], "additionalProperties": False},
982
+ "shape": {"$ref": "#/$defs/shape"},
983
+ "signed": {"type": "boolean", "description": "with input: keep the sign (negative inside)"}},
984
+ require_one=[["input"], ["points"], ["segment"], ["plane"], ["shape"]]),
985
+ "shape": _op("shape", "An exact primitive solid (box, cylinder, tube, sphere, union), as in a study.",
986
+ {"shape": {"$ref": "#/$defs/shape"}}, ["shape"]),
987
+ "mesh": _op("mesh", "A closed mesh (STL/3MF/OBJ) as a solid: exact distance near its surface, so it "
988
+ "re-meshes smooth.",
989
+ {"path": {"type": "string", "description": "relative to the graph's folder"},
990
+ "source": {"type": "string", "description": "an in-memory mesh passed to FieldGraph(meshes=)"},
991
+ "pitch": {**_POS, "description": "mm, far-field voxel grid (default extent/200, >= 0.25)"},
992
+ "band": {**_POS, "description": "mm from the surface within which distance is exact "
993
+ "(default 3 pitches); make it cover shell thicknesses"}},
994
+ require_one=[["path"], ["source"]]),
995
+ "result": _op("result", "A simulation result as a scalar field: von Mises stress (MPa) or displacement "
996
+ "(mm) of `part` under `study` (analyzed on demand), a saved .npz (points + values, "
997
+ "or verify fields), or an in-memory source.",
998
+ {"path": {"type": "string", "description": ".npz with points and values (save_result_field) "
999
+ "or a grid (origin, pitch, values)"},
1000
+ "source": {"type": "string"},
1001
+ "study": {"type": "string", "description": "study JSON, relative to the graph's folder, or "
1002
+ "'example:<name>' for a packaged example"},
1003
+ "part": {"type": "string", "description": "the mesh the study analyzes, or the name of a "
1004
+ "node of this graph (meshed for the analysis)"},
1005
+ "pitch": {**_POS, "description": "mm, meshing a node part for the analysis (default 1)"},
1006
+ "quantity": {"type": "string", "description": "vonMises (default) or displacement for a "
1007
+ "study; the array name in an .npz"},
1008
+ "case": {"type": "string", "description": "one load case (default: the worst at each point)"},
1009
+ "scale": {"anyOf": [{"const": "peak"}, _POS],
1010
+ "description": "divide by this, or 'peak' (the 99.5th percentile): remap in "
1011
+ "fractions of the part's own worst stress"},
1012
+ "cells": {"type": "integer", "minimum": 1000, "description": "analysis size (default 120000)"}},
1013
+ require_one=[["path"], ["source"], ["study", "part"]]),
1014
+ "grid": _op("grid", "An imported scalar field on a regular grid (.npz with origin, pitch, values[nx,ny,nz]): "
1015
+ "a temperature map, a CFD pressure, a hand-painted density.",
1016
+ {"path": {"type": "string"}, "source": {"type": "string"},
1017
+ "quantity": {"type": "string", "description": "array name (default values)"}},
1018
+ require_one=[["path"], ["source"]]),
1019
+ "image": _op("image", "A picture as a scalar field from 0 to 1, laid on a plane and carried straight through "
1020
+ "along the third axis: a logo to emboss, a texture to drive a wall, a hand-painted density.",
1021
+ {"path": {"type": "string", "description": "PNG, JPEG, ... in the graph's folder"},
1022
+ "plane": {"enum": ["xy", "xz", "yz"], "description": "the plane it lies in (default xy)"},
1023
+ "origin": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2,
1024
+ "description": "lower-left corner in the plane, mm (default [0, 0])"},
1025
+ "size": {"type": "array", "items": {"type": "number", "exclusiveMinimum": 0}, "minItems": 2,
1026
+ "maxItems": 2, "description": "[width, height] mm"},
1027
+ "channel": {"enum": ["luma", "r", "g", "b", "a"], "description": "default luma"},
1028
+ "invert": {"type": "boolean"}}, ["path", "size"]),
1029
+ "remap": _op("remap", "Map `input` from [a, b] to [c, d] through a curve (clamped unless clamp is false). "
1030
+ "Longer from/to lists are a piecewise-linear table (from ascending), e.g. a density "
1031
+ "per height for an impact core.",
1032
+ {"input": _FIELD,
1033
+ "from": {"type": "array", "items": {"type": "number"}, "minItems": 2},
1034
+ "to": {"type": "array", "items": {"type": "number"}, "minItems": 2},
1035
+ "curve": {"anyOf": [
1036
+ {"enum": ["linear", "smoothstep", "smootherstep", "sqrt", "square"]},
1037
+ {"type": "array", "minItems": 2, "description": "points [u, v] over the unit square, u "
1038
+ "ascending from 0 to 1: a drawn curve",
1039
+ "items": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}}]},
1040
+ "clamp": {"type": "boolean"}}, ["input", "from", "to"]),
1041
+ "expr": _op("expr", "Arithmetic over node names, x, y, z, pi, e and abs sqrt exp log sin cos tan floor ceil "
1042
+ "sign min max clamp lerp smoothstep step where, e.g. '0.8 + 1.2 * smoothstep(5, 30, "
1043
+ "stress)'.",
1044
+ {"expr": {"type": "string"},
1045
+ "vars": {"type": "object", "additionalProperties": _FIELD,
1046
+ "description": "local names for fields used in expr"}}, ["expr"]),
1047
+ "union": _op("union", "Join solids; `blend` (mm, or a field) is the fillet radius where they meet.",
1048
+ {"inputs": {"type": "array", "items": _FIELD, "minItems": 1}, "blend": _FIELD}, ["inputs"]),
1049
+ "intersect": _op("intersect", "Keep what is inside every input; `blend` rounds the edges.",
1050
+ {"inputs": {"type": "array", "items": _FIELD, "minItems": 1}, "blend": _FIELD}, ["inputs"]),
1051
+ "subtract": _op("subtract", "Cut the second and later inputs out of the first; `blend` fillets the cut.",
1052
+ {"inputs": {"type": "array", "items": _FIELD, "minItems": 2}, "blend": _FIELD}, ["inputs"]),
1053
+ "shell": _op("shell", "A wall of `thickness` (mm, or a field: a variable wall) on a solid's surface.",
1054
+ {"input": _FIELD, "thickness": _FIELD,
1055
+ "side": {"enum": ["inside", "outside", "center"], "description": "default inside"}},
1056
+ ["input", "thickness"]),
1057
+ "offset": _op("offset", "Grow (positive) or shrink a solid by `distance` (mm, or a field).",
1058
+ {"input": _FIELD, "distance": _FIELD}, ["input", "distance"]),
1059
+ "lattice": _op("lattice", "A lattice filling all of space (intersect it with a solid): a TPMS sheet "
1060
+ "(gyroid, diamond, schwarz-p) or a strut cell (cubic, bcc, bccz, fcc, fccz, octet, "
1061
+ "kelvin, diamond-strut) or a stochastic voronoi foam. Cell size, wall or strut "
1062
+ "thickness, relative density and node blend can each be a field; a conformal frame "
1063
+ "lays the cells along a surface.",
1064
+ {"type": {"enum": ["gyroid", "diamond", "schwarz-p", "cubic", "bcc", "bccz", "fcc", "fccz",
1065
+ "octet", "kelvin", "diamond-strut", "voronoi"],
1066
+ "description": "TPMS sheets are stiffer and stronger per gram (lattice.SHEETS vs "
1067
+ "STRUTS); struts are open, easy to clear of powder, and print on FDM "
1068
+ "when the cell is oriented (lattice report's overhangs)"},
1069
+ "cell": {**_FIELD, "description": "mm per period (default 8); a field grades it (voronoi: "
1070
+ "mean seed spacing)"},
1071
+ "thickness": {**_FIELD, "description": "sheet wall or strut diameter, mm (default 1)"},
1072
+ "density": {**_FIELD, "description": "relative density instead of thickness (sheets "
1073
+ "0.05-0.95, struts 0.02-0.95; the unit-cell tables "
1074
+ "set the wall or diameter)"},
1075
+ "blend": {**_FIELD, "description": "strut cells: mm smooth-union radius at the nodes "
1076
+ "(node fillets; default 0)"},
1077
+ "form": {"enum": ["sheet", "network", "beam"],
1078
+ "description": "sheet (TPMS default): a wall on the surface; network: one side "
1079
+ "solid; beam: strut cells (their only form)"},
1080
+ "grading": {"enum": ["warp", "blend"],
1081
+ "description": "how a field cell grades: warp (default) bends the lattice's "
1082
+ "coordinates so one period is one local cell, continuous "
1083
+ "everywhere; blend cross-fades fixed periods (cylindrical frame)"},
1084
+ "aspect": {"type": "array", "items": _POS, "minItems": 3, "maxItems": 3,
1085
+ "description": "stretch the cell per axis, e.g. [1, 1, 1.5]: a tall BCC cell's "
1086
+ "struts rise above 45 degrees and print on FDM unsupported"},
1087
+ "orient": {"description": "turn the cells (not the fields grading them) about the "
1088
+ "origin: a rotation vector in degrees [rx, ry, rz], or "
1089
+ "{axis, degrees}, or {zones: [{at, rotate}], blend} to turn "
1090
+ "them part by part; `loadpath lattice-orient` picks them so "
1091
+ "the stiff cell directions follow the load",
1092
+ "anyOf": [{"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3},
1093
+ {"type": "object", "additionalProperties": False,
1094
+ "description": "{axis, degrees}; or zones: cells turned zone by zone, "
1095
+ "cross-faded over `blend` mm (default one cell) where "
1096
+ "the zones meet",
1097
+ "properties": {
1098
+ "axis": _AXIS, "degrees": {"type": "number"},
1099
+ "zones": {"type": "array", "minItems": 1, "items": {
1100
+ "type": "object", "additionalProperties": False,
1101
+ "required": ["at"],
1102
+ "properties": {"at": _VEC3, "rotate": _VEC3}}},
1103
+ "blend": _POS,
1104
+ "seam": {"type": "number", "minimum": 0,
1105
+ "description": "zones: mm thick solid plate on each face "
1106
+ "between zones, the cells of both ending on "
1107
+ "it as on a skin (a hard split, no blend); "
1108
+ "without one, thin cells of two directions "
1109
+ "meet at small joints that read too stiff"}}}]},
1110
+ "seed": {"type": "integer", "description": "voronoi: random seed (default 0)"},
1111
+ "offset": {**_FIELD, "description": "mm, moves the level surface (network form)"},
1112
+ "origin": _VEC3,
1113
+ "frame": {"type": "object",
1114
+ "description": "cylindrical wraps the cells round an axis, spherical round a "
1115
+ "centre; conformal lays them along a solid's surface (layers "
1116
+ "parallel to the skin, the first on it; in-plane cells follow the "
1117
+ "surface as a stripe pattern)",
1118
+ "properties": {"type": {"enum": ["cartesian", "cylindrical", "spherical",
1119
+ "conformal"]},
1120
+ "axis": {**_AXIS, "description": "cylindrical: the axis; conformal: "
1121
+ "the direction cells line up with "
1122
+ "along the surface (default the "
1123
+ "longest extent)"},
1124
+ "center": _VEC3,
1125
+ "radius": {**_POS, "description": "mm where cells are square"},
1126
+ "surface": {**_FIELD, "description": "conformal: the solid whose "
1127
+ "surface the cells follow"},
1128
+ "normalCell": {**_POS, "description": "conformal: mm cell through "
1129
+ "the thickness (default the "
1130
+ "cell)"},
1131
+ "frameField": {"enum": [True, False, "auto"],
1132
+ "description": "conformal: true, cells follow a 3D "
1133
+ "frame field, turning a box's edges "
1134
+ "and corners; false, two stripe "
1135
+ "patterns along the surface and the "
1136
+ "surface's distance as the layer "
1137
+ "coordinate (exact layers); auto "
1138
+ "(default), whichever shears fewer "
1139
+ "cells in the first cell under the "
1140
+ "skin"},
1141
+ "layerSmoothing": {"type": "number", "minimum": 0, "maximum": 1,
1142
+ "description": "conformal: round the layers where "
1143
+ "the surface's distance folds "
1144
+ "(its medial axis): the distance "
1145
+ "is averaged over a sphere this "
1146
+ "times the depth (capped at a "
1147
+ "cell); exact at the skin; 0 "
1148
+ "keeps the sharp fold (default "
1149
+ "0)"}},
1150
+ "required": ["type"], "additionalProperties": False}},
1151
+ require_one=None),
1152
+ "transform": _op("transform", "Move, rotate or scale a field's input (translate, then rotate, then scale).",
1153
+ {"input": _FIELD, "translate": _VEC3,
1154
+ "rotate": {"type": "object", "properties": {"axis": _AXIS, "degrees": {"type": "number"},
1155
+ "center": _VEC3},
1156
+ "required": ["degrees"], "additionalProperties": False},
1157
+ "scale": _POS}, ["input"]),
1158
+ "group": _op("group", "A copy of a sub-graph from the graph's `groups`, with its own param values and "
1159
+ "inputs; it stands for the group's output.",
1160
+ {"group": {"type": "string", "description": "the name of a group in `groups`"},
1161
+ "params": {"type": "object", "additionalProperties": {"type": "number"},
1162
+ "description": "param -> value (the group's defaults otherwise)"},
1163
+ "inputs": {"type": "object", "additionalProperties": {"type": "string"},
1164
+ "description": "the group's input name -> a node of this graph"}}, ["group"]),
1165
+ }
1166
+
1167
+ _NODE = {
1168
+ "title": "a node object with an 'op'",
1169
+ "description": "A field node: " + ", ".join(_FIELD_OPS) + ".",
1170
+ "oneOf": [{"$ref": f"#/$defs/op-{k}"} for k in _FIELD_OPS],
1171
+ "x-discriminator": {"property": "op", "mapping": {k: f"#/$defs/op-{k}" for k in _FIELD_OPS}},
1172
+ }
1173
+
1174
+ _MATERIAL_BODY = {
1175
+ "type": "object", "required": ["name", "material"], "additionalProperties": False,
1176
+ "description": "One material body of a field graph (fields.material_bodies): the part's points inside "
1177
+ "`solid` (default the whole part) where the `where` field is above `above` and below "
1178
+ "`below`, less what earlier bodies took.",
1179
+ "properties": {
1180
+ "name": {"type": "string", "description": "Names the body's STL (<name>-<body>.stl) and its 3MF part."},
1181
+ "material": {"anyOf": [_ONE_MATERIAL, {"type": "array", "items": _ONE_MATERIAL, "minItems": 2,
1182
+ "maxItems": 2}],
1183
+ "description": "The body's material, or with `mix` the pair [A, B] it blends."},
1184
+ "solid": {**_FIELD, "description": "solid node the body is cut from (default the output)"},
1185
+ "where": {**_FIELD, "description": "scalar field that picks the body's points inside `solid` "
1186
+ "(a remapped distance, a `result` stress)"},
1187
+ "above": {**_FIELD, "description": "the body is where `where` > this (a number or a field)"},
1188
+ "below": {**_FIELD, "description": "the body is where `where` < this (a number or a field)"},
1189
+ "mix": {**_FIELD, "description": "0..1, the fraction of material B (material[1]) against A at each "
1190
+ "point: a graded blend for multi-material jetting, no hard "
1191
+ "partition. Writes a voxel print job (voxelprint.py) and weighs the "
1192
+ "body by the blend."},
1193
+ "voxel": {"type": "object", "additionalProperties": False,
1194
+ "description": "a mix body's voxel job",
1195
+ "properties": {
1196
+ "pitch": {**_POS, "description": "mm, the fraction grid (default the mesh pitch)"},
1197
+ "printer": {"type": "string", "description": "voxelprint printer (default j850)"},
1198
+ "materials": {"type": "array", "minItems": 2, "maxItems": 2,
1199
+ "items": {"anyOf": [{"type": "string"}, {"type": "array"}, {"type": "object"}]},
1200
+ "description": "printer resins for A and B (default from the cards: "
1201
+ "vero -> VeroCyan, agilus30 -> Agilus30Blk)"},
1202
+ "maxLayers": {"type": "integer", "minimum": 1, "description": "write only this many "
1203
+ "layers (previews)"}}},
1204
+ "extruder": {"type": "integer", "minimum": 1,
1205
+ "description": "FDM tool for the 3MF (default: one per distinct material, in order)"},
1206
+ "color": {"type": "string", "description": "#RRGGBB in the 3MF (default by material)"}},
1207
+ }
1208
+
1209
+ _ND_WHERE = {"region": {"type": "string", "description": "solid node: the mesh nodes inside it"},
1210
+ "at": {**_VEC3, "description": "mm: the mesh nodes within radius of this point"},
1211
+ "radius": {**_POS, "description": "mm round `at` (default 1.5 cells)"}}
1212
+ _OBSTACLE = {
1213
+ "type": "object",
1214
+ "description": "A rigid object the part may not enter (frictionless unless friction is set): plane (point, normal: the side the "
1215
+ "part is on), sphere (center, radius), cylinder (center, axis, radius; infinitely long) or "
1216
+ "box (min, max). inside: the part stays inside it instead (a socket, a tube). move: mm the "
1217
+ "object travels at full load (an indenter, a jaw, a snap fit's catch).",
1218
+ "properties": {"type": {"enum": ["plane", "sphere", "cylinder", "box"]},
1219
+ "name": {"type": "string"},
1220
+ "point": _VEC3, "normal": _VEC3, "center": _VEC3, "radius": _POS, "axis": _AXIS,
1221
+ "min": _VEC3, "max": _VEC3, "inside": {"type": "boolean"},
1222
+ "move": {**_VEC3, "description": "mm at full load"},
1223
+ "stiffness": {**_POS, "description": "penalty, x E x pitch N/mm per node (default 20)"},
1224
+ "friction": {"type": "number", "minimum": 0,
1225
+ "description": "Coulomb coefficient mu: sticks until the tangential force "
1226
+ "reaches mu x the normal force, then slides (default: the "
1227
+ "contact block's, else 0)"}},
1228
+ "required": ["type"],
1229
+ "additionalProperties": False,
1230
+ }
1231
+ _LARGE_DEFLECTION = {
1232
+ "type": "object",
1233
+ "description": "Large-deflection check (nonlinear.py: total Lagrangian hex8, neo-Hookean, Newton with "
1234
+ "load steps and a buckling watch) of a part meant to bend: a TPU finger, a flexure, a snap "
1235
+ "fit, a compliant gripper. Drive it by dead loads, prescribed displacements and tendons "
1236
+ "(cables through guides on the part); contact with rigid obstacles and the part's own "
1237
+ "surface. Needs material.",
1238
+ "properties": {
1239
+ "part": {"type": "string", "description": "solid node analysed (default the output)"},
1240
+ "fixed": {"type": "string", "description": "solid node: nodes inside it are clamped"},
1241
+ "pitch": {**_POS, "description": "mm analysis grid (default 1; coarsened to fit maxCells)"},
1242
+ "maxCells": {"type": "integer", "minimum": 100, "description": "most cells (default 20000)"},
1243
+ "nu": {"type": "number", "exclusiveMinimum": 0, "maximum": 0.499,
1244
+ "description": "Poisson's ratio (default the material's)"},
1245
+ "model": {"enum": ["neo-hookean", "svk"], "description": "hyperelastic law (default neo-hookean)"},
1246
+ "steps": {"type": "integer", "minimum": 1, "description": "first guess at load steps (default 10)"},
1247
+ "loads": {"type": "array", "items": {"type": "object", "properties": {
1248
+ **_ND_WHERE, "force": {**_VEC3, "description": "N, dead (keeps its direction), spread evenly"}},
1249
+ "required": ["force"], "x-requireOne": [["region"], ["at"]], "additionalProperties": False}},
1250
+ "displacements": {"type": "array", "items": {"type": "object", "properties": {
1251
+ **_ND_WHERE, "move": {**_VEC3, "description": "mm at full load: an actuator's stroke"},
1252
+ "free": {"type": "boolean", "description": "leave the directions with zero move free (default "
1253
+ "false: held)"}},
1254
+ "required": ["move"], "x-requireOne": [["region"], ["at"]], "additionalProperties": False},
1255
+ "description": "Prescribed motion; the report gives the force it takes (actuatorForce, N)."},
1256
+ "tendons": {"type": "array", "items": {"type": "object", "properties": {
1257
+ "path": {"type": "array", "minItems": 2, "items": {"anyOf": [
1258
+ {**_VEC3, "description": "a fixed point in space (the spool, a palm pulley)"},
1259
+ {"type": "object", "properties": _ND_WHERE, "x-requireOne": [["region"], ["at"]],
1260
+ "additionalProperties": False, "description": "a guide on the part; the cable slides "
1261
+ "through it"}]},
1262
+ "description": "Points the cable runs straight between; the last is the knot."},
1263
+ "tension": {**_POS, "description": "N, constant (scaled with the load steps)"},
1264
+ "stroke": {**_POS, "description": "mm of cable a servo winds in (displacement driven)"},
1265
+ "stiffness": {**_POS, "description": "N/mm of the cable and servo, with stroke"}},
1266
+ "required": ["path"], "x-requireOne": [["tension"], ["stroke", "stiffness"]],
1267
+ "additionalProperties": False},
1268
+ "description": "Cables through guides on the part: the load follows the part as it curls."},
1269
+ "contact": {"type": "object", "properties": {
1270
+ "obstacles": {"type": "array", "items": _OBSTACLE},
1271
+ "self": {"type": "boolean", "description": "the part's surface on itself (fingers meeting, a "
1272
+ "finger folding, a gap closing)"},
1273
+ "stiffness": {**_POS, "description": "penalty for all, x E x pitch N/mm per node (default 20)"},
1274
+ "friction": {"type": "number", "minimum": 0,
1275
+ "description": "Coulomb coefficient for all contacts, the part on itself included "
1276
+ "(default 0: frictionless). PLA on PLA ~0.3-0.5, TPU ~0.6-1, PTFE "
1277
+ "~0.05-0.1."}},
1278
+ "additionalProperties": False},
1279
+ "probes": {"type": "array", "items": {"type": "object", "properties": {
1280
+ **_ND_WHERE, "name": {"type": "string"},
1281
+ "direction": {**_VEC3, "description": "report the mean travel along this (mm)"}},
1282
+ "required": ["direction"], "x-requireOne": [["region"], ["at"]], "additionalProperties": False}},
1283
+ "maxStrain": {**_POS, "description": "largest allowed strain: sets ok"}},
1284
+ "required": ["fixed"],
1285
+ "additionalProperties": False,
1286
+ }
1287
+
1288
+
1289
+ FIELD_SCHEMA = {
1290
+ "$schema": _DIALECT,
1291
+ "$id": "https://github.com/connorwhite-online/loadpath/schema/field.schema.json",
1292
+ "title": "loadpath field graph",
1293
+ "description": "An implicit (field) part: named nodes, each a solid (signed distance, negative "
1294
+ "inside, mm) or a scalar field, where any numeric parameter can be another field.",
1295
+ "type": "object",
1296
+ "properties": {
1297
+ "name": {"type": "string", "description": "Names the output files."},
1298
+ "description": {"type": "string"},
1299
+ "nodes": {"type": "object", "additionalProperties": {"$ref": "#/$defs/node"},
1300
+ "description": "name -> node. Names are what other nodes and expr refer to."},
1301
+ "output": {"type": "string", "description": "The node to mesh (default the last)."},
1302
+ "groups": {"type": "object", "description": "name -> a reusable sub-graph that `group` nodes copy",
1303
+ "additionalProperties": {
1304
+ "type": "object", "additionalProperties": False, "required": ["nodes"],
1305
+ "properties": {
1306
+ "description": {"type": "string"},
1307
+ "params": {"type": "object", "additionalProperties": {"type": "number"},
1308
+ "description": "param -> default; the group's nodes refer to it by name"},
1309
+ "inputs": {"type": "array", "items": {"type": "string"},
1310
+ "description": "names the group's nodes use for nodes the instance gives"},
1311
+ "nodes": {"type": "object", "additionalProperties": {"$ref": "#/$defs/node"}},
1312
+ "output": {"type": "string", "description": "the node the group stands for "
1313
+ "(default the last)"}}}},
1314
+ "bounds": {**_BOX, "description": "Meshing box (default: worked out from the output)."},
1315
+ "mesh": {"type": "object", "properties": {
1316
+ "pitch": {**_POS, "description": "mm grid (default: the box over 4M cells); a third of the "
1317
+ "thinnest wall or finer"},
1318
+ "project": {"type": "integer", "minimum": 0, "description": "surface relax+projection rounds "
1319
+ "(default 3; 0 = raw marching cubes)"},
1320
+ "sharp": {"type": "boolean", "description": "hard booleans (no blend) with plain shapes and meshes are "
1321
+ "done exactly, so bores, bolt faces and keep-outs keep "
1322
+ "sharp edges (default true; false meshes the whole field, "
1323
+ "every crease rounded over about one pitch)"},
1324
+ "creases": {"type": "boolean", "description": "hard booleans between field surfaces (a lattice clipped "
1325
+ "by a body, two bodies unioned with no blend, a shell's "
1326
+ "rim) keep sharp creases: edges that cross from one "
1327
+ "surface to the other are split on the true intersection "
1328
+ "curve (default true; not with stream)"},
1329
+ "decimate": {**_POS, "description": "mm: thin the triangles wherever the mesh stays this close to "
1330
+ "the surface (flat and gently curved regions lose most of "
1331
+ "theirs; creases and small features keep them), e.g. 0.01-0.05"},
1332
+ "stream": {"type": "boolean", "description": "write the STL slab by slab without holding the mesh, "
1333
+ "in bounded memory and with no cell budget: for parts "
1334
+ "of tens of millions of triangles (fine lattices). "
1335
+ "Skips the printability check, bodies and the render."}},
1336
+ "additionalProperties": False},
1337
+ "step": {"anyOf": [{"type": "boolean"}, {"type": "object", "properties": {
1338
+ "faces": {"type": "integer", "minimum": 100, "description": "organic facets in the B-rep "
1339
+ "(default 20000, ~14 MB)"},
1340
+ "surface": {"enum": ["facets", "smooth"],
1341
+ "description": "organic regions as flat facets (default) or smooth B-spline patches "
1342
+ "(experimental: CAD-friendlier, not smaller; falls back to facets when "
1343
+ "OpenCascade's booleans fail)"},
1344
+ "patches": {"type": "integer", "minimum": 50, "description": "surface \"smooth\": organic triangles of the patch layout, two to a patch, refined where it misses (default 2000)"},
1345
+ "lattice": {"enum": ["solid", "exact", "cavities"],
1346
+ "description": "lattice parts: the lattice solid in the STEP (default; the lattice is in "
1347
+ "the STL/3MF) or \"exact\": every lattice as smooth patches on its true "
1348
+ "surface, node blends and fillets included: strut cells (octet, bcc, "
1349
+ "cubic, ...), Voronoi foams, TPMS sheets or networks; graded, conformal; "
1350
+ "under a skin or open at the outside (latticestep.export_void_step: the "
1351
+ "envelope less the void, sealed voids as inner shells, open pores cut in "
1352
+ "one boolean; ~1 KB a patch). \"cavities\": the older route for one TPMS "
1353
+ "sheet lattice under a closed skin (its two sides' cavities)"},
1354
+ "latticeGrid": {"type": "number", "exclusiveMinimum": 0,
1355
+ "description": "lattice \"exact\": patch size, mm (default the smaller of cell / 6 "
1356
+ "and half the thinnest strut or wall); coarser is smaller and misses "
1357
+ "tight fillets; much over half the thinnest strut can lose struts"}},
1358
+ "additionalProperties": False}],
1359
+ "description": "also write <name>.step (needs loadpath[step]): exact planes, cylinders and spheres "
1360
+ "where the graph's hard booleans use plain shapes, the rest faceted or smooth; "
1361
+ "with lattice nodes, the part with its lattices solid (drain bores included) or, "
1362
+ "lattice \"exact\", the lattice itself as smooth patches"},
1363
+ "lattice3mf": {"anyOf": [{"type": "boolean"}, {"type": "object", "properties": {
1364
+ "decimate": {"type": "number", "exclusiveMinimum": 0,
1365
+ "description": "body and region meshes within this many mm of the surface (default 0.02)"},
1366
+ "grid": {"type": "number", "exclusiveMinimum": 0,
1367
+ "description": "voxel size, mm, of the 3D images a graded TPMS reads (wall, cell, lattice "
1368
+ "coordinates, blend radius; default min(cell / 6, 1))"},
1369
+ "meshReaders": {"type": "boolean",
1370
+ "description": "mark the lattice region meshes type \"other\" (not the Beam Lattice "
1371
+ "spec's \"model\") so PrusaSlicer 2.x opens a beam lattice file, "
1372
+ "printing the struts' prism fallback; level sets still need an "
1373
+ "implicit-capable reader"}},
1374
+ "additionalProperties": False}],
1375
+ "description": "also write <name>-lattice.3mf (needs loadpath[3mf]): every lattice node as a lattice, "
1376
+ "TPMS as an implicit level set (3MF Volumetric & Implicit; graded cell, wall and "
1377
+ "conformal frames through 3D images; a blended union with the body kept as a smooth "
1378
+ "union in the level set), strut cells as a beam lattice with prism fallback triangles, "
1379
+ "each clipped by its own region, beside the rest of the part as a mesh"},
1380
+ "material": {**_ONE_MATERIAL, "description": "Reports the mass."},
1381
+ "materials": {"type": "array", "minItems": 1, "items": _MATERIAL_BODY,
1382
+ "description": "Multi-material: split the output into material bodies, in precedence "
1383
+ "order (an earlier body wins where regions overlap, so bodies never "
1384
+ "overlap and together they are the part). Each body is a solid node's "
1385
+ "share of the part, a field threshold's (where/above/below: TPU where a "
1386
+ "distance or a stress says), or both; a body with neither takes what is "
1387
+ "left. Writes <name>-<body>.stl per body (sharing their interfaces "
1388
+ "exactly) and a multi-material <name>.3mf; the report gives each body's "
1389
+ "volume and mass and each interface's area."},
1390
+ "minWall": {**_POS, "description": "mm, the printability check's minimum wall (default 0.8)"},
1391
+ "build": {**_VEC3, "description": "build direction for the overhang check (default +z)"},
1392
+ "check": {"type": "boolean", "description": "run the printability check (default true)"},
1393
+ "editor": {"type": "object", "description": "What `loadpath edit` keeps with the graph (node positions in "
1394
+ "'layout'); nothing else reads it."},
1395
+ "tune": {"type": "object", "additionalProperties": False, "required": ["study", "params"],
1396
+ "description": "`loadpath tune`: turn these numbers for the lightest part that still meets the "
1397
+ "study's limits, each candidate meshed and analyzed (tune.py)",
1398
+ "properties": {
1399
+ "study": {"type": "string", "description": "study JSON path or example:<name>: loads, "
1400
+ "supports, material, goal"},
1401
+ "node": {"type": "string", "description": "the node analyzed (default the output)"},
1402
+ "params": {"type": "array", "minItems": 1, "items": {
1403
+ "type": "object", "additionalProperties": False, "required": ["path", "min", "max"],
1404
+ "properties": {
1405
+ "path": {"type": "string", "description": "where the number is, '/'-separated: "
1406
+ "nodes/web/shape/max/2"},
1407
+ "also": {"type": "array", "items": {"type": "string"},
1408
+ "description": "numbers that move with it ('-' first: the other way)"},
1409
+ "min": {"type": "number"}, "max": {"type": "number"}}}},
1410
+ "safetyFactor": {**_POS, "description": "least safety factor (default the study's goal)"},
1411
+ "maxDeflection": {**_POS, "description": "mm at the loads (default the study's goal)"},
1412
+ "evaluations": {"type": "integer", "minimum": 2, "description": "analyses to spend "
1413
+ "(default 20)"},
1414
+ "sweep": {"type": "object", "minProperties": 1, "maxProperties": 1,
1415
+ "description": "the trade-off: tune again held to each of these values of one "
1416
+ "limit, e.g. {\"maxDeflection\": [0.4, 0.6, 0.8]}",
1417
+ "properties": {k: {"type": "array", "minItems": 1, "items": _POS}
1418
+ for k in ("safetyFactor", "maxDeflection")},
1419
+ "additionalProperties": False},
1420
+ "analysis": {"enum": ["auto", "solid", "homogenized"],
1421
+ "description": "auto (default): a part with a lattice is analyzed with the "
1422
+ "lattice homogenized (unit-cell stiffness and wall stress), "
1423
+ "else meshed and verified as a solid; homogenized on a part with no "
1424
+ "lattice reads it on the same fill-weighted grid (thin skins "
1425
+ "without a fine mesh)"},
1426
+ "pitch": {**_POS, "description": "mm mesh for each candidate (default 1)"},
1427
+ "cells": {"type": "integer", "minimum": 1000, "description": "analysis cells (default 30000)"},
1428
+ "checkCells": {"type": "integer", "minimum": 1000,
1429
+ "description": "most cells for the confirming check, which runs at two cells "
1430
+ "through the thinnest shell wall (default 2000000)"}}},
1431
+ "impact": {"type": "object", "properties": {
1432
+ "envelope": {"type": "string", "description": "solid node the lattice core fills (the crush pad)"},
1433
+ "density": {"anyOf": [{"type": "string"}, {"type": "number", "exclusiveMinimum": 0, "maximum": 1}],
1434
+ "description": "relative density: a scalar node (the one the lattice node uses) or a "
1435
+ "number (default 1 = solid)"},
1436
+ "lattice": {"enum": ["gyroid", "diamond", "schwarz-p"], "description": "sheet type (default gyroid)"},
1437
+ "axis": {"enum": ["x", "y", "z"], "description": "crush direction (default z)"},
1438
+ "mass": {**_POS, "description": "kg, the moving mass"},
1439
+ "velocity": {**_POS, "description": "m/s at contact"},
1440
+ "energy": {**_POS, "description": "J, instead of mass and velocity"},
1441
+ "forceLimit": {**_POS, "description": "N, the most the part behind may feel"},
1442
+ "pitch": {**_POS, "description": "mm sampling grid (default 0.5)"},
1443
+ "grade": {"type": "boolean", "description": "also work out the flattest graded core for this "
1444
+ "envelope and give it as a density remap table"},
1445
+ "rhoMin": {**_POS, "description": "lowest printable relative density for grading (default 0.12)"},
1446
+ "rhoMax": {**_POS, "description": "highest relative density for grading (default 0.5)"},
1447
+ "cell": {**_POS, "description": "mm lattice cell: with minWall, raises rhoMin to the thinnest "
1448
+ "printable wall"},
1449
+ "minWall": {**_POS, "description": "mm thinnest printable sheet (default 0.8)"},
1450
+ "rate": {"type": "boolean", "description": "elastomers (TPU): stiffen the crush law for the "
1451
+ "impact's crush time (viscoelastic.py; default true)"}},
1452
+ "required": ["envelope"], "additionalProperties": False,
1453
+ "description": "Crush and impact of a lattice core (impact.py): force-crush curve, where a "
1454
+ "mass at a speed stops, peak force and g, and with grade a density table that "
1455
+ "crushes every layer at the same force. Needs material."},
1456
+ "vibration": {"type": "object", "properties": {
1457
+ "envelope": {"type": "string", "description": "solid node analysed (homogenized lattice inside)"},
1458
+ "density": {"anyOf": [{"type": "string"}, {"type": "number", "exclusiveMinimum": 0, "maximum": 1}],
1459
+ "description": "relative density node or number (default 1 = solid)"},
1460
+ "lattice": {"enum": ["gyroid", "diamond", "schwarz-p"]},
1461
+ "fixed": {"type": "string", "description": "solid node: nodes inside it are clamped"},
1462
+ "pointMasses": {"type": "array", "items": {"type": "object", "properties": {
1463
+ "region": {"type": "string", "description": "solid node the mass attaches over"},
1464
+ "mass": {**_POS, "description": "kg"}}, "required": ["region", "mass"],
1465
+ "additionalProperties": False}},
1466
+ "modes": {"type": "integer", "minimum": 1, "maximum": 50, "description": "how many (default 6)"},
1467
+ "pitch": {**_POS, "description": "mm analysis grid (default 1)"},
1468
+ "minFrequency": {**_POS, "description": "Hz the first mode must clear"},
1469
+ "excitation": {**_POS, "description": "Hz of a known shake: reports the worst transmissibility"},
1470
+ "lossFactor": {**_POS, "description": "material loss factor (default from modal.LOSS_FACTOR)"}},
1471
+ "required": ["envelope", "fixed"], "additionalProperties": False,
1472
+ "description": "Natural frequencies, effective masses and damping (modal.py). Needs "
1473
+ "material."},
1474
+ "largeDeflection": _LARGE_DEFLECTION,
1475
+ "process": {**_PROCESS, "description": "How it is made: sets the minimum wall, the build direction and "
1476
+ "the drain holes (sls, mjf, sla, dlp, volumetric, lpbf drain "
1477
+ "sealed voids by default)."},
1478
+ "drains": {"anyOf": [
1479
+ {"enum": ["auto"]}, {"type": "boolean"},
1480
+ {"type": "object", "properties": {
1481
+ "enabled": {"type": "boolean"},
1482
+ "diameter": {**_POS, "description": "mm (default 1.5 x the process's minimum escape hole or 2 mm "
1483
+ "over it, whichever is larger: SLS 6, MJF 7.5, SLA/DLP 5.5, "
1484
+ "volumetric 5; LPBF 6.8, an M8 tap drill)"},
1485
+ "count": {"type": "integer", "minimum": 1, "description": "holes per void (default 2: one "
1486
+ "low, one high along the build)"},
1487
+ "stress": {"type": ["string", "null"], "description": "node that ranks the sites, calmest "
1488
+ "first (default the first result node; "
1489
+ "null: none)"},
1490
+ "boss": {**_POS, "description": "mm of solid round each hole (default max(1.5 x min wall, "
1491
+ "0.4 x radius))"},
1492
+ "blend": {"type": "number", "minimum": 0, "description": "mm fillet boss to part "
1493
+ "(default the boss wall)"},
1494
+ "maxDepth": {**_POS, "description": "mm: sites at most this far under the outside (default 6)"},
1495
+ "fillBelow": {"type": "number", "minimum": 0, "description": "mm^3: smaller voids are filled "
1496
+ "solid (default the escape ball)"},
1497
+ "keepClear": {"type": "array", "items": {"type": "string"},
1498
+ "description": "solid nodes the bosses stay out of (bearing seats, mating faces)"},
1499
+ "clearance": {"type": "number", "minimum": 0, "description": "mm from keepClear (default 1)"},
1500
+ "pitch": {**_POS, "description": "mm grid for finding voids (default the mesh pitch)"},
1501
+ "maxPath": {"anyOf": [_POS, {"type": "null"}],
1502
+ "description": "mm: add holes (up to 8 a void) while any powder is farther than this "
1503
+ "from a hole, through the void (default 60; null: just count)"}},
1504
+ "additionalProperties": False}],
1505
+ "description": "Drain holes into sealed voids (trapped powder or resin): 'auto' (default) drills "
1506
+ "for powder-bed and resin processes, true always, false never. Holes go at the "
1507
+ "calmest flat sites, each on a solid boss; pockets too small for a hole are filled; "
1508
+ "the report says what (if anything) is still sealed."},
1509
+ },
1510
+ "required": ["nodes"],
1511
+ "additionalProperties": False,
1512
+ "$defs": {**_SHAPES, "shape": _SHAPE, "node": _NODE, **{f"op-{k}": v for k, v in _FIELD_OPS.items()}},
1513
+ }
1514
+
1515
+ SCHEMAS = {"study": STUDY_SCHEMA, "heatExchanger": HEAT_EXCHANGER_SCHEMA, "lattice": LATTICE_SCHEMA,
1516
+ "field": FIELD_SCHEMA}
1517
+
1518
+
1519
+ def schema(kind: str = "study") -> dict:
1520
+ """A copy of one schema: 'study', 'heatExchanger', 'lattice' or 'field'."""
1521
+ if kind not in SCHEMAS:
1522
+ raise ValueError(f"schema kind must be one of {sorted(SCHEMAS)}")
1523
+ return copy.deepcopy(SCHEMAS[kind])
1524
+
1525
+
1526
+ # --- linting: a small validator for the subset of JSON Schema used above ---
1527
+
1528
+ _TYPES = {"object": dict, "array": list, "string": str, "boolean": bool, "null": type(None)}
1529
+
1530
+
1531
+ def _is(v, t):
1532
+ if t == "number":
1533
+ return isinstance(v, (int, float)) and not isinstance(v, bool)
1534
+ if t == "integer":
1535
+ return isinstance(v, int) and not isinstance(v, bool) or (isinstance(v, float) and v.is_integer())
1536
+ return isinstance(v, _TYPES[t])
1537
+
1538
+
1539
+ def _resolve(node, root):
1540
+ while "$ref" in node:
1541
+ node = root["$defs"][node["$ref"].rsplit("/", 1)[1]]
1542
+ return node
1543
+
1544
+
1545
+ def _brief(node, root):
1546
+ node = _resolve(node, root)
1547
+ if "title" in node:
1548
+ return node["title"]
1549
+ if "enum" in node:
1550
+ return "one of " + ", ".join(json.dumps(v) for v in node["enum"])
1551
+ if "const" in node:
1552
+ return json.dumps(node["const"])
1553
+ if node.get("type") == "array" and node.get("minItems") == node.get("maxItems") == 3:
1554
+ return "[x, y, z]"
1555
+ if "description" in node:
1556
+ return node["description"]
1557
+ return node.get("type", "a valid value")
1558
+
1559
+
1560
+ def _check(v, node, root, path, out):
1561
+ node = _resolve(node, root)
1562
+ if "x-discriminator" in node:
1563
+ disc = node["x-discriminator"]
1564
+ key = disc["property"]
1565
+ if not isinstance(v, dict) or key not in v:
1566
+ out.append(("error", path, f"must be an object with a '{key}' ({', '.join(disc['mapping'])})"))
1567
+ return
1568
+ target = disc["mapping"].get(v[key])
1569
+ if target is None:
1570
+ near = [_ALIASES[v[key]][0]] if v[key] in _ALIASES else \
1571
+ difflib.get_close_matches(str(v[key]), list(disc["mapping"]), 1)
1572
+ hint = f"; did you mean {near[0]!r}?" if near else ""
1573
+ out.append(("error", f"{path}.{key}", f"unknown {key} {v[key]!r}: one of "
1574
+ f"{', '.join(disc['mapping'])}{hint}"))
1575
+ return
1576
+ _check(v, {"$ref": target}, root, path, out)
1577
+ return
1578
+ for kw in ("anyOf", "oneOf"):
1579
+ if kw in node:
1580
+ trials = []
1581
+ for alt in node[kw]:
1582
+ sub = []
1583
+ _check(v, alt, root, path, sub)
1584
+ if not any(s[0] == "error" for s in sub):
1585
+ out.extend(sub)
1586
+ return
1587
+ trials.append((alt, sub))
1588
+ # report against the alternative of the right type, else list them
1589
+ typed = [(a, s) for a, s in trials if _type_ok(v, _resolve(a, root))]
1590
+ if len(typed) == 1:
1591
+ out.extend(typed[0][1])
1592
+ else:
1593
+ out.append(("error", path, "must be " + " or ".join(_brief(a, root) for a, _ in trials)
1594
+ + f", got {json.dumps(v)[:60]}"))
1595
+ return
1596
+ if not _type_ok(v, node):
1597
+ out.append(("error", path, f"must be {_brief(node, root)}, got {json.dumps(v)[:60]}"))
1598
+ return
1599
+ if "const" in node and v != node["const"]:
1600
+ out.append(("error", path, f"must be {json.dumps(node['const'])}"))
1601
+ if "enum" in node and v not in node["enum"]:
1602
+ near = difflib.get_close_matches(str(v), [str(e) for e in node["enum"]], 1)
1603
+ hint = f"; did you mean {near[0]!r}?" if near else ""
1604
+ out.append(("error", path, f"must be {_brief(node, root)}, got {json.dumps(v)}{hint}"))
1605
+ if "pattern" in node and isinstance(v, str) and not re.fullmatch(node["pattern"], v):
1606
+ out.append(("error", path, f"{v!r} is not valid: {node.get('description', node['pattern'])}"))
1607
+ if _is(v, "number"):
1608
+ if "minimum" in node and v < node["minimum"]:
1609
+ out.append(("error", path, f"must be at least {node['minimum']}, got {v}"))
1610
+ if "maximum" in node and v > node["maximum"]:
1611
+ out.append(("error", path, f"must be at most {node['maximum']}, got {v}"))
1612
+ if "exclusiveMinimum" in node and v <= node["exclusiveMinimum"]:
1613
+ out.append(("error", path, f"must be greater than {node['exclusiveMinimum']}, got {v}"))
1614
+ if isinstance(v, list):
1615
+ if "minItems" in node and len(v) < node["minItems"]:
1616
+ out.append(("error", path, f"needs at least {node['minItems']} item(s)"))
1617
+ if "maxItems" in node and len(v) > node["maxItems"]:
1618
+ out.append(("error", path, f"takes at most {node['maxItems']} items"))
1619
+ if "items" in node:
1620
+ for i, x in enumerate(v):
1621
+ _check(x, node["items"], root, f"{path}[{i}]", out)
1622
+ if isinstance(v, dict):
1623
+ props = node.get("properties", {})
1624
+ for k in node.get("required", []):
1625
+ if k not in v:
1626
+ out.append(("error", path, f"missing '{k}' ({_brief(props.get(k, {}), root)})"))
1627
+ groups = node.get("x-requireOne")
1628
+ if groups and not any(all(k in v for k in g) for g in groups):
1629
+ out.append(("error", path, "needs " + " or ".join("+".join(g) for g in groups)))
1630
+ for k, x in v.items():
1631
+ if k in props:
1632
+ _check(x, props[k], root, f"{path}.{k}", out)
1633
+ elif isinstance(node.get("additionalProperties"), dict):
1634
+ _check(x, node["additionalProperties"], root, f"{path}.{k}", out)
1635
+ elif node.get("additionalProperties") is False:
1636
+ near = [p for p in _ALIASES.get(k, []) if p in props] or \
1637
+ [p for p in props if p.lower() == k.lower()] or difflib.get_close_matches(k, list(props), 1, 0.6)
1638
+ if k in ("cycles", "hours", "temperature") and ".loads[" in path:
1639
+ out.append(("error", f"{path}.{k}", f"{k} goes on the load case, not on a load "
1640
+ "(it applies to the whole case)"))
1641
+ elif near:
1642
+ out.append(("error", f"{path}.{k}", f"unknown key; did you mean '{near[0]}'?"))
1643
+ else:
1644
+ out.append(("warning", f"{path}.{k}", "unknown key, ignored (known: "
1645
+ + ", ".join(props) + ")"))
1646
+
1647
+
1648
+ # common words for a field that difflib would not match
1649
+ _ALIASES = {"radius": ["r"], "diameter": ["r"], "innerRadius": ["rInner"], "bolts": ["fasteners"],
1650
+ "screws": ["fasteners"], "holes": ["keepOut"], "fixed": ["supports"], "constraints": ["supports"],
1651
+ "forces": ["loads"], "torque": ["moment"], "domain": ["designSpace"], "bounds": ["designSpace"],
1652
+ # shape types
1653
+ "cube": ["box"], "cuboid": ["box"], "rect": ["box"], "cyl": ["cylinder"], "rod": ["cylinder"],
1654
+ "pipe": ["tube"], "ball": ["sphere"], "stl": ["mesh"], "group": ["union"],
1655
+ # material bodies
1656
+ "within": ["solid"], "inside": ["solid"], "threshold": ["below", "above"]}
1657
+
1658
+
1659
+ def _type_ok(v, node):
1660
+ if "x-discriminator" in node:
1661
+ return isinstance(v, dict)
1662
+ t = node.get("type")
1663
+ if t is None:
1664
+ if "const" in node:
1665
+ return v == node["const"] or type(v) is type(node["const"])
1666
+ if "enum" in node:
1667
+ return any(type(v) is type(e) for e in node["enum"])
1668
+ return True
1669
+ return any(_is(v, x) for x in (t if isinstance(t, list) else [t]))
1670
+
1671
+
1672
+ def lint(doc, kind: str = "study") -> list[dict]:
1673
+ """Every problem in a document against its schema, as
1674
+ [{"level": "error" | "warning", "path": "$.keepOut[0].shape", "message"}]."""
1675
+ root = SCHEMAS[kind]
1676
+ if isinstance(doc, str):
1677
+ try:
1678
+ doc = json.loads(doc)
1679
+ except json.JSONDecodeError as exc:
1680
+ return [{"level": "error", "path": "$", "message": f"not valid JSON: {exc}"}]
1681
+ out = []
1682
+ _check(doc, root, root, "$", out)
1683
+ if kind == "field" and isinstance(doc, dict) and not any(lv == "error" for lv, _, _ in out):
1684
+ # beyond JSON Schema: references, expressions and cycles
1685
+ from .fields import FieldGraph
1686
+ try:
1687
+ FieldGraph(doc)
1688
+ except ValueError as exc:
1689
+ out.append(("error", "$.nodes", str(exc)))
1690
+ if kind == "study" and isinstance(doc, dict) and not doc.get("supports"):
1691
+ # beyond JSON Schema: supports are optional only for pressure vessels
1692
+ cases = doc.get("loadCases") or ([{"loads": doc["loads"]}] if isinstance(doc.get("loads"), list) else [])
1693
+ loads = [ld for c in cases if isinstance(c, dict) for ld in (c.get("loads") or [])]
1694
+ if not loads or not all(isinstance(ld, dict) and ld.get("pressure") for ld in loads):
1695
+ out.append(("error", "$.supports", "needs at least one support (where the part is held); only a "
1696
+ "study whose every load is a pressure may leave them out"))
1697
+ return [{"level": lv, "path": p, "message": m} for lv, p, m in out]
1698
+
1699
+
1700
+ def format_issues(issues: list[dict]) -> str:
1701
+ return "\n".join(f"{i['level']}: {i['path']}: {i['message']}" for i in issues)