rocket-engine 0.0.2b1__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 (241) hide show
  1. rocket_engine/__init__.py +8 -0
  2. rocket_engine/__main__.py +12 -0
  3. rocket_engine/adapters/README.md +54 -0
  4. rocket_engine/adapters/__init__.py +7 -0
  5. rocket_engine/adapters/api.py +39 -0
  6. rocket_engine/adapters/base.py +46 -0
  7. rocket_engine/adapters/cace/__init__.py +163 -0
  8. rocket_engine/adapters/circulax/README.md +10 -0
  9. rocket_engine/adapters/circulax/__init__.py +112 -0
  10. rocket_engine/adapters/circulax/_driver.py +190 -0
  11. rocket_engine/adapters/circulax/adapter.py +538 -0
  12. rocket_engine/adapters/circulax/results.py +291 -0
  13. rocket_engine/adapters/cli/README.md +34 -0
  14. rocket_engine/adapters/cli/__init__.py +305 -0
  15. rocket_engine/adapters/connectivity/__init__.py +196 -0
  16. rocket_engine/adapters/connectivity/results.py +214 -0
  17. rocket_engine/adapters/cosim/README.md +9 -0
  18. rocket_engine/adapters/cosim/__init__.py +1390 -0
  19. rocket_engine/adapters/cosim/results.py +214 -0
  20. rocket_engine/adapters/devsim/__init__.py +171 -0
  21. rocket_engine/adapters/digital_flow/README.md +9 -0
  22. rocket_engine/adapters/digital_flow/__init__.py +1121 -0
  23. rocket_engine/adapters/digital_flow/results.py +227 -0
  24. rocket_engine/adapters/digital_flow/state.py +462 -0
  25. rocket_engine/adapters/drc/README.klayout.md +7 -0
  26. rocket_engine/adapters/drc/README.md +7 -0
  27. rocket_engine/adapters/drc/__init__.py +1 -0
  28. rocket_engine/adapters/drc/klayout.py +952 -0
  29. rocket_engine/adapters/drc/magic.py +287 -0
  30. rocket_engine/adapters/drc/results.py +184 -0
  31. rocket_engine/adapters/drc/results_kl.py +188 -0
  32. rocket_engine/adapters/elmer/__init__.py +163 -0
  33. rocket_engine/adapters/envelope.py +120 -0
  34. rocket_engine/adapters/equivalence/__init__.py +156 -0
  35. rocket_engine/adapters/errors.py +181 -0
  36. rocket_engine/adapters/extract_magic/README.md +15 -0
  37. rocket_engine/adapters/extract_magic/__init__.py +425 -0
  38. rocket_engine/adapters/extract_magic/results.py +209 -0
  39. rocket_engine/adapters/formal_sby/README.md +7 -0
  40. rocket_engine/adapters/formal_sby/__init__.py +773 -0
  41. rocket_engine/adapters/formal_sby/formal_edge.py +195 -0
  42. rocket_engine/adapters/formal_sby/results.py +185 -0
  43. rocket_engine/adapters/gdsfactory/__init__.py +194 -0
  44. rocket_engine/adapters/gdsfactory/_driver.py +459 -0
  45. rocket_engine/adapters/gdsfactory/adapter.py +609 -0
  46. rocket_engine/adapters/gdsfactory/results.py +181 -0
  47. rocket_engine/adapters/hdl_sim/README.md +7 -0
  48. rocket_engine/adapters/hdl_sim/__init__.py +369 -0
  49. rocket_engine/adapters/hdl_sim/ghdl.py +155 -0
  50. rocket_engine/adapters/hdl_sim/results.py +178 -0
  51. rocket_engine/adapters/hdl_sim/verilator.py +167 -0
  52. rocket_engine/adapters/http/README.md +15 -0
  53. rocket_engine/adapters/http/__init__.py +458 -0
  54. rocket_engine/adapters/http/events.py +63 -0
  55. rocket_engine/adapters/ipc/README.md +9 -0
  56. rocket_engine/adapters/ipc/__init__.py +111 -0
  57. rocket_engine/adapters/lvs_netgen/README.md +20 -0
  58. rocket_engine/adapters/lvs_netgen/__init__.py +889 -0
  59. rocket_engine/adapters/lvs_netgen/results.py +168 -0
  60. rocket_engine/adapters/mcp/README.md +19 -0
  61. rocket_engine/adapters/mcp/__init__.py +85 -0
  62. rocket_engine/adapters/mcp/api-schema.json +6107 -0
  63. rocket_engine/adapters/mcp/pull.py +246 -0
  64. rocket_engine/adapters/mcp/server.py +794 -0
  65. rocket_engine/adapters/meep/__init__.py +167 -0
  66. rocket_engine/adapters/meow/README.md +7 -0
  67. rocket_engine/adapters/meow/__init__.py +190 -0
  68. rocket_engine/adapters/meow/_driver.py +227 -0
  69. rocket_engine/adapters/meow/_envelope.py +108 -0
  70. rocket_engine/adapters/meow/adapter.py +491 -0
  71. rocket_engine/adapters/meow/results.py +218 -0
  72. rocket_engine/adapters/mutation/__init__.py +192 -0
  73. rocket_engine/adapters/ngspice/README.md +28 -0
  74. rocket_engine/adapters/ngspice/__init__.py +627 -0
  75. rocket_engine/adapters/ngspice/results.py +215 -0
  76. rocket_engine/adapters/postlayout/README.md +28 -0
  77. rocket_engine/adapters/postlayout/__init__.py +17 -0
  78. rocket_engine/adapters/postlayout/adapter.py +1062 -0
  79. rocket_engine/adapters/postlayout/results.py +330 -0
  80. rocket_engine/adapters/postlayout/translate.py +71 -0
  81. rocket_engine/adapters/profile.py +220 -0
  82. rocket_engine/adapters/sax/README.md +23 -0
  83. rocket_engine/adapters/sax/__init__.py +516 -0
  84. rocket_engine/adapters/sax/_driver.py +148 -0
  85. rocket_engine/adapters/sax/results.py +291 -0
  86. rocket_engine/adapters/stdio_console.py +1151 -0
  87. rocket_engine/adapters/syntax/README.md +5 -0
  88. rocket_engine/adapters/syntax/__init__.py +366 -0
  89. rocket_engine/adapters/syntax/results.py +181 -0
  90. rocket_engine/adapters/synth_yosys/README.md +7 -0
  91. rocket_engine/adapters/synth_yosys/__init__.py +655 -0
  92. rocket_engine/adapters/synth_yosys/amaranth.py +120 -0
  93. rocket_engine/adapters/synth_yosys/ghdl.py +128 -0
  94. rocket_engine/adapters/synth_yosys/results.py +173 -0
  95. rocket_engine/adapters/timing_sta/README.md +9 -0
  96. rocket_engine/adapters/timing_sta/__init__.py +726 -0
  97. rocket_engine/adapters/view_sync/__init__.py +185 -0
  98. rocket_engine/adapters/view_sync/results.py +189 -0
  99. rocket_engine/adapters/vlsir/__init__.py +142 -0
  100. rocket_engine/adapters/xschem/__init__.py +440 -0
  101. rocket_engine/adapters/xschem/deck.py +266 -0
  102. rocket_engine/clients/__init__.py +5 -0
  103. rocket_engine/clients/runtime.py +255 -0
  104. rocket_engine/composition.py +2279 -0
  105. rocket_engine/execution/README.md +30 -0
  106. rocket_engine/execution/__init__.py +150 -0
  107. rocket_engine/execution/apig/__init__.py +1 -0
  108. rocket_engine/execution/apig/pool.py +463 -0
  109. rocket_engine/execution/apig/submit.py +124 -0
  110. rocket_engine/execution/block_runs_scope.py +479 -0
  111. rocket_engine/execution/coalesce.py +414 -0
  112. rocket_engine/execution/console/__init__.py +1 -0
  113. rocket_engine/execution/console/apply_session.py +318 -0
  114. rocket_engine/execution/console/lifecycle.py +265 -0
  115. rocket_engine/execution/dispatch.py +205 -0
  116. rocket_engine/execution/errors.py +31 -0
  117. rocket_engine/execution/ports.py +797 -0
  118. rocket_engine/execution/pyexec/__init__.py +1 -0
  119. rocket_engine/execution/pyexec/apply_context.py +547 -0
  120. rocket_engine/execution/pyexec/approvals.py +173 -0
  121. rocket_engine/execution/pyexec/runner.py +404 -0
  122. rocket_engine/execution/pyexec/support.py +464 -0
  123. rocket_engine/execution/python_exec.py +897 -0
  124. rocket_engine/execution/queue.py +85 -0
  125. rocket_engine/execution/replay_safe.py +601 -0
  126. rocket_engine/execution/routing/__init__.py +1 -0
  127. rocket_engine/execution/routing/calls.py +364 -0
  128. rocket_engine/execution/routing/namespace.py +785 -0
  129. rocket_engine/execution/routing/ports.py +102 -0
  130. rocket_engine/execution/routing/tools.py +346 -0
  131. rocket_engine/execution/runner.py +311 -0
  132. rocket_engine/execution/runtime.py +1149 -0
  133. rocket_engine/execution/service.py +279 -0
  134. rocket_engine/execution/worker.py +464 -0
  135. rocket_engine/interfaces/README.md +26 -0
  136. rocket_engine/interfaces/__init__.py +58 -0
  137. rocket_engine/interfaces/apig_assets.py +407 -0
  138. rocket_engine/interfaces/celllink.py +461 -0
  139. rocket_engine/interfaces/documents.py +92 -0
  140. rocket_engine/interfaces/envelopes.py +131 -0
  141. rocket_engine/interfaces/errors.py +16 -0
  142. rocket_engine/interfaces/layout.py +410 -0
  143. rocket_engine/interfaces/layout_schema.yaml +278 -0
  144. rocket_engine/interfaces/paths.py +223 -0
  145. rocket_engine/interfaces/pdks.py +550 -0
  146. rocket_engine/interfaces/ports.py +72 -0
  147. rocket_engine/interfaces/references.py +115 -0
  148. rocket_engine/interfaces/refs.py +85 -0
  149. rocket_engine/interfaces/runparams.py +304 -0
  150. rocket_engine/interfaces/setup.py +552 -0
  151. rocket_engine/interfaces/setup_catalogue.yaml +243 -0
  152. rocket_engine/interfaces/setup_probes.py +66 -0
  153. rocket_engine/interfaces/vocabulary.py +58 -0
  154. rocket_engine/interfaces/yaml_dump.py +31 -0
  155. rocket_engine/library/README.md +22 -0
  156. rocket_engine/library/__init__.py +13 -0
  157. rocket_engine/library/apig_catalog.py +371 -0
  158. rocket_engine/library/catalog.py +185 -0
  159. rocket_engine/library/errors.py +16 -0
  160. rocket_engine/mutate/__init__.py +776 -0
  161. rocket_engine/mutate/operators.py +700 -0
  162. rocket_engine/ops/__init__.py +1 -0
  163. rocket_engine/ops/deferred.py +279 -0
  164. rocket_engine/ops/editors.py +404 -0
  165. rocket_engine/ops/evidence.py +194 -0
  166. rocket_engine/ops/gates.py +262 -0
  167. rocket_engine/ops/ports.py +76 -0
  168. rocket_engine/ops/queries.py +417 -0
  169. rocket_engine/ops/runs.py +553 -0
  170. rocket_engine/ops/submit.py +614 -0
  171. rocket_engine/project/README.md +27 -0
  172. rocket_engine/project/__init__.py +6 -0
  173. rocket_engine/project/canvas/__init__.py +55 -0
  174. rocket_engine/project/canvas/compile.py +109 -0
  175. rocket_engine/project/canvas/integrate.py +48 -0
  176. rocket_engine/project/canvas/schema.py +241 -0
  177. rocket_engine/project/canvas/templates.py +46 -0
  178. rocket_engine/project/canvas/validate.py +603 -0
  179. rocket_engine/project/edit/__init__.py +5 -0
  180. rocket_engine/project/edit/cas.py +87 -0
  181. rocket_engine/project/edit/transactions.py +282 -0
  182. rocket_engine/project/errors.py +16 -0
  183. rocket_engine/project/frontend13.py +685 -0
  184. rocket_engine/project/geometry/__init__.py +23 -0
  185. rocket_engine/project/geometry/ops.py +451 -0
  186. rocket_engine/project/geometry/snap.py +103 -0
  187. rocket_engine/project/ip/__init__.py +1 -0
  188. rocket_engine/project/ip/blocks.py +195 -0
  189. rocket_engine/project/ip/entry.schema.json +78 -0
  190. rocket_engine/project/ip/lifecycle.py +402 -0
  191. rocket_engine/project/ip/verbs.py +123 -0
  192. rocket_engine/project/layers/__init__.py +6 -0
  193. rocket_engine/project/layers/bindings.py +115 -0
  194. rocket_engine/project/layers/catalog.py +228 -0
  195. rocket_engine/project/layers/presets.py +43 -0
  196. rocket_engine/project/ledger/__init__.py +1 -0
  197. rocket_engine/project/ledger/docs.py +167 -0
  198. rocket_engine/project/ledger/entities.py +176 -0
  199. rocket_engine/project/ledger/events.py +29 -0
  200. rocket_engine/project/ledger/runs.py +257 -0
  201. rocket_engine/project/ledger/shared.py +174 -0
  202. rocket_engine/project/ledger/snapshots.py +385 -0
  203. rocket_engine/project/ledger/wires.py +552 -0
  204. rocket_engine/project/process/__init__.py +14 -0
  205. rocket_engine/project/process/model.py +193 -0
  206. rocket_engine/project/process/recipes.py +121 -0
  207. rocket_engine/project/schematic/__init__.py +1 -0
  208. rocket_engine/project/schematic/hierarchy.py +312 -0
  209. rocket_engine/project/schematic/instances.py +393 -0
  210. rocket_engine/project/schematic/sheets.py +198 -0
  211. rocket_engine/project/service.py +2941 -0
  212. rocket_engine/project/symbols/__init__.py +1 -0
  213. rocket_engine/project/symbols/cells.py +324 -0
  214. rocket_engine/project/symbols/linkage.py +356 -0
  215. rocket_engine/project/symbols/symbols.py +352 -0
  216. rocket_engine/project/templates/__init__.py +303 -0
  217. rocket_engine/project/templates/errors.py +25 -0
  218. rocket_engine/project/testbench/__init__.py +49 -0
  219. rocket_engine/project/testbench/blocks.py +619 -0
  220. rocket_engine/project/testbench/templates.py +134 -0
  221. rocket_engine/stores/README.md +12 -0
  222. rocket_engine/stores/__init__.py +6 -0
  223. rocket_engine/stores/ecs.py +1508 -0
  224. rocket_engine/stores/layers.py +1023 -0
  225. rocket_engine/stores/settings.py +1188 -0
  226. rocket_engine/stores/wireplace.py +1324 -0
  227. rocket_engine/workflows/README.md +18 -0
  228. rocket_engine/workflows/__init__.py +53 -0
  229. rocket_engine/workflows/capability_plan.py +102 -0
  230. rocket_engine/workflows/completion.py +228 -0
  231. rocket_engine/workflows/connectgraph.py +734 -0
  232. rocket_engine/workflows/connectgraph_emit.py +336 -0
  233. rocket_engine/workflows/gates.py +144 -0
  234. rocket_engine/workflows/photonic_wire.py +76 -0
  235. rocket_engine/workflows/stages.py +324 -0
  236. rocket_engine/workflows/viewsync.py +399 -0
  237. rocket_engine-0.0.2b1.dist-info/METADATA +200 -0
  238. rocket_engine-0.0.2b1.dist-info/RECORD +241 -0
  239. rocket_engine-0.0.2b1.dist-info/WHEEL +4 -0
  240. rocket_engine-0.0.2b1.dist-info/entry_points.txt +2 -0
  241. rocket_engine-0.0.2b1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1023 @@
1
+ """Layer stackup + preset serving for the viewer and 3D (LAYER-SERVING).
2
+
3
+ The engine is the queryable rendering authority: per-PDK Stackup
4
+ (ordered layers, metal/tech grouping, enabled set, colors, Z
5
+ heights) served read-only from PDK truth, plus LAYER_PRESET
6
+ records mirroring KiCad's `{layers, renderLayers, flip, active}`
7
+ with built-ins (All/None/All-metal/Top/Bottom), user
8
+ save/overwrite/delete, and explicit apply. Viewer rows derive
9
+ from the same schemas.
10
+
11
+ PDK truth (sky130A in MVP): the registered store entry's KLayout
12
+ tech files — `.lyp` (layer order, colors, valid/visible flags),
13
+ `.lyt` (tech name, database unit), and the xsection `.xs` script
14
+ (film thicknesses for Z heights). Files resolve through
15
+ PDK-STORE's hash-checked `resolve_deck`, so drift fails loudly
16
+ instead of rendering stale fab data. Missing xsect serves
17
+ `z_complete=False` with a reason — the 3D adapter must refuse to
18
+ render heights, never invent them. The PDK remains truth: every
19
+ call re-parses and returns fresh objects, and no API mutates a
20
+ stackup (no user fork in MVP).
21
+ """
22
+
23
+ import ast
24
+ import copy
25
+ import re
26
+ import xml.etree.ElementTree as ET
27
+ from pathlib import Path
28
+
29
+ from ..interfaces.errors import ContractError
30
+ from ..interfaces.paths import join_contained, slugify
31
+ from ..interfaces.pdks import PdkError, list_pdks, resolve_deck
32
+
33
+ STACKUP_VERSION = 1
34
+ PRESET_VERSION = 1
35
+
36
+ BUILTIN_PRESETS = ("All", "None", "All-metal", "Top", "Bottom")
37
+
38
+ GROUPS = ("metal", "via", "frontend", "tech", "other")
39
+
40
+ # PDK slug -> store relpaths for the stackup sources. Only sky130A in
41
+ # MVP; a PDK without an entry raises unknown_stackup (loud, with the
42
+ # remedy to extend this table — never a silent empty stackup).
43
+ STACKUP_FILES = {
44
+ "sky130a": {
45
+ "lyp": "libs.tech/klayout/tech/sky130A.lyp",
46
+ "lyt": "libs.tech/klayout/tech/sky130A.lyt",
47
+ "xsect": "libs.tech/klayout/tech/xsect/sky130.xs",
48
+ },
49
+ }
50
+
51
+ _METAL_BASES = frozenset({"li1", "met1", "met2", "met3", "met4", "met5"})
52
+ _VIA_BASES = frozenset({"licon1", "mcon", "via", "via2", "via3", "via4"})
53
+ _FRONTEND_BASES = frozenset({
54
+ "diff", "tap", "nwell", "pwell", "dnwell", "poly", "nsdm", "psdm",
55
+ "rpm", "urpm", "npc", "hvntm", "hvi", "hvtr", "hvtp", "lvtn",
56
+ "tunm", "ldntm", "pwbm", "pwde",
57
+ })
58
+ _TECH_BASES = frozenset({"capm", "cap2m", "pad", "nsm", "prBoundary"})
59
+
60
+ # Top-side preset: the top metal plus back-end tech layers.
61
+ _TOP_BASES = frozenset({"met5", "capm", "cap2m", "pad", "nsm", "prBoundary"})
62
+
63
+ # Viewer aids carried on every preset (KiCad's renderLayers analogue:
64
+ # non-stackup toggles the viewer owns). Unknown keys pass through so
65
+ # the viewer can evolve without an engine change.
66
+ _RENDER_DEFAULT = {"grid": True, "cellframe": True, "labels": True, "rulers": True}
67
+
68
+ # Idents that must resolve for z_complete (conductors + contacts).
69
+ _Z_REQUIRED = (
70
+ "poly", "li1", "met1", "met2", "met3", "met4", "met5",
71
+ "licon1", "mcon", "via", "via2", "via3", "via4",
72
+ )
73
+
74
+ _XS_INPUT_RE = re.compile(
75
+ r"""^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*layer\(\s*"(\d+)\s*/\s*(\d+)"\s*\)"""
76
+ )
77
+ _XS_ASSIGN_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+?)\s*$")
78
+ _XS_ARITH_CHARS = re.compile(r"[0-9A-Za-z_+\-*/(). \t]+")
79
+
80
+ _BINOPS = (ast.Add, ast.Sub, ast.Mult, ast.Div)
81
+
82
+
83
+ class LayerServingError(ContractError):
84
+ """Layer-serving violation with a stable machine code.
85
+
86
+ Args:
87
+ code: Stable machine-readable error code.
88
+ message: Human-readable detail.
89
+ fields: Itemized fields, when the error names any.
90
+ """
91
+
92
+
93
+ def _group_of(base):
94
+ if base in _METAL_BASES:
95
+ return "metal"
96
+ if base in _VIA_BASES:
97
+ return "via"
98
+ if base in _FRONTEND_BASES:
99
+ return "frontend"
100
+ if base in _TECH_BASES:
101
+ return "tech"
102
+ return "other"
103
+
104
+
105
+ def _child_text(node, tag, default=None):
106
+ child = node.find(tag)
107
+ if child is None or child.text is None:
108
+ return default
109
+ return child.text.strip()
110
+
111
+
112
+ def parse_lyp(text):
113
+ """Parse a KLayout .lyp layer-properties file.
114
+
115
+ Args:
116
+ text: Raw .lyp XML text.
117
+
118
+ Returns:
119
+ Mapping with file-order entries (name, label, base,
120
+ purpose, layer, datatype, frame, fill, valid,
121
+ visible, order) plus the entry count.
122
+
123
+ Raises:
124
+ LayerServingError: invalid_stackup for malformed XML, a
125
+ missing name/source, a bad source, or zero entries.
126
+ """
127
+ try:
128
+ root = ET.fromstring(text)
129
+ except ET.ParseError as exc:
130
+ raise LayerServingError(
131
+ "invalid_stackup", f"Cannot parse .lyp layer properties: {exc}."
132
+ ) from exc
133
+ if root.tag != "layer-properties":
134
+ raise LayerServingError(
135
+ "invalid_stackup",
136
+ f".lyp root must be layer-properties, got {root.tag!r}.",
137
+ )
138
+ entries = []
139
+ for order, node in enumerate(root.findall("properties")):
140
+ label = _child_text(node, "name")
141
+ if not label:
142
+ raise LayerServingError(
143
+ "invalid_stackup", ".lyp entry without a name."
144
+ )
145
+ source = _child_text(node, "source")
146
+ if not source:
147
+ raise LayerServingError(
148
+ "invalid_stackup", f".lyp entry without a source: {label}."
149
+ )
150
+ try:
151
+ layer_s, rest = source.split("@", 1)[0].split("/", 1)
152
+ layer, datatype = int(layer_s), int(rest.split("@", 1)[0])
153
+ except (ValueError, IndexError):
154
+ raise LayerServingError(
155
+ "invalid_stackup",
156
+ f".lyp entry has a bad source {source!r}: {label}.",
157
+ ) from None
158
+ head = label.rsplit(" - ", 1)[0]
159
+ if "." in head:
160
+ base, purpose = head.split(".", 1)
161
+ else:
162
+ base, purpose = head, ""
163
+ entries.append({
164
+ "name": f"{base}.{purpose}" if purpose else base,
165
+ "label": label,
166
+ "base": base,
167
+ "purpose": purpose,
168
+ "layer": layer,
169
+ "datatype": datatype,
170
+ "frame": _child_text(node, "frame-color", "") or "",
171
+ "fill": _child_text(node, "fill-color", "") or "",
172
+ "valid": _child_text(node, "valid", "true") == "true",
173
+ "visible": _child_text(node, "visible", "true") == "true",
174
+ "order": order,
175
+ })
176
+ if not entries:
177
+ raise LayerServingError(
178
+ "invalid_stackup", ".lyp carries no <properties> entries."
179
+ )
180
+ return {"entries": entries, "count": len(entries)}
181
+
182
+
183
+ def parse_lyt(text):
184
+ """Parse a KLayout .lyt technology file (tech name + dbu).
185
+
186
+ Args:
187
+ text: Raw .lyt XML text.
188
+
189
+ Returns:
190
+ Mapping with tech, dbu_um, and layer_properties.
191
+
192
+ Raises:
193
+ LayerServingError: invalid_stackup for malformed XML, a
194
+ missing tech name, or a missing/bad dbu.
195
+ """
196
+ try:
197
+ root = ET.fromstring(text)
198
+ except ET.ParseError as exc:
199
+ raise LayerServingError(
200
+ "invalid_stackup", f"Cannot parse .lyt technology: {exc}."
201
+ ) from exc
202
+ if root.tag != "technology":
203
+ raise LayerServingError(
204
+ "invalid_stackup",
205
+ f".lyt root must be technology, got {root.tag!r}.",
206
+ )
207
+ tech = _child_text(root, "name")
208
+ if not tech:
209
+ raise LayerServingError("invalid_stackup", ".lyt carries no tech name.")
210
+ try:
211
+ dbu = float(_child_text(root, "dbu", ""))
212
+ except (TypeError, ValueError):
213
+ raise LayerServingError(
214
+ "invalid_stackup", ".lyt carries no numeric dbu."
215
+ ) from None
216
+ return {
217
+ "tech": tech,
218
+ "dbu_um": dbu,
219
+ "layer_properties": _child_text(root, "layer-properties_file"),
220
+ }
221
+
222
+
223
+ def _strip_xs_comment(line):
224
+ in_string = False
225
+ for index, char in enumerate(line):
226
+ if char == '"':
227
+ in_string = not in_string
228
+ elif char == "#" and not in_string:
229
+ return line[:index]
230
+ return line
231
+
232
+
233
+ def _eval_xs_arith(node, values, where):
234
+ if isinstance(node, ast.Expression):
235
+ return _eval_xs_arith(node.body, values, where)
236
+ if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
237
+ return float(node.value)
238
+ if isinstance(node, ast.Name):
239
+ if node.id not in values:
240
+ raise LayerServingError(
241
+ "invalid_stackup",
242
+ f".xs references unknown variable {node.id!r} in {where}.",
243
+ )
244
+ return values[node.id]
245
+ if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.UAdd, ast.USub)):
246
+ operand = _eval_xs_arith(node.operand, values, where)
247
+ return operand if isinstance(node.op, ast.UAdd) else -operand
248
+ if isinstance(node, ast.BinOp) and isinstance(node.op, _BINOPS):
249
+ left = _eval_xs_arith(node.left, values, where)
250
+ right = _eval_xs_arith(node.right, values, where)
251
+ if isinstance(node.op, ast.Add):
252
+ return left + right
253
+ if isinstance(node.op, ast.Sub):
254
+ return left - right
255
+ if isinstance(node.op, ast.Mult):
256
+ return left * right
257
+ try:
258
+ return left / right
259
+ except ZeroDivisionError:
260
+ raise LayerServingError(
261
+ "invalid_stackup", f".xs divides by zero in {where}."
262
+ ) from None
263
+ return None
264
+
265
+
266
+ def _xs_spans(values):
267
+ """Z spans from evaluated xsect variables (sky130 recipe).
268
+
269
+ shortcut: the recipe assumes the klayoutmatthias sky130
270
+ xsection variable shape (li/mN bottoms, ild/mN thicknesses);
271
+ generalize when a second PDK ships xsect.
272
+ """
273
+ need = values.get
274
+ spans = {}
275
+
276
+ def _put(ident, base, top, thick, source):
277
+ if base is None or top is None or thick is None:
278
+ return
279
+ spans[ident] = {
280
+ "z_base_um": base,
281
+ "z_top_um": top,
282
+ "thickness_um": thick,
283
+ "z_source": f"xsect:{source}",
284
+ }
285
+
286
+ gox, poly_t = need("gox_thickness"), need("poly_thickness")
287
+ _put("poly", gox,
288
+ gox + poly_t if gox is not None and poly_t is not None else None,
289
+ poly_t, "gox_thickness..+poly_thickness")
290
+ li_bottom, li_t = need("li_bottom"), need("li_thickness")
291
+ _put("li1", li_bottom,
292
+ li_bottom + li_t if li_bottom is not None and li_t is not None else None,
293
+ li_t, "li_bottom..+li_thickness")
294
+ bottoms = {n: need(f"m{n}_bottom") for n in range(1, 6)}
295
+ thick = {n: need(f"m{n}_thickness") for n in range(1, 6)}
296
+ for n in range(1, 6):
297
+ ident = f"met{n}"
298
+ base, thick_n = bottoms[n], thick[n]
299
+ _put(ident, base,
300
+ base + thick_n if base is not None and thick_n is not None else None,
301
+ thick_n, f"m{n}_bottom..+m{n}_thickness")
302
+ _put("licon1", 0.0, li_bottom, li_bottom, "0..li_bottom")
303
+ chain = [
304
+ ("mcon", li_bottom, bottoms[1], "li_bottom..m1_bottom"),
305
+ ("via", bottoms[1], bottoms[2], "m1_bottom..m2_bottom"),
306
+ ("via2", bottoms[2], bottoms[3], "m2_bottom..m3_bottom"),
307
+ ("via3", bottoms[3], bottoms[4], "m3_bottom..m4_bottom"),
308
+ ("via4", bottoms[4], bottoms[5], "m4_bottom..m5_bottom"),
309
+ ]
310
+ for ident, base, top, source in chain:
311
+ _put(ident, base, top,
312
+ top - base if base is not None and top is not None else None,
313
+ source)
314
+ capm_depth, capm_t = need("capm_depth"), need("capm_thickness")
315
+ if capm_depth is not None and capm_t is not None and bottoms[4] is not None:
316
+ top = bottoms[4] - capm_depth
317
+ _put("capm", top - capm_t, top, capm_t,
318
+ "m4_bottom-capm_depth-capm_thickness..")
319
+ cap2m_depth, cap2m_t = need("cap2m_depth"), need("cap2m_thickness")
320
+ if cap2m_depth is not None and cap2m_t is not None and bottoms[5] is not None:
321
+ top = bottoms[5] - cap2m_depth
322
+ _put("cap2m", top - cap2m_t, top, cap2m_t,
323
+ "m5_bottom-cap2m_depth-cap2m_thickness..")
324
+ return spans
325
+
326
+
327
+ def parse_xsect(text):
328
+ """Parse a KLayout xsection script (inputs + thicknesses + spans).
329
+
330
+ Single-line statements only: `ident = layer("L/D")` inputs and
331
+ pure-arithmetic assignments evaluate; process calls (mask,
332
+ grow, deposit, output, ...) are skipped, never executed.
333
+
334
+ Args:
335
+ text: Raw .xs script text.
336
+
337
+ Returns:
338
+ Mapping with inputs ({(layer, datatype): ident}),
339
+ values ({variable: float}), and spans ({ident: Z
340
+ span record}).
341
+
342
+ Raises:
343
+ LayerServingError: invalid_stackup when compound
344
+ arithmetic references an unknown variable or divides
345
+ by zero (bare aliases to undefined process handles
346
+ are skipped, never numbers).
347
+ """
348
+ inputs = {}
349
+ pending = []
350
+ for raw in (text or "").splitlines():
351
+ line = _strip_xs_comment(raw).strip()
352
+ if not line:
353
+ continue
354
+ match = _XS_INPUT_RE.match(line)
355
+ if match:
356
+ ident, layer, datatype = match.groups()
357
+ inputs.setdefault((int(layer), int(datatype)), ident)
358
+ continue
359
+ assign = _XS_ASSIGN_RE.match(line)
360
+ if not assign or _XS_INPUT_RE.match(line):
361
+ continue
362
+ ident, expr = assign.groups()
363
+ if "(" in expr or ")" in expr:
364
+ continue # process code (calls), not arithmetic
365
+ # Bare attribute reads (no call) fall through to the AST shape
366
+ # check below, which skips them as non-arithmetic; float
367
+ # literals legitimately contain dots, so dots never skip.
368
+ if _XS_ARITH_CHARS.fullmatch(expr) is None:
369
+ continue
370
+ try:
371
+ tree = ast.parse(expr, mode="eval")
372
+ except SyntaxError:
373
+ continue
374
+ pending.append((ident, expr, tree))
375
+ values = {}
376
+ remaining = list(pending)
377
+ while remaining:
378
+ progressed = False
379
+ still = []
380
+ for ident, expr, tree in remaining:
381
+ try:
382
+ value = _eval_xs_arith(tree, values, f"{ident} = {expr}")
383
+ except LayerServingError:
384
+ still.append((ident, expr, tree))
385
+ continue
386
+ if value is None:
387
+ continue # non-arithmetic shape; process code
388
+ values[ident] = value
389
+ progressed = True
390
+ remaining = still
391
+ if not progressed:
392
+ compound = [line for line in still
393
+ if not isinstance(line[2].body, ast.Name)]
394
+ if not compound:
395
+ break # bare aliases to process handles; not numbers
396
+ ident, expr, tree = compound[0]
397
+ _eval_xs_arith(tree, values, f"{ident} = {expr}")
398
+ raise LayerServingError( # pragma: no cover (the eval raises)
399
+ "invalid_stackup", f".xs cannot evaluate {ident} = {expr}."
400
+ )
401
+ return {"inputs": inputs, "values": values, "spans": _xs_spans(values)}
402
+
403
+
404
+ def build_stackup(pdk_id, revision, lyp_text, lyt_text=None,
405
+ xsect_text=None, source=None):
406
+ """Assemble one read-only Stackup record from PDK file texts.
407
+
408
+ Args:
409
+ pdk_id: Canonical PDK display id (echoed in the record).
410
+ revision: PDK revision pin (echoed in the record).
411
+ lyp_text: Raw .lyp layer-properties text (required).
412
+ lyt_text: Raw .lyt technology text; tech/dbu stay None
413
+ when omitted.
414
+ xsect_text: Raw .xs xsection text; heights stay None
415
+ with z_complete=False when omitted.
416
+ source: Provenance mapping (kind + relpaths/hashes);
417
+ {"kind": "inline"} when omitted.
418
+
419
+ Returns:
420
+ Fresh Stackup record (ordered layers, enabled set,
421
+ groups, z_complete + reason); every call returns
422
+ independent objects — mutating a record never
423
+ affects later calls.
424
+
425
+ Raises:
426
+ LayerServingError: invalid_stackup for a bad id/rev, a
427
+ duplicate layer name, or any unparseable file.
428
+ """
429
+ if not isinstance(pdk_id, str) or not pdk_id.strip():
430
+ raise LayerServingError("invalid_stackup", "Stackup needs a PDK id.")
431
+ if not isinstance(revision, str) or not revision.strip():
432
+ raise LayerServingError("invalid_stackup", "Stackup needs a revision.")
433
+ entries = parse_lyp(lyp_text)["entries"]
434
+ # Same head on different GDS (e.g. sky130A's four `boundary`
435
+ # entries) disambiguates with @L/D on every colliding entry;
436
+ # same head on the SAME GDS is a corrupt duplicate, loud.
437
+ by_head = {}
438
+ for entry in entries:
439
+ by_head.setdefault(entry["name"], []).append(entry)
440
+ for head, group in by_head.items():
441
+ if len(group) == 1:
442
+ continue
443
+ gdss = {(entry["layer"], entry["datatype"]) for entry in group}
444
+ if len(gdss) != len(group):
445
+ raise LayerServingError(
446
+ "invalid_stackup",
447
+ f"Duplicate layer name in .lyp: {head}.",
448
+ )
449
+ for entry in group:
450
+ entry["name"] = (
451
+ f"{head}@{entry['layer']}/{entry['datatype']}"
452
+ )
453
+ tech = parse_lyt(lyt_text) if lyt_text is not None else {
454
+ "tech": None, "dbu_um": None, "layer_properties": None,
455
+ }
456
+ parsed_xs = parse_xsect(xsect_text) if xsect_text is not None else None
457
+ spans = (parsed_xs or {}).get("spans") or {}
458
+ inputs = (parsed_xs or {}).get("inputs") or {}
459
+ layers = []
460
+ for entry in entries:
461
+ ident = inputs.get((entry["layer"], entry["datatype"]))
462
+ span = spans.get(ident) if ident else None
463
+ layers.append({
464
+ "name": entry["name"],
465
+ "label": entry["label"],
466
+ "base": entry["base"],
467
+ "purpose": entry["purpose"],
468
+ "gds": [entry["layer"], entry["datatype"]],
469
+ "group": _group_of(entry["base"]),
470
+ "color": {"frame": entry["frame"], "fill": entry["fill"]},
471
+ "visible": entry["visible"],
472
+ "enabled": entry["valid"],
473
+ "z_base_um": span["z_base_um"] if span else None,
474
+ "z_top_um": span["z_top_um"] if span else None,
475
+ "thickness_um": span["thickness_um"] if span else None,
476
+ "z_source": span["z_source"] if span else None,
477
+ "stack_index": None,
478
+ })
479
+ ranked = sorted(
480
+ (index for index, layer in enumerate(layers)
481
+ if layer["z_base_um"] is not None),
482
+ key=lambda i: (layers[i]["z_base_um"], i),
483
+ )
484
+ for rank, index in enumerate(ranked):
485
+ layers[index]["stack_index"] = rank
486
+ missing = [ident for ident in _Z_REQUIRED if ident not in spans]
487
+ if xsect_text is None:
488
+ z_reason = "no xsect served: 3D heights unavailable (no guess served)"
489
+ elif missing:
490
+ z_reason = f"incomplete xsect spans for: {', '.join(missing)}"
491
+ else:
492
+ z_reason = ""
493
+ groups = {
494
+ group: [layer["name"] for layer in layers if layer["group"] == group]
495
+ for group in GROUPS
496
+ }
497
+ return {
498
+ "stackup_version": STACKUP_VERSION,
499
+ "pdk_id": pdk_id,
500
+ "revision": revision,
501
+ "source": copy.deepcopy(source) if source is not None else {"kind": "inline"},
502
+ "tech": tech["tech"],
503
+ "dbu_um": tech["dbu_um"],
504
+ "layers": layers,
505
+ "order": "file",
506
+ "enabled": [layer["name"] for layer in layers if layer["enabled"]],
507
+ "groups": groups,
508
+ "z_complete": not missing and xsect_text is not None,
509
+ "z_missing": missing,
510
+ "z_reason": z_reason,
511
+ }
512
+
513
+
514
+ def get_stackup_from_dir(pdk_id, revision, pdk_dir):
515
+ """Serve a Stackup by parsing a plain PDK tree directory.
516
+
517
+ Args:
518
+ pdk_id: Canonical PDK display id (selects the relpath map).
519
+ revision: Revision label echoed in the record (the dir is
520
+ not a store entry, so nothing verifies it).
521
+ pdk_dir: PDK tree root holding the mapped relpaths.
522
+
523
+ Returns:
524
+ Fresh Stackup record with pdk-dir provenance.
525
+
526
+ Raises:
527
+ LayerServingError: unknown_stackup for an unmapped PDK or
528
+ a missing .lyp; invalid_stackup for unparseable files.
529
+ """
530
+ mapping = STACKUP_FILES.get(slugify(pdk_id))
531
+ if mapping is None:
532
+ raise LayerServingError(
533
+ "unknown_stackup",
534
+ f"No layer stackup mapping for PDK {pdk_id!r}; mapped: "
535
+ f"{sorted(STACKUP_FILES)}.",
536
+ )
537
+ base = Path(pdk_dir)
538
+ lyp_path = join_contained(base, mapping["lyp"])
539
+ if not lyp_path.is_file():
540
+ raise LayerServingError(
541
+ "unknown_stackup",
542
+ f"No stackup .lyp for PDK {pdk_id!r}: {mapping['lyp']} not found.",
543
+ )
544
+ texts = {"lyp_text": lyp_path.read_text(encoding="utf-8")}
545
+ source = {"kind": "pdk-dir", "dir": str(base), "lyp": mapping["lyp"]}
546
+ for key in ("lyt", "xsect"):
547
+ path = join_contained(base, mapping[key])
548
+ if path.is_file():
549
+ texts[f"{key}_text"] = path.read_text(encoding="utf-8")
550
+ source[key] = mapping[key]
551
+ return build_stackup(pdk_id, revision, source=source, **texts)
552
+
553
+
554
+ def get_stackup(pdk_id, revision=None, engine_root=None):
555
+ """Serve a Stackup from a registered PDK store entry.
556
+
557
+ Args:
558
+ pdk_id: Canonical display id (either spelling resolves).
559
+ revision: Revision pin; the newest registered revision
560
+ when omitted (echoed in the record — explicit pins
561
+ stay recommended for runs).
562
+ engine_root: Explicit store root; resolved when omitted.
563
+
564
+ Returns:
565
+ Fresh Stackup record with pdk-store provenance (relpaths
566
+ plus manifest-verified sha256).
567
+
568
+ Raises:
569
+ PdkError: unknown_pdk for a missing store/id/revision or
570
+ .lyp deck, pdk_drift for a tampered file.
571
+ LayerServingError: unknown_stackup for a PDK with no
572
+ stackup mapping.
573
+ """
574
+ try:
575
+ entries = list_pdks(engine_root=engine_root)
576
+ except ContractError as exc:
577
+ raise PdkError(
578
+ "unknown_pdk", f"Unknown PDK: {pdk_id}@{revision} ({exc})."
579
+ ) from exc
580
+ slug = slugify(pdk_id)
581
+ candidates = [e for e in entries if slugify(e.get("id") or "") == slug]
582
+ if revision is None:
583
+ revisions = sorted(e["revision"] for e in candidates)
584
+ if not revisions:
585
+ raise PdkError(
586
+ "unknown_pdk", f"Unknown PDK: {pdk_id} (no store entry)."
587
+ )
588
+ revision = revisions[-1]
589
+ else:
590
+ candidates = [e for e in candidates if e.get("revision") == revision]
591
+ if not candidates:
592
+ raise PdkError(
593
+ "unknown_pdk", f"Unknown PDK: {pdk_id}@{revision} (no store entry)."
594
+ )
595
+ mapping = STACKUP_FILES.get(slug)
596
+ if mapping is None:
597
+ raise LayerServingError(
598
+ "unknown_stackup",
599
+ f"No layer stackup mapping for PDK {pdk_id!r}; mapped: "
600
+ f"{sorted(STACKUP_FILES)}.",
601
+ )
602
+ entry = candidates[0]
603
+ lyp_hit = resolve_deck(entry["id"], revision, mapping["lyp"],
604
+ engine_root=engine_root)
605
+ texts = {"lyp_text": Path(lyp_hit["path"]).read_text(encoding="utf-8")}
606
+ sha256 = {mapping["lyp"]: lyp_hit["sha256"]}
607
+ source = {
608
+ "kind": "pdk-store",
609
+ "store_path": entry["path"],
610
+ "lyp": mapping["lyp"],
611
+ }
612
+ for key in ("lyt", "xsect"):
613
+ try:
614
+ hit = resolve_deck(entry["id"], revision, mapping[key],
615
+ engine_root=engine_root)
616
+ except PdkError as exc:
617
+ if exc.code != "unknown_pdk":
618
+ raise
619
+ continue # optional source absent: served degraded, with reason
620
+ texts[f"{key}_text"] = Path(hit["path"]).read_text(encoding="utf-8")
621
+ sha256[mapping[key]] = hit["sha256"]
622
+ source[key] = mapping[key]
623
+ source["sha256"] = sha256
624
+ return build_stackup(entry["id"], revision, source=source, **texts)
625
+
626
+
627
+ def _extreme_drawing(layers, visible, want_max):
628
+ drawing = [layer for layer in layers
629
+ if layer["purpose"] == "drawing"
630
+ and layer["stack_index"] is not None
631
+ and visible.get(layer["name"], False)]
632
+ if not drawing:
633
+ return None
634
+ key = (lambda layer: (layer["stack_index"], layer["name"]))
635
+ return (max if want_max else min)(drawing, key=key)["name"]
636
+
637
+
638
+ def builtin_preset(stackup, name):
639
+ """Compute one built-in LAYER_PRESET from a Stackup.
640
+
641
+ All covers every PDK-enabled layer, None hides all, All-metal
642
+ covers the metal + via groups, Top covers the top metal plus
643
+ back-end tech (flip=False, active = topmost visible drawing),
644
+ Bottom covers the frontend group (flip=True, active = lowest
645
+ visible drawing). Without heights the Top/Bottom active stays
646
+ None.
647
+
648
+ Args:
649
+ stackup: Stackup record the preset resolves against.
650
+ name: Built-in preset name (see BUILTIN_PRESETS).
651
+
652
+ Returns:
653
+ Fresh preset record (preset_version, name, builtin,
654
+ stackup_ref, full layers map, renderLayers, flip,
655
+ active).
656
+
657
+ Raises:
658
+ LayerServingError: unknown_preset for a non-built-in name.
659
+ """
660
+ if name not in BUILTIN_PRESETS:
661
+ raise LayerServingError(
662
+ "unknown_preset",
663
+ f"Unknown built-in preset {name!r}; built-ins: "
664
+ f"{', '.join(BUILTIN_PRESETS)}.",
665
+ )
666
+ layers = stackup["layers"]
667
+ if name == "All":
668
+ visible = {layer["name"]: layer["enabled"] for layer in layers}
669
+ elif name == "None":
670
+ visible = {layer["name"]: False for layer in layers}
671
+ elif name == "All-metal":
672
+ visible = {
673
+ layer["name"]: layer["enabled"] and layer["group"] in ("metal", "via")
674
+ for layer in layers
675
+ }
676
+ elif name == "Top":
677
+ visible = {
678
+ layer["name"]: layer["enabled"] and layer["base"] in _TOP_BASES
679
+ for layer in layers
680
+ }
681
+ else:
682
+ visible = {
683
+ layer["name"]: layer["enabled"] and layer["group"] == "frontend"
684
+ for layer in layers
685
+ }
686
+ if name == "Top":
687
+ active = _extreme_drawing(layers, visible, True)
688
+ elif name == "Bottom":
689
+ active = _extreme_drawing(layers, visible, False)
690
+ else:
691
+ active = None
692
+ return {
693
+ "preset_version": PRESET_VERSION,
694
+ "name": name,
695
+ "builtin": True,
696
+ "stackup_ref": {
697
+ "pdk_id": stackup["pdk_id"], "revision": stackup["revision"],
698
+ },
699
+ "layers": visible,
700
+ "renderLayers": dict(_RENDER_DEFAULT),
701
+ "flip": name == "Bottom",
702
+ "active": active,
703
+ }
704
+
705
+
706
+ class PresetStore:
707
+ """In-memory user layer presets plus the computed built-ins.
708
+
709
+ Built-ins compute from the stackup on every apply (never stale,
710
+ never overwritten, never deleted). User presets validate
711
+ against the stackup at save and at apply: stale names fail
712
+ loudly instead of half-applying.
713
+ """
714
+
715
+ def __init__(self):
716
+ self._user = {}
717
+
718
+ def save(self, stackup, name, layers, render_layers=None,
719
+ flip=False, active=None, overwrite=False):
720
+ """Save a user preset (sparse visibility map + view state).
721
+
722
+ Missing names default to hidden (KiCad LSET semantics: the
723
+ map is the visible set); apply() resolves the full map.
724
+
725
+ Args:
726
+ stackup: Stackup record names validate against.
727
+ name: Preset name (stored stripped; built-in names
728
+ are reserved).
729
+ layers: Sparse {layer name: visible} map.
730
+ render_layers: {toggle: bool} viewer aids; the
731
+ default set when omitted (unknown keys pass
732
+ through for viewer evolution).
733
+ flip: View-from-bottom flag.
734
+ active: Active layer name, or None.
735
+ overwrite: Replace an existing user preset of the
736
+ same name instead of conflicting.
737
+
738
+ Returns:
739
+ Stored preset record (builtin False).
740
+
741
+ Raises:
742
+ LayerServingError: invalid_preset for a bad name,
743
+ unknown layer names, or a bad render/flip/active
744
+ shape; preset_conflict for a built-in name or an
745
+ existing user preset without overwrite.
746
+ """
747
+ known = {layer["name"] for layer in stackup["layers"]}
748
+ if not isinstance(name, str) or not name.strip():
749
+ raise LayerServingError("invalid_preset", "Preset needs a name.")
750
+ stored = name.strip()
751
+ if stored in BUILTIN_PRESETS:
752
+ raise LayerServingError(
753
+ "preset_conflict",
754
+ f"Built-in preset {stored!r} cannot be overwritten.",
755
+ )
756
+ if not isinstance(layers, dict):
757
+ raise LayerServingError("invalid_preset", "Preset layers must be a map.")
758
+ unknown = sorted(n for n in layers if n not in known)
759
+ if unknown:
760
+ raise LayerServingError(
761
+ "invalid_preset",
762
+ f"Preset {stored!r} names unknown layers: "
763
+ f"{', '.join(unknown)}.",
764
+ )
765
+ for layer_name, value in layers.items():
766
+ if type(value) is not bool:
767
+ raise LayerServingError(
768
+ "invalid_preset",
769
+ f"Preset {stored!r} layer {layer_name!r} must be a bool.",
770
+ )
771
+ if render_layers is None:
772
+ render = dict(_RENDER_DEFAULT)
773
+ else:
774
+ if not isinstance(render_layers, dict):
775
+ raise LayerServingError(
776
+ "invalid_preset", "Preset renderLayers must be a map."
777
+ )
778
+ for key, value in render_layers.items():
779
+ if not isinstance(key, str) or not key or type(value) is not bool:
780
+ raise LayerServingError(
781
+ "invalid_preset",
782
+ f"Preset {stored!r} renderLayers must map names to bools.",
783
+ )
784
+ render = dict(render_layers)
785
+ if type(flip) is not bool:
786
+ raise LayerServingError(
787
+ "invalid_preset", f"Preset {stored!r} flip must be a bool."
788
+ )
789
+ if active is not None and active not in known:
790
+ raise LayerServingError(
791
+ "invalid_preset",
792
+ f"Preset {stored!r} names unknown active layer {active!r}.",
793
+ )
794
+ if stored in self._user and not overwrite:
795
+ raise LayerServingError(
796
+ "preset_conflict",
797
+ f"Preset {stored!r} already exists (overwrite to replace).",
798
+ )
799
+ record = {
800
+ "preset_version": PRESET_VERSION,
801
+ "name": stored,
802
+ "builtin": False,
803
+ "stackup_ref": {
804
+ "pdk_id": stackup["pdk_id"], "revision": stackup["revision"],
805
+ },
806
+ "layers": dict(layers),
807
+ "renderLayers": render,
808
+ "flip": flip,
809
+ "active": active,
810
+ }
811
+ self._user[stored] = record
812
+ return copy.deepcopy(record)
813
+
814
+ def delete(self, name):
815
+ """Delete one user preset (built-ins are undeletable).
816
+
817
+ Args:
818
+ name: Preset name to delete.
819
+
820
+ Returns:
821
+ Deletion record with the deleted name.
822
+
823
+ Raises:
824
+ LayerServingError: preset_conflict for a built-in
825
+ name; unknown_preset for a missing user preset.
826
+ """
827
+ if name in BUILTIN_PRESETS:
828
+ raise LayerServingError(
829
+ "preset_conflict", f"Built-in preset {name!r} cannot be deleted."
830
+ )
831
+ if name not in self._user:
832
+ raise LayerServingError("unknown_preset", f"Unknown preset {name!r}.")
833
+ del self._user[name]
834
+ return {"deleted": name}
835
+
836
+ def list_presets(self):
837
+ """List built-in plus user preset names.
838
+
839
+ Returns:
840
+ Name records (name + builtin), built-ins first in
841
+ pinned order, then user presets sorted.
842
+ """
843
+ records = [{"name": name, "builtin": True} for name in BUILTIN_PRESETS]
844
+ records += [
845
+ {"name": name, "builtin": False} for name in sorted(self._user)
846
+ ]
847
+ return records
848
+
849
+ def apply(self, stackup, name):
850
+ """Explicitly apply one preset to a Stackup.
851
+
852
+ Args:
853
+ stackup: Stackup record the preset resolves against
854
+ (echoed as stackup_ref).
855
+ name: Built-in or user preset name.
856
+
857
+ Returns:
858
+ Full preset record with the complete layers map,
859
+ renderLayers, flip, and active.
860
+
861
+ Raises:
862
+ LayerServingError: unknown_preset for a missing name;
863
+ invalid_preset when a user preset names layers
864
+ the stackup no longer serves.
865
+ """
866
+ if name in BUILTIN_PRESETS:
867
+ return builtin_preset(stackup, name)
868
+ if name not in self._user:
869
+ raise LayerServingError("unknown_preset", f"Unknown preset {name!r}.")
870
+ stored = self._user[name]
871
+ known = {layer["name"] for layer in stackup["layers"]}
872
+ unknown = sorted(n for n in stored["layers"] if n not in known)
873
+ if unknown:
874
+ raise LayerServingError(
875
+ "invalid_preset",
876
+ f"Preset {name!r} names layers this stackup no longer "
877
+ f"serves: {', '.join(unknown)}.",
878
+ )
879
+ if stored["active"] is not None and stored["active"] not in known:
880
+ raise LayerServingError(
881
+ "invalid_preset",
882
+ f"Preset {name!r} names an active layer this stackup no "
883
+ f"longer serves: {stored['active']!r}.",
884
+ )
885
+ return {
886
+ "preset_version": PRESET_VERSION,
887
+ "name": name,
888
+ "builtin": False,
889
+ "stackup_ref": {
890
+ "pdk_id": stackup["pdk_id"], "revision": stackup["revision"],
891
+ },
892
+ "layers": {
893
+ layer["name"]: stored["layers"].get(layer["name"], False)
894
+ for layer in stackup["layers"]
895
+ },
896
+ "renderLayers": copy.deepcopy(stored["renderLayers"]),
897
+ "flip": stored["flip"],
898
+ "active": stored["active"],
899
+ }
900
+
901
+
902
+ def viewer_rows(stackup, visibility=None, active=None):
903
+ """Render viewer rows for a Stackup (visibility + color + active).
904
+
905
+ Args:
906
+ stackup: Stackup record rows derive from.
907
+ visibility: Sparse {layer name: visible} overrides over
908
+ the PDK defaults; all defaults when omitted.
909
+ active: Active layer name, or None.
910
+
911
+ Returns:
912
+ Page with stackup_ref, flip False, the active name, and
913
+ file-order rows (name, label, gds, group, purpose,
914
+ color, visible, active, enabled, stack_index, Z).
915
+
916
+ Raises:
917
+ LayerServingError: invalid_visibility for unknown layer
918
+ names or a non-bool/non-map shape.
919
+ """
920
+ known = {layer["name"] for layer in stackup["layers"]}
921
+ if visibility is None:
922
+ overrides = {}
923
+ else:
924
+ if not isinstance(visibility, dict):
925
+ raise LayerServingError(
926
+ "invalid_visibility", "Visibility overrides must be a map."
927
+ )
928
+ unknown = sorted(n for n in visibility if n not in known)
929
+ if unknown:
930
+ raise LayerServingError(
931
+ "invalid_visibility",
932
+ f"Unknown layers in visibility: {', '.join(unknown)}.",
933
+ )
934
+ for layer_name, value in visibility.items():
935
+ if type(value) is not bool:
936
+ raise LayerServingError(
937
+ "invalid_visibility",
938
+ f"Visibility for {layer_name!r} must be a bool.",
939
+ )
940
+ overrides = visibility
941
+ if active is not None and active not in known:
942
+ raise LayerServingError(
943
+ "invalid_visibility", f"Unknown active layer {active!r}."
944
+ )
945
+ rows = []
946
+ for layer in stackup["layers"]:
947
+ rows.append({
948
+ "name": layer["name"],
949
+ "label": layer["label"],
950
+ "gds": list(layer["gds"]),
951
+ "group": layer["group"],
952
+ "purpose": layer["purpose"],
953
+ "color": dict(layer["color"]),
954
+ "visible": overrides.get(layer["name"], layer["visible"]),
955
+ "active": layer["name"] == active,
956
+ "enabled": layer["enabled"],
957
+ "stack_index": layer["stack_index"],
958
+ "z_base_um": layer["z_base_um"],
959
+ "z_top_um": layer["z_top_um"],
960
+ })
961
+ return {
962
+ "stackup_ref": {
963
+ "pdk_id": stackup["pdk_id"], "revision": stackup["revision"],
964
+ },
965
+ "flip": False,
966
+ "active": active,
967
+ "rows": rows,
968
+ }
969
+
970
+
971
+ def viewer_rows_for_preset(store, stackup, name, overrides=None, active=None):
972
+ """Render viewer rows with one preset explicitly applied.
973
+
974
+ Args:
975
+ store: PresetStore holding user presets.
976
+ stackup: Stackup record rows derive from.
977
+ name: Built-in or user preset name.
978
+ overrides: Sparse {layer name: visible} wins over the
979
+ preset map; never clears the preset flip.
980
+ active: Active layer override; the preset active when
981
+ omitted.
982
+
983
+ Returns:
984
+ Page with stackup_ref, preset name, flip, active, and
985
+ file-order rows.
986
+
987
+ Raises:
988
+ LayerServingError: unknown_preset for a missing preset;
989
+ invalid_preset for a stale user preset;
990
+ invalid_visibility for bad overrides/active.
991
+ """
992
+ preset = store.apply(stackup, name)
993
+ merged = dict(preset["layers"])
994
+ if overrides:
995
+ merged.update(overrides)
996
+ page = viewer_rows(
997
+ stackup,
998
+ visibility=merged,
999
+ active=preset["active"] if active is None else active,
1000
+ )
1001
+ page["flip"] = preset["flip"]
1002
+ page["preset"] = name
1003
+ return page
1004
+
1005
+
1006
+ __all__ = [
1007
+ "BUILTIN_PRESETS",
1008
+ "GROUPS",
1009
+ "PRESET_VERSION",
1010
+ "STACKUP_FILES",
1011
+ "STACKUP_VERSION",
1012
+ "LayerServingError",
1013
+ "PresetStore",
1014
+ "build_stackup",
1015
+ "builtin_preset",
1016
+ "get_stackup",
1017
+ "get_stackup_from_dir",
1018
+ "parse_lyp",
1019
+ "parse_lyt",
1020
+ "parse_xsect",
1021
+ "viewer_rows",
1022
+ "viewer_rows_for_preset",
1023
+ ]