enginehall 2.0.0__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 (75) hide show
  1. enginehall-2.0.0.dist-info/METADATA +511 -0
  2. enginehall-2.0.0.dist-info/RECORD +75 -0
  3. enginehall-2.0.0.dist-info/WHEEL +4 -0
  4. enginehall-2.0.0.dist-info/entry_points.txt +4 -0
  5. enginehall-2.0.0.dist-info/licenses/LICENSE +51 -0
  6. pcba/THIRD_PARTY_NOTICES.md +72 -0
  7. pcba/__init__.py +6 -0
  8. pcba/adapters/__init__.py +10 -0
  9. pcba/adapters/altium_bridge/MANIFEST.json +5 -0
  10. pcba/adapters/altium_bridge/__init__.py +1 -0
  11. pcba/adapters/altium_bridge/bridge.pas +3342 -0
  12. pcba/adapters/altium_drive.py +1160 -0
  13. pcba/adapters/altium_house.py +591 -0
  14. pcba/adapters/altium_ops.py +858 -0
  15. pcba/adapters/altium_outjob.py +247 -0
  16. pcba/adapters/altium_uia.py +304 -0
  17. pcba/adapters/altium_vcs.py +197 -0
  18. pcba/adapters/altium_win32.py +457 -0
  19. pcba/adapters/cubemx_drive.py +248 -0
  20. pcba/baseline.py +151 -0
  21. pcba/changes.py +81 -0
  22. pcba/cli.py +891 -0
  23. pcba/components.py +323 -0
  24. pcba/config.py +180 -0
  25. pcba/data/components/generic.json +61 -0
  26. pcba/discover.py +350 -0
  27. pcba/doctor.py +131 -0
  28. pcba/house.py +123 -0
  29. pcba/mcp/__init__.py +5 -0
  30. pcba/mcp/altium_server.py +1358 -0
  31. pcba/mcp/protocol.py +224 -0
  32. pcba/mcp/server.py +332 -0
  33. pcba/model/__init__.py +38 -0
  34. pcba/model/design.py +232 -0
  35. pcba/model/finding.py +148 -0
  36. pcba/model/pinmap.py +200 -0
  37. pcba/parsers/__init__.py +0 -0
  38. pcba/parsers/altium/__init__.py +37 -0
  39. pcba/parsers/altium/_sheet.py +293 -0
  40. pcba/parsers/altium/geometry.py +103 -0
  41. pcba/parsers/altium/netlist.py +302 -0
  42. pcba/parsers/altium/pinmap.py +336 -0
  43. pcba/parsers/altium/project.py +249 -0
  44. pcba/parsers/altium/records.py +227 -0
  45. pcba/parsers/firmware/__init__.py +5 -0
  46. pcba/parsers/firmware/index.py +250 -0
  47. pcba/parsers/ioc/__init__.py +49 -0
  48. pcba/parsers/ioc/mcu_db.py +180 -0
  49. pcba/parsers/ioc/reader.py +182 -0
  50. pcba/parsers/ioc/validate.py +192 -0
  51. pcba/parsers/kicad/__init__.py +103 -0
  52. pcba/parsers/kicad/board.py +269 -0
  53. pcba/parsers/kicad/kicad_cli.py +190 -0
  54. pcba/parsers/kicad/pinmap.py +90 -0
  55. pcba/parsers/kicad/schematic.py +517 -0
  56. pcba/parsers/kicad/sexpr.py +136 -0
  57. pcba/publish.py +269 -0
  58. pcba/report/__init__.py +7 -0
  59. pcba/report/json_report.py +39 -0
  60. pcba/report/sarif.py +128 -0
  61. pcba/report/text.py +304 -0
  62. pcba/requirements.py +320 -0
  63. pcba/review.py +177 -0
  64. pcba/rules/__init__.py +312 -0
  65. pcba/rules/cross_domain.py +496 -0
  66. pcba/rules/kicad_board.py +775 -0
  67. pcba/rules/kicad_common.py +167 -0
  68. pcba/rules/kicad_components.py +603 -0
  69. pcba/rules/kicad_schematic.py +621 -0
  70. pcba/rules/locate.py +22 -0
  71. pcba/rules/schematic.py +123 -0
  72. pcba/rules/signals.py +149 -0
  73. pcba/rules/spice.py +59 -0
  74. pcba/rules/stm32.py +155 -0
  75. pcba/simulation.py +143 -0
@@ -0,0 +1,858 @@
1
+ """The operation catalogue: typed, validated requests rendered as bridge calls.
2
+
3
+ An agent never sends DelphiScript. It sends operations such as
4
+ `{"op": "sch_wire", "points": [[1000, 2000], [1500, 2000]]}`; this module checks each one
5
+ against its schema and renders it as a call of a procedure in `altium_bridge/bridge.pas`
6
+ with literal arguments. That keeps three promises the free-form script tool upstream could not:
7
+ the code Altium runs is the hash-checked library, an argument can never become code, and a
8
+ float can never meet a German decimal comma (every number reaches Altium as an integer).
9
+
10
+ Units
11
+ -----
12
+ - Schematic and symbol coordinates: integer **mils** (use a 100 mil grid for pins and wires).
13
+ - PCB and footprint coordinates and sizes: **millimetres** (floats), converted here to Altium's
14
+ internal units (1 mil = 10 000 units).
15
+ - Angles: degrees.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import math
21
+ import ntpath
22
+ import re
23
+ from typing import Any
24
+
25
+ UNITS_PER_MM = 10000 / 0.0254 # Altium internal units per millimetre (1 mil = 10 000)
26
+
27
+ ORIENT = {0: "eRotate0", 90: "eRotate90", 180: "eRotate180", 270: "eRotate270"}
28
+
29
+ ELECTRICAL = {
30
+ "input": "eElectricInput",
31
+ "output": "eElectricOutput",
32
+ "io": "eElectricIO",
33
+ "passive": "eElectricPassive",
34
+ "power": "eElectricPower",
35
+ "oc": "eElectricOpenCollector",
36
+ "oe": "eElectricOpenEmitter",
37
+ "hiz": "eElectricHiZ",
38
+ }
39
+
40
+ POWER_STYLE = {
41
+ "bar": "ePowerBar",
42
+ "gnd": "ePowerGndPower",
43
+ "signal_gnd": "ePowerGndSignal",
44
+ "earth": "ePowerGndEarth",
45
+ "arrow": "ePowerArrow",
46
+ "circle": "ePowerCircle",
47
+ "wave": "ePowerWave",
48
+ }
49
+
50
+ SHEET = {s: f"eSheet{s}" for s in ("A4", "A3", "A2", "A1", "A0", "A", "B", "C", "D", "E")}
51
+
52
+ # Ports and sheet entries: the signal direction, and a port's arrow shape (horizontal ports only).
53
+ PORT_IO = {"unspecified": "ePortUnspecified", "output": "ePortOutput", "input": "ePortInput",
54
+ "bidirectional": "ePortBidirectional"}
55
+ PORT_STYLE = {"none": "ePortNone", "left": "ePortLeft", "right": "ePortRight", "both": "ePortLeftRight"}
56
+ ENTRY_SIDE = {"left": "False", "right": "True"}
57
+
58
+ PAD_SHAPE = {
59
+ "rect": "eRectangular",
60
+ "round": "eRounded",
61
+ "oval": "eRounded",
62
+ "roundrect": "eRoundedRectangular",
63
+ "octagon": "eOctagonal",
64
+ }
65
+
66
+ # A Draftsman drawing's kind is PcbDrawing (measured): "PCBDWF" made a board saved under the
67
+ # drawing's name, which Draftsman then refused as invalid data.
68
+ DOC_KIND = {"SCH": "SCH", "PCB": "PCB", "SCHLIB": "SCHLIB", "PCBLIB": "PCBLIB", "OUTPUTJOB": "OUTPUTJOB",
69
+ "PCBDWF": "PcbDrawing", "PCBDRAWING": "PcbDrawing"}
70
+
71
+ # Routing Corners style and Routing Topology, in Altium's enumeration order.
72
+ CORNER = {"90": "0", "45": "1", "round": "2"}
73
+ TOPOLOGY = {"shortest": "0", "horizontal": "1", "vertical": "2", "daisy_simple": "3", "daisy_mid_driven": "4",
74
+ "daisy_balanced": "5", "starburst": "6"}
75
+
76
+ # Plane and polygon connect styles (TPlaneConnectStyle order) and relief spoke angles.
77
+ CONNECT_STYLE = {"relief": "0", "direct": "1", "none": "2"}
78
+ RELIEF_ENTRIES = {2: "2", 4: "4"}
79
+ RELIEF_ANGLE = {45: "45", 90: "90"}
80
+
81
+ # Altium rule kinds whose value is a single length (PcbRuleValue).
82
+ RULE_KIND = {"plane_clearance": "12", "solder_mask_expansion": "13", "paste_mask_expansion": "14",
83
+ "component_clearance": "24", "hole_to_hole": "52"}
84
+ ZOOM = {"": "", "Sch:Zoom": "Sch:Zoom", "PCB:Zoom": "PCB:Zoom"}
85
+
86
+
87
+ def _layers() -> dict[str, str]:
88
+ layers = {
89
+ "top": "eTopLayer",
90
+ "bottom": "eBottomLayer",
91
+ "multi": "eMultiLayer",
92
+ "top_overlay": "eTopOverlay",
93
+ "bottom_overlay": "eBottomOverlay",
94
+ "top_paste": "eTopPaste",
95
+ "bottom_paste": "eBottomPaste",
96
+ "top_solder": "eTopSolder",
97
+ "bottom_solder": "eBottomSolder",
98
+ "keepout": "eKeepOutLayer",
99
+ "drill_guide": "eDrillGuide",
100
+ "drill_drawing": "eDrillDrawing",
101
+ }
102
+ layers.update({f"mid{i}": f"eMidLayer{i}" for i in range(1, 31)})
103
+ layers.update({f"plane{i}": f"eInternalPlane{i}" for i in range(1, 17)})
104
+ layers.update({f"mech{i}": f"eMechanical{i}" for i in range(1, 17)})
105
+ return layers
106
+
107
+
108
+ LAYER = _layers()
109
+
110
+
111
+ def _layer_numbers() -> dict[str, int]:
112
+ """Altium's TLayer numbers, as `O.Layer` reads them (pads report 74 for the multi-layer)."""
113
+ numbers = {"top": 1, "bottom": 32, "top_overlay": 33, "bottom_overlay": 34, "top_paste": 35,
114
+ "bottom_paste": 36, "top_solder": 37, "bottom_solder": 38, "drill_guide": 55, "keepout": 56,
115
+ "drill_drawing": 73, "multi": 74}
116
+ numbers.update({f"mid{i}": 1 + i for i in range(1, 31)})
117
+ numbers.update({f"plane{i}": 38 + i for i in range(1, 17)})
118
+ numbers.update({f"mech{i}": 56 + i for i in range(1, 17)})
119
+ return numbers
120
+
121
+
122
+ LAYER_NUMBER = _layer_numbers()
123
+ LAYER_BY_NUMBER = {v: k for k, v in LAYER_NUMBER.items()}
124
+ PRIMITIVE_KINDS = ("track", "arc", "via", "fill", "region", "text")
125
+
126
+
127
+ class OpError(ValueError):
128
+ """An operation failed validation. Raised before anything reaches Altium."""
129
+
130
+
131
+ # --- literals ------------------------------------------------------------------------------
132
+
133
+
134
+ def pascal_string(value: Any) -> str:
135
+ """A Pascal string literal. Printable ASCII is written as is; any other printable character
136
+ (a manufacturer name with an umlaut, a typographic apostrophe) as a Pascal character code
137
+ (`'W'#252'rth'`), so the script file itself stays ASCII. Control characters are refused."""
138
+ text = str(value)
139
+ if len(text) > 4000:
140
+ raise OpError("string longer than 4000 characters")
141
+ out, literal = [], []
142
+ for ch in text:
143
+ code = ord(ch)
144
+ if code < 32 or 0x7F <= code < 0xA0 or 0xD800 <= code <= 0xDFFF or code > 0xFFFF:
145
+ raise OpError(f"control or non-BMP character {ch!r} cannot reach Altium (in {text!r})")
146
+ if code > 126:
147
+ if literal:
148
+ out.append("'" + "".join(literal) + "'")
149
+ literal = []
150
+ out.append(f"#{code}")
151
+ else:
152
+ literal.append("''" if ch == "'" else ch)
153
+ if literal or not out:
154
+ out.append("'" + "".join(literal) + "'")
155
+ return "".join(out)
156
+
157
+
158
+ def mm(value: Any, name: str) -> int:
159
+ if not isinstance(value, (int, float)) or isinstance(value, bool) or not math.isfinite(value):
160
+ raise OpError(f"{name} must be a number of millimetres, got {value!r}")
161
+ if abs(value) > 10000:
162
+ raise OpError(f"{name} = {value} mm is out of range")
163
+ return round(value * UNITS_PER_MM)
164
+
165
+
166
+ def mils(value: Any, name: str) -> int:
167
+ if not isinstance(value, int) or isinstance(value, bool):
168
+ raise OpError(f"{name} must be an integer number of mils, got {value!r}")
169
+ if abs(value) > 200000:
170
+ raise OpError(f"{name} = {value} mil is out of range")
171
+ return value
172
+
173
+
174
+ def integer(value: Any, name: str, lo: int = -(2**31), hi: int = 2**31 - 1) -> int:
175
+ if not isinstance(value, int) or isinstance(value, bool) or not lo <= value <= hi:
176
+ raise OpError(f"{name} must be an integer in [{lo}, {hi}], got {value!r}")
177
+ return value
178
+
179
+
180
+ def boolean(value: Any, name: str) -> str:
181
+ if not isinstance(value, bool):
182
+ raise OpError(f"{name} must be true or false, got {value!r}")
183
+ return "True" if value else "False"
184
+
185
+
186
+ def signal_layers(value: Any, name: str) -> str:
187
+ """Signal layer names as Altium layer numbers between bars: ["top", "bottom"] -> "|1|32|"."""
188
+ numbers = {"top": 1, "bottom": 32, **{f"mid{i}": 1 + i for i in range(1, 31)}}
189
+ if not isinstance(value, list) or not value or any(v not in numbers for v in value):
190
+ raise OpError(f"{name} must be a non-empty list of signal layers (top, mid1..mid30, bottom), got {value!r}")
191
+ return "|" + "|".join(str(numbers[v]) for v in value) + "|"
192
+
193
+
194
+ # Query functions that take one name. Altium needs the name quoted (`InNetClass('LED')`); a bare
195
+ # name made a rule whose scope matched nothing, and the next ECO raised a script error (#41).
196
+ QUERY_NAME_FUNCTIONS = (
197
+ "InNet", "InNetClass", "InNetClassComplete", "InComponent", "InComponentClass", "InDifferentialPair",
198
+ "InDifferentialPairClass", "InPadClass", "InPolygon", "InPolygonClass", "InRoom", "InLayerClass",
199
+ "InFromTo", "InFromToClass", "InXSignal", "InXSignalClass", "OnLayer", "HasFootprint",
200
+ )
201
+ _QUERY_BARE = re.compile(r"\b(" + "|".join(QUERY_NAME_FUNCTIONS) + r")\(\s*([^'\"()\s][^'\"()]*?)\s*\)")
202
+
203
+
204
+ def query_scope(value: Any, name: str) -> str:
205
+ """An Altium query expression for a rule scope. A bare name in a one-name query function is
206
+ quoted (`InNetClass(LED)` -> `InNetClass('LED')`); unbalanced quotes or brackets are refused,
207
+ because Altium would take the rule and then fail it later, in the middle of an ECO."""
208
+ if not isinstance(value, str) or not value.strip():
209
+ raise OpError(f"{name} must be an Altium query such as All or InNetClass('PWR'), got {value!r}")
210
+ if len(value) > 4000:
211
+ raise OpError(f"{name} is longer than 4000 characters")
212
+ # Only outside quoted literals: a quoted class name stays exactly as given.
213
+ parts = re.split(r"('[^']*')", value.strip())
214
+ text = "".join(part if i % 2 else _QUERY_BARE.sub(lambda m: f"{m.group(1)}('{m.group(2)}')", part)
215
+ for i, part in enumerate(parts))
216
+ if text.count("'") % 2:
217
+ raise OpError(f"{name} has an unclosed quote: {value!r}")
218
+ depth = 0
219
+ for ch in re.sub(r"'[^']*'", "", text):
220
+ depth += (ch == "(") - (ch == ")")
221
+ if depth < 0:
222
+ break
223
+ if depth:
224
+ raise OpError(f"{name} has unbalanced brackets: {value!r}")
225
+ return text
226
+
227
+
228
+ def enum(table: dict, value: Any, name: str) -> str:
229
+ if value not in table:
230
+ raise OpError(f"{name} must be one of {sorted(map(str, table))}, got {value!r}")
231
+ return table[value]
232
+
233
+
234
+ def angle(value: Any, name: str) -> int:
235
+ if not isinstance(value, (int, float)) or isinstance(value, bool):
236
+ raise OpError(f"{name} must be degrees")
237
+ return int(round(value)) % 360
238
+
239
+
240
+ def points(value: Any, name: str, conv, minimum: int) -> str:
241
+ if not isinstance(value, list) or len(value) < minimum:
242
+ raise OpError(f"{name} must be a list of at least {minimum} [x, y] points")
243
+ flat = []
244
+ for i, pt in enumerate(value):
245
+ if not isinstance(pt, (list, tuple)) or len(pt) != 2:
246
+ raise OpError(f"{name}[{i}] must be [x, y]")
247
+ flat += [conv(pt[0], f"{name}[{i}].x"), conv(pt[1], f"{name}[{i}].y")]
248
+ return pascal_string(",".join(str(v) for v in flat))
249
+
250
+
251
+ def _get(op: dict, key: str, default: Any = ...) -> Any:
252
+ if not isinstance(op, dict):
253
+ raise OpError(f"expected an object with {key!r}, got {type(op).__name__} {op!r}"[:200])
254
+ if key in op:
255
+ return op[key]
256
+ if default is ...:
257
+ raise OpError(f"operation {op.get('op')!r} needs {key!r}")
258
+ return default
259
+
260
+
261
+ def _objects(op: dict, key: str, required: bool = False) -> list[dict]:
262
+ """A list argument whose items are objects (pins, pads, tracks, ...), checked item by item."""
263
+ items = _get(op, key) if required else _get(op, key, [])
264
+ if not isinstance(items, list) or (required and not items):
265
+ raise OpError(f"{key} must be a {'non-empty ' if required else ''}list of objects")
266
+ for i, item in enumerate(items):
267
+ if not isinstance(item, dict):
268
+ raise OpError(f"{key}[{i}] must be an object, got {type(item).__name__}")
269
+ return items
270
+
271
+
272
+ def _call(name: str, *args: str) -> str:
273
+ return f"{name}({', '.join(args)});" if args else f"{name};"
274
+
275
+
276
+ # --- renderers -----------------------------------------------------------------------------
277
+
278
+
279
+ def _symbol(op: dict) -> list[str]:
280
+ calls = [
281
+ _call(
282
+ "SymBegin",
283
+ pascal_string(_get(op, "lib")),
284
+ pascal_string(_get(op, "name")),
285
+ pascal_string(_get(op, "description", "")),
286
+ pascal_string(_get(op, "prefix", "U")),
287
+ pascal_string(_get(op, "comment", _get(op, "name"))),
288
+ pascal_string(_get(op, "footprint", "")),
289
+ str(integer(_get(op, "part_count", 1), "part_count", 1, 64)),
290
+ )
291
+ ]
292
+ # Graphics first: Altium draws in insertion order, and a solid body drawn after the pins
293
+ # paints over their names.
294
+ for i, r in enumerate(_objects(op, "rects")):
295
+ calls.append(_call("SymRect", *(str(mils(_get(r, k), f"rects[{i}].{k}")) for k in ("x1", "y1", "x2", "y2")),
296
+ str(integer(_get(r, "part", 1), "part", 0, 64))))
297
+ for i, ln in enumerate(_objects(op, "lines")):
298
+ calls.append(_call("SymLine", *(str(mils(_get(ln, k), f"lines[{i}].{k}")) for k in ("x1", "y1", "x2", "y2")),
299
+ str(integer(_get(ln, "part", 1), "part", 0, 64))))
300
+ for i, t in enumerate(_objects(op, "texts")):
301
+ calls.append(_call("SymText", pascal_string(_get(t, "text")), str(mils(_get(t, "x"), "x")),
302
+ str(mils(_get(t, "y"), "y")), str(integer(_get(t, "part", 1), "part", 0, 64))))
303
+ pins = _objects(op, "pins", required=True)
304
+ seen = set()
305
+ for i, pin in enumerate(pins):
306
+ number = str(_get(pin, "number"))
307
+ if number in seen:
308
+ raise OpError(f"pin number {number} appears twice")
309
+ seen.add(number)
310
+ calls.append(
311
+ _call(
312
+ "SymPin",
313
+ pascal_string(number),
314
+ pascal_string(_get(pin, "name", number)),
315
+ enum(ELECTRICAL, _get(pin, "electrical", "passive"), f"pins[{i}].electrical"),
316
+ str(mils(_get(pin, "x"), f"pins[{i}].x")),
317
+ str(mils(_get(pin, "y"), f"pins[{i}].y")),
318
+ enum(ORIENT, _get(pin, "orientation"), f"pins[{i}].orientation"),
319
+ str(mils(_get(pin, "length", 200), f"pins[{i}].length")),
320
+ str(integer(_get(pin, "part", 1), f"pins[{i}].part", 0, 64)),
321
+ boolean(_get(pin, "show_name", True), "show_name"),
322
+ boolean(_get(pin, "hidden", False), "hidden"),
323
+ )
324
+ )
325
+ params = _get(op, "params", {})
326
+ if not isinstance(params, dict):
327
+ raise OpError("params must be an object of name: value")
328
+ for key, value in params.items():
329
+ calls.append(_call("SymParam", pascal_string(key), pascal_string(value), "False"))
330
+ calls.append(_call("SymEnd"))
331
+ return calls
332
+
333
+
334
+ def _sheet_symbol(op: dict) -> list[str]:
335
+ """A sheet symbol for a child sheet with its entries. Entries sit on the left or right edge,
336
+ `offset` mils below the top; their electrical points are reported back."""
337
+ name, file = _get(op, "designator"), _get(op, "file")
338
+ if not isinstance(name, str) or not name.strip():
339
+ raise OpError("designator must be the sheet symbol's name")
340
+ if not isinstance(file, str) or not file.lower().endswith(".schdoc"):
341
+ raise OpError(f"file must name the child .SchDoc as the project lists it, got {file!r}")
342
+ width = mils(_get(op, "width"), "width")
343
+ height = mils(_get(op, "height"), "height")
344
+ if width < 100 or height < 100:
345
+ raise OpError("width and height must be at least 100 mil")
346
+ calls = [_call("SchSheetSymBegin", pascal_string(name), pascal_string(file), str(mils(_get(op, "x"), "x")),
347
+ str(mils(_get(op, "y"), "y")), str(width), str(height))]
348
+ seen = set()
349
+ for i, entry in enumerate(_objects(op, "entries")):
350
+ entry_name = _get(entry, "name")
351
+ if not isinstance(entry_name, str) or not entry_name.strip():
352
+ raise OpError(f"entries[{i}].name must be the net it carries")
353
+ if entry_name.upper() in seen:
354
+ raise OpError(f"entry {entry_name} appears twice")
355
+ seen.add(entry_name.upper())
356
+ offset = mils(_get(entry, "offset"), f"entries[{i}].offset")
357
+ if not 0 < offset < height:
358
+ raise OpError(f"entries[{i}].offset must lie inside the symbol (0 < offset < {height}), got {offset}")
359
+ calls.append(_call("SchSheetEntry", pascal_string(entry_name),
360
+ enum(ENTRY_SIDE, _get(entry, "side", "left"), f"entries[{i}].side"), str(offset),
361
+ enum(PORT_IO, _get(entry, "io", "unspecified"), f"entries[{i}].io")))
362
+ calls.append(_call("SchSheetSymEnd"))
363
+ return calls
364
+
365
+
366
+ def _footprint(op: dict) -> list[str]:
367
+ calls = [
368
+ _call(
369
+ "FpBegin",
370
+ pascal_string(_get(op, "lib")),
371
+ pascal_string(_get(op, "name")),
372
+ pascal_string(_get(op, "description", "")),
373
+ str(mm(_get(op, "height", 0.0), "height")),
374
+ )
375
+ ]
376
+ pads = _objects(op, "pads", required=True)
377
+ for i, pad in enumerate(pads):
378
+ hole = _get(pad, "hole", 0.0)
379
+ layer = _get(pad, "layer", "multi" if hole else "top")
380
+ if hole and layer != "multi":
381
+ raise OpError(f"pads[{i}] has a hole, so its layer must be 'multi'")
382
+ calls.append(
383
+ _call(
384
+ "FpPad",
385
+ pascal_string(_get(pad, "name")),
386
+ str(mm(_get(pad, "x"), f"pads[{i}].x")),
387
+ str(mm(_get(pad, "y"), f"pads[{i}].y")),
388
+ enum(PAD_SHAPE, _get(pad, "shape", "rect"), f"pads[{i}].shape"),
389
+ str(mm(_get(pad, "w"), f"pads[{i}].w")),
390
+ str(mm(_get(pad, "h"), f"pads[{i}].h")),
391
+ str(angle(_get(pad, "rotation", 0), "rotation")),
392
+ enum(LAYER, layer, f"pads[{i}].layer"),
393
+ str(mm(hole, f"pads[{i}].hole")),
394
+ boolean(_get(pad, "plated", True), "plated"),
395
+ str(integer(_get(pad, "corner_pct", 25), "corner_pct", 0, 100)),
396
+ boolean(_get(pad, "mask_expansion", None) is not None, "mask_expansion"),
397
+ str(mm(_get(pad, "mask_expansion", None) or 0.0, f"pads[{i}].mask_expansion")),
398
+ boolean(_get(pad, "paste", True), "paste"),
399
+ )
400
+ )
401
+ for i, t in enumerate(_objects(op, "tracks")):
402
+ calls.append(_call("FpTrack", enum(LAYER, _get(t, "layer"), f"tracks[{i}].layer"),
403
+ *(str(mm(_get(t, k), f"tracks[{i}].{k}")) for k in ("x1", "y1", "x2", "y2", "width"))))
404
+ for i, a in enumerate(_objects(op, "arcs")):
405
+ calls.append(_call("FpArc", enum(LAYER, _get(a, "layer"), f"arcs[{i}].layer"),
406
+ str(mm(_get(a, "xc"), "xc")), str(mm(_get(a, "yc"), "yc")), str(mm(_get(a, "r"), "r")),
407
+ str(angle(_get(a, "start", 0), "start")), str(angle(_get(a, "end", 360), "end") or 360),
408
+ str(mm(_get(a, "width"), "width"))))
409
+ for i, b in enumerate(_objects(op, "bodies")):
410
+ calls.append(_call("FpBody", points(_get(b, "outline"), f"bodies[{i}].outline", mm, 3),
411
+ str(mm(_get(b, "standoff", 0.0), "standoff")), str(mm(_get(b, "height"), "height")),
412
+ enum(LAYER, _get(b, "layer", "mech13"), "layer")))
413
+ for i, m in enumerate(_objects(op, "models")):
414
+ calls.append(_model(m, f"models[{i}]"))
415
+ calls.append(_call("FpEnd"))
416
+ return calls
417
+
418
+
419
+ def _triple(value: Any, name: str, conv) -> list:
420
+ if not isinstance(value, list) or len(value) != 3:
421
+ raise OpError(f"{name} must be a list of three numbers [x, y, z]")
422
+ return [conv(v, f"{name}[{k}]") for k, v in enumerate(value)]
423
+
424
+
425
+ def _model(m: dict, where: str) -> str:
426
+ """A STEP model embedded in the footprint as a 3D body. Altium rotates the model about its own
427
+ origin, X then Y then Z, lifts it by the Z offset, then moves it by the X/Y offset."""
428
+ path = _get(m, "file")
429
+ if not isinstance(path, str) or not path.lower().endswith((".step", ".stp")):
430
+ raise OpError(f"{where}.file must be a .step or .stp path")
431
+ rx, ry, rz = _triple(_get(m, "rotation", [0, 0, 0]), f"{where}.rotation", angle)
432
+ dx, dy, dz = _triple(_get(m, "offset", [0, 0, 0]), f"{where}.offset", mm)
433
+ return _call("FpModel", pascal_string(path), str(rx), str(ry), str(rz), str(dx), str(dy), str(dz),
434
+ enum(LAYER, _get(m, "layer", "mech13"), f"{where}.layer"))
435
+
436
+
437
+ def _track(op: dict) -> list[str]:
438
+ pts = _get(op, "points")
439
+ if not isinstance(pts, list) or len(pts) < 2:
440
+ raise OpError("pcb_track needs at least two points")
441
+ net = pascal_string(_get(op, "net", ""))
442
+ layer = enum(LAYER, _get(op, "layer"), "layer")
443
+ width = str(mm(_get(op, "width"), "width"))
444
+ locked = boolean(_get(op, "locked", False), "locked")
445
+ calls = []
446
+ for i in range(len(pts) - 1):
447
+ (x1, y1), (x2, y2) = pts[i], pts[i + 1]
448
+ calls.append(_call("PcbTrack", net, layer, str(mm(x1, "x")), str(mm(y1, "y")),
449
+ str(mm(x2, "x")), str(mm(y2, "y")), width, locked))
450
+ return calls
451
+
452
+
453
+ def _text_set(op: dict) -> list[str]:
454
+ """Change one free PCB text, found by its whole text (and `layer`, if given). Only the keys
455
+ present are changed: x and y together, height, rotation, mirror, text."""
456
+ fields = ""
457
+ if "x" in op or "y" in op:
458
+ _get(op, "x"), _get(op, "y")
459
+ fields += "p"
460
+ for key, flag in (("height", "h"), ("rotation", "r"), ("mirror", "m"), ("text", "t")):
461
+ if key in op:
462
+ fields += flag
463
+ if not fields:
464
+ raise OpError("pcb_text_set changes nothing: give x and y, height, rotation, mirror or text")
465
+ layer = _get(op, "layer", None)
466
+ if layer is not None:
467
+ enum(LAYER, layer, "layer")
468
+ return [_call(
469
+ "PcbTextSet",
470
+ pascal_string(_get(op, "match")),
471
+ "-1" if layer is None else str(LAYER_NUMBER[layer]),
472
+ pascal_string(fields),
473
+ str(mm(_get(op, "x", 0.0), "x")),
474
+ str(mm(_get(op, "y", 0.0), "y")),
475
+ str(mm(_get(op, "height", 1.0), "height")),
476
+ str(angle(_get(op, "rotation", 0), "rotation")),
477
+ boolean(_get(op, "mirror", False), "mirror"),
478
+ pascal_string(_get(op, "text", "")),
479
+ )]
480
+
481
+
482
+ def _simple(name: str, *spec):
483
+ """Renderer for an op whose arguments map one-to-one to a bridge procedure."""
484
+
485
+ def render(op: dict) -> list[str]:
486
+ args = []
487
+ for key, kind, default in spec:
488
+ value = _get(op, key, default)
489
+ if kind == "str":
490
+ args.append(pascal_string(value))
491
+ elif kind == "mils":
492
+ args.append(str(mils(value, key)))
493
+ elif kind == "mm":
494
+ args.append(str(mm(value, key)))
495
+ elif kind == "int":
496
+ args.append(str(integer(value, key)))
497
+ elif kind == "angle":
498
+ args.append(str(angle(value, key)))
499
+ elif kind == "end_angle": # 360 is a full sweep, not 0
500
+ args.append(str(angle(value, key) or 360))
501
+ elif kind == "bool":
502
+ args.append(boolean(value, key))
503
+ elif kind == "orient":
504
+ args.append(enum(ORIENT, value, key))
505
+ elif kind == "layer":
506
+ args.append(enum(LAYER, value, key))
507
+ elif kind == "power":
508
+ args.append(enum(POWER_STYLE, value, key))
509
+ elif kind == "sheet":
510
+ args.append(enum(SHEET, value, key))
511
+ elif kind == "part": # one part of a (multi-part) component
512
+ args.append(str(integer(value, key, 1, 64)))
513
+ elif kind == "part_any": # 0: every part, or the only one
514
+ args.append(str(integer(value, key, 0, 64)))
515
+ elif kind == "length_mils":
516
+ if mils(value, key) < 100:
517
+ raise OpError(f"{key} must be at least 100 mil, got {value!r}")
518
+ args.append(str(value))
519
+ elif kind == "scope":
520
+ args.append(pascal_string(query_scope(value, key)))
521
+ elif kind == "connect_style":
522
+ args.append(enum(CONNECT_STYLE, value, key))
523
+ elif kind == "relief_entries":
524
+ args.append(enum(RELIEF_ENTRIES, value, key))
525
+ elif kind == "relief_angle":
526
+ args.append(enum(RELIEF_ANGLE, value, key))
527
+ elif kind == "port_io":
528
+ args.append(enum(PORT_IO, value, key))
529
+ elif kind == "port_style":
530
+ args.append(enum(PORT_STYLE, value, key))
531
+ elif kind == "kind":
532
+ args.append(pascal_string(enum(DOC_KIND, str(value).upper(), key)))
533
+ elif kind == "zoom":
534
+ args.append(pascal_string(enum(ZOOM, value, key)))
535
+ elif kind == "rule_kind":
536
+ args.append(enum(RULE_KIND, value, key))
537
+ elif kind == "corner":
538
+ args.append(enum(CORNER, str(value), key))
539
+ elif kind == "topology":
540
+ args.append(enum(TOPOLOGY, value, key))
541
+ elif kind == "priority":
542
+ args.append(str(integer(value, key, 0, 100)))
543
+ elif kind == "side":
544
+ args.append(boolean(enum({"top": False, "bottom": True}, value, key), key))
545
+ elif kind == "mils_points":
546
+ args.append(points(value, key, mils, 2))
547
+ elif kind == "mm_points":
548
+ args.append(points(value, key, mm, 3))
549
+ elif kind == "signal_layers":
550
+ args.append(pascal_string(signal_layers(value, key)))
551
+ elif kind == "csv":
552
+ if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
553
+ raise OpError(f"{key} must be a list of names")
554
+ args.append(pascal_string(",".join(value)))
555
+ elif kind == "save_mode":
556
+ args.append(boolean(enum({"all": True, "none": False}, value, key), key))
557
+ elif kind == "primitive_kinds":
558
+ if not isinstance(value, list) or not value or any(v not in PRIMITIVE_KINDS for v in value):
559
+ raise OpError(f"{key} must be a non-empty list of {sorted(PRIMITIVE_KINDS)}, got {value!r}")
560
+ args.append(pascal_string(",".join(dict.fromkeys(value))))
561
+ elif kind == "layer_filter":
562
+ if not isinstance(value, list) or any(v not in LAYER for v in value):
563
+ raise OpError(f"{key} must be a list of layer names (empty for every layer), got {value!r}")
564
+ args.append(pascal_string("".join(f"|{LAYER_NUMBER[v]}" for v in value) + ("|" if value else "")))
565
+ elif kind == "layer_or_any":
566
+ if value is not None:
567
+ enum(LAYER, value, key)
568
+ args.append("-1" if value is None else str(LAYER_NUMBER[value]))
569
+ else: # pragma: no cover - programming error
570
+ raise AssertionError(kind)
571
+ return [_call(name, *args)]
572
+
573
+ return render
574
+
575
+
576
+ OPS: dict[str, Any] = {
577
+ # workspace and documents
578
+ "project_open": _simple("ProjectOpen", ("path", "str", ...)),
579
+ "project_close": _simple("ProjectClose", ("path", "str", ...), ("save", "save_mode", "all")),
580
+ "project_state": _simple("ProjectState", ("path", "str", "")),
581
+ "project_save": _simple("ProjectSave", ("path", "str", ...)),
582
+ "project_netlist": _simple("ProjectNetlist", ("path", "str", ...), ("reopen", "bool", False)),
583
+ "doc_create": _simple("DocCreate", ("kind", "kind", ...), ("path", "str", ...), ("project", "str", "")),
584
+ "doc_save": _simple("DocSave", ("path", "str", ...)),
585
+ "doc_close": _simple("DocClose", ("path", "str", ...), ("save", "bool", True)),
586
+ "doc_focus": _simple("DocFocus", ("kind", "kind", ...), ("path", "str", ...), ("zoom", "zoom", "")),
587
+ # libraries
588
+ "symbol": _symbol,
589
+ "footprint": _footprint,
590
+ # schematic
591
+ "sch_open": _simple("SchOpen", ("path", "str", ...)),
592
+ "sch_clear": _simple("SchClear"),
593
+ "sch_clear_wiring": _simple("SchClearWiring"),
594
+ "sch_move": _simple("SchMove", ("designator", "str", ...), ("x", "mils", ...), ("y", "mils", ...),
595
+ ("orientation", "orient", 0), ("mirrored", "bool", False), ("part", "part_any", 0)),
596
+ "sch_part_texts": _simple("SchPartTexts", ("designator", "str", ...), ("dx", "mils", ...), ("dy", "mils", ...),
597
+ ("x", "mils", ...), ("y", "mils", ...), ("step", "mils", 100)),
598
+ "sch_sheet": _simple("SchSheet", ("size", "sheet", "A3"), ("title", "str", ""),
599
+ ("revision", "str", ""), ("document_number", "str", ""), ("sheet_number", "str", "1"),
600
+ ("sheet_total", "str", "1")),
601
+ "sch_delete": _simple("SchDelete", ("designator", "str", ...), ("part", "part_any", 0)),
602
+ "sch_unique_id": _simple("SchUniqueId", ("designator", "str", ...), ("unique_id", "str", ...)),
603
+ "sch_sheet_param": _simple("SchAddSheetParam", ("name", "str", ...), ("value", "str", ...)),
604
+ "sch_place": _simple("SchPlace", ("lib", "str", ...), ("lib_ref", "str", ...), ("designator", "str", ...),
605
+ ("x", "mils", ...), ("y", "mils", ...), ("orientation", "orient", 0),
606
+ ("comment", "str", ""), ("part", "part", 1)),
607
+ "sch_param": _simple("SchParam", ("designator", "str", ...), ("name", "str", ...), ("value", "str", ...),
608
+ ("visible", "bool", False)),
609
+ "sch_param_remove": _simple("SchParamRemove", ("designator", "str", ...), ("name", "str", ...)),
610
+ "sch_component_kind": _simple("SchComponentKind", ("designator", "str", ...), ("no_bom", "bool", ...)),
611
+ "sch_wire": _simple("SchWire", ("points", "mils_points", ...)),
612
+ "sch_label": _simple("SchLabel", ("text", "str", ...), ("x", "mils", ...), ("y", "mils", ...),
613
+ ("orientation", "orient", 0)),
614
+ "sch_power": _simple("SchPower", ("text", "str", ...), ("x", "mils", ...), ("y", "mils", ...),
615
+ ("style", "power", "bar"), ("orientation", "orient", 90)),
616
+ "sch_noerc": _simple("SchNoErc", ("x", "mils", ...), ("y", "mils", ...)),
617
+ "sch_junction": _simple("SchJunction", ("x", "mils", ...), ("y", "mils", ...)),
618
+ "sch_text": _simple("SchText", ("text", "str", ...), ("x", "mils", ...), ("y", "mils", ...),
619
+ ("orientation", "orient", 0)),
620
+ "sch_sheet_symbol": _sheet_symbol,
621
+ "sch_port": _simple("SchPort", ("name", "str", ...), ("x", "mils", ...), ("y", "mils", ...),
622
+ ("width", "length_mils", 600), ("style", "port_style", "right"),
623
+ ("io", "port_io", "unspecified")),
624
+ "sch_save": _simple("SchSave"),
625
+ "sch_pins": _simple("SchPins", ("designator", "str", ...), ("part", "part_any", 0)),
626
+ "sch_components": _simple("SchComponents"),
627
+ # PCB
628
+ "pcb_open": _simple("PcbOpen", ("path", "str", ...)),
629
+ "pcb_origin": _simple("PcbOrigin", ("x", "mm", ...), ("y", "mm", ...)),
630
+ "pcb_outline": _simple("PcbOutline", ("points", "mm_points", ...)),
631
+ "pcb_place": _simple("PcbPlace", ("designator", "str", ...), ("x", "mm", ...), ("y", "mm", ...),
632
+ ("rotation", "angle", 0), ("side", "side", "top")),
633
+ "pcb_track": _track,
634
+ "pcb_arc": _simple("PcbArc", ("net", "str", ""), ("layer", "layer", ...), ("xc", "mm", ...), ("yc", "mm", ...),
635
+ ("r", "mm", ...), ("start", "angle", 0), ("end", "end_angle", 360), ("width", "mm", ...)),
636
+ "pcb_via": _simple("PcbVia", ("net", "str", ""), ("x", "mm", ...), ("y", "mm", ...), ("hole", "mm", 0.3),
637
+ ("diameter", "mm", 0.6), ("locked", "bool", False)),
638
+ "pcb_polygon": _simple("PcbPolygon", ("net", "str", ""), ("layer", "layer", ...), ("points", "mm_points", ...),
639
+ ("name", "str", ""), ("clearance", "mm", 0.0)),
640
+ "pcb_region": _simple("PcbRegion", ("net", "str", ""), ("layer", "layer", ...), ("points", "mm_points", ...)),
641
+ "pcb_text_replace": _simple("PcbTextReplace", ("old", "str", ...), ("new", "str", ...)),
642
+ "pcb_text_set": _text_set,
643
+ "pcb_text": _simple("PcbText", ("layer", "layer", ...), ("x", "mm", ...), ("y", "mm", ...), ("height", "mm", 1.0),
644
+ ("rotation", "angle", 0), ("text", "str", ...), ("mirror", "bool", False)),
645
+ "pcb_rule_clearance": _simple("PcbRuleClearance", ("name", "str", ...), ("scope1", "scope", "All"),
646
+ ("scope2", "scope", "All"), ("gap", "mm", ...)),
647
+ "pcb_rule_width": _simple("PcbRuleWidth", ("name", "str", ...), ("scope", "scope", ...), ("min", "mm", ...),
648
+ ("preferred", "mm", ...), ("max", "mm", ...)),
649
+ "pcb_rule_via": _simple("PcbRuleVia", ("name", "str", ...), ("scope", "scope", "All"), ("hole_min", "mm", ...),
650
+ ("hole", "mm", ...), ("hole_max", "mm", ...), ("diameter_min", "mm", ...),
651
+ ("diameter", "mm", ...), ("diameter_max", "mm", ...)),
652
+ "pcb_rule_delete": _simple("PcbRuleDelete", ("name", "str", ...)),
653
+ "pcb_rule_value": _simple("PcbRuleValue", ("name", "str", ...), ("kind", "rule_kind", ...),
654
+ ("scope1", "scope", "All"), ("scope2", "scope", "All"), ("value", "mm", ...),
655
+ ("vertical", "mm", 0.0)),
656
+ "pcb_rule_routing_layers": _simple("PcbRuleRoutingLayers", ("name", "str", "RoutingLayers"),
657
+ ("scope", "scope", "All"), ("layers", "signal_layers", ...)),
658
+ "pcb_rule_hole_size": _simple("PcbRuleHoleSize", ("name", "str", "HoleSize"), ("min", "mm", ...),
659
+ ("max", "mm", ...)),
660
+ "pcb_rule_silk_to_silk": _simple("PcbRuleSilkToSilk", ("name", "str", "SilkToSilkClearance"), ("gap", "mm", ...)),
661
+ "pcb_rule_mask_sliver": _simple("PcbRuleMaskSliver", ("name", "str", "MinimumSolderMaskSliver"),
662
+ ("gap", "mm", ...)),
663
+ "pcb_rule_corners": _simple("PcbRuleCorners", ("name", "str", "RoutingCorners"), ("scope", "scope", "All"),
664
+ ("style", "corner", "45"), ("setback_min", "mm", 0.1), ("setback_max", "mm", 0.1)),
665
+ "pcb_rule_priority": _simple("PcbRulePriority", ("name", "str", ...), ("scope", "scope", ...),
666
+ ("priority", "priority", ...)),
667
+ "pcb_rule_topology": _simple("PcbRuleTopology", ("name", "str", "RoutingTopology"), ("scope", "scope", "All"),
668
+ ("topology", "topology", "shortest")),
669
+ "pcb_diff_pairs": _simple("PcbDiffPairs"),
670
+ "pcb_diff_pair": _simple("PcbDiffPair", ("name", "str", ...), ("positive", "str", ...), ("negative", "str", ...)),
671
+ "pcb_rule_diff_pair": _simple("PcbRuleDiffPair", ("name", "str", ...), ("scope", "scope", "IsDifferentialPair"),
672
+ ("width_min", "mm", ...), ("width", "mm", ...), ("width_max", "mm", ...),
673
+ ("gap_min", "mm", ...), ("gap", "mm", ...), ("gap_max", "mm", ...),
674
+ ("uncoupled_max", "mm", 6.0)),
675
+ "pcb_rule_plane_connect": _simple("PcbRulePlaneConnect", ("name", "str", "PlaneConnect"), ("scope", "scope", "All"),
676
+ ("style", "connect_style", "relief"), ("conductor_width", "mm", 0.254),
677
+ ("entries", "relief_entries", 4), ("air_gap", "mm", 0.254),
678
+ ("expansion", "mm", 0.508)),
679
+ "pcb_rule_polygon_connect": _simple("PcbRulePolygonConnect", ("name", "str", "PolygonConnect"),
680
+ ("scope", "scope", "All"), ("style", "connect_style", "relief"),
681
+ ("conductor_width", "mm", 0.254), ("entries", "relief_entries", 4),
682
+ ("angle", "relief_angle", 90), ("air_gap", "mm", 0.254)),
683
+ "pcb_tent_vias": _simple("PcbTentVias", ("top", "bool", True), ("bottom", "bool", True)),
684
+ "pcb_remove_components": _simple("PcbRemoveComponents", ("designators", "csv", ...)),
685
+ "pcb_clear_texts": _simple("PcbClearTexts", ("layer", "layer", ...)),
686
+ "pcb_clear_layer": _simple("PcbClearLayer", ("layer", "layer", ...)),
687
+ "pcb_designator": _simple("PcbDesignator", ("designator", "str", ...), ("show", "bool", True), ("x", "mm", 0.0),
688
+ ("y", "mm", 0.0), ("height", "mm", 0.8), ("rotation", "angle", 0)),
689
+ "pcb_netclass": _simple("PcbNetClass", ("name", "str", ...), ("nets", "csv", ...)),
690
+ "pcb_plane_net": _simple("PcbPlaneNet", ("layer", "layer", ...), ("net", "str", ...), ("pullback", "mm", 0.5)),
691
+ "pcb_copper": _simple("PcbCopper", ("layer", "layer", ...), ("copper", "mm", ...)),
692
+ "pcb_dielectric": _simple("PcbDielectric", ("layer", "layer", ...), ("height", "mm", ...), ("copper", "mm", 0.035)),
693
+ "pcb_unroute": _simple("PcbUnroute", ("nets", "csv", ...), ("include_locked", "bool", False)),
694
+ "pcb_delete_track": _simple("PcbDeleteTrack", ("layer", "layer", ...), ("x1", "mm", ...), ("y1", "mm", ...),
695
+ ("x2", "mm", ...), ("y2", "mm", ...)),
696
+ "pcb_delete_polygon": _simple("PcbDeletePolygon", ("name", "str", ...)),
697
+ "pcb_delete_via": _simple("PcbDeleteVia", ("x", "mm", ...), ("y", "mm", ...), ("net", "str", "")),
698
+ "pcb_track_end": _simple("PcbTrackEnd", ("net", "str", ...), ("layer", "layer", ...), ("x", "mm", ...),
699
+ ("y", "mm", ...), ("to_x", "mm", ...), ("to_y", "mm", ...)),
700
+ "pcb_repour": _simple("PcbRepour"),
701
+ "pcb_save": _simple("PcbSave"),
702
+ "pcb_components": _simple("PcbComponents"),
703
+ "pcb_pads": _simple("PcbPads"),
704
+ "pcb_nets": _simple("PcbNets"),
705
+ "pcb_layers": _simple("PcbLayers"),
706
+ "pcb_board_outline": _simple("PcbBoardOutline"),
707
+ "pcb_bodies": _simple("PcbBodies"),
708
+ "pcb_rules": _simple("PcbRules"),
709
+ "pcb_polygons": _simple("PcbPolygons"),
710
+ "pcb_primitives": _simple("PcbPrimitives", ("kinds", "primitive_kinds", list(PRIMITIVE_KINDS)),
711
+ ("layers", "layer_filter", []), ("offset", "int", 0), ("limit", "int", 2000)),
712
+ "pcb_add_layer": _simple("PcbAddLayer", ("layer", "layer", ...)),
713
+ # outputs
714
+ "outjob_run": _simple("OutJobRun", ("outjob", "str", ...), ("project", "str", ""),
715
+ ("medium", "str", "Folder Structure")),
716
+ }
717
+
718
+ READ_ONLY = {
719
+ "project_netlist", "sch_pins", "sch_components", "pcb_components", "pcb_pads", "pcb_nets",
720
+ "pcb_layers", "pcb_board_outline", "pcb_rules", "pcb_bodies", "doc_focus", "project_open", "sch_open", "pcb_open",
721
+ "project_state", "pcb_polygons", "pcb_primitives", "pcb_diff_pairs",
722
+ }
723
+
724
+ # Operations that remove design content in bulk, or give a placed part a new identity. A plan
725
+ # with any of them needs `allow_destructive`: a full rebuild for a local change once replaced a
726
+ # board's reviewed routing.
727
+ DESTRUCTIVE = {
728
+ "pcb_unroute": "removes the routing of whole nets",
729
+ "pcb_remove_components": "removes placed components and their copper links",
730
+ "pcb_clear_layer": "removes every free primitive of a layer",
731
+ "pcb_clear_texts": "removes every free text of a layer",
732
+ "pcb_delete_polygon": "removes a polygon pour",
733
+ "sch_clear": "removes everything on the sheet",
734
+ "sch_clear_wiring": "removes the sheet's wires, labels and ports",
735
+ "sch_delete": "removes a schematic part: the PCB link to it is lost",
736
+ }
737
+
738
+
739
+ def destructive(ops: list[dict]) -> list[dict]:
740
+ """The operations of a plan that need `allow_destructive`, with what each removes."""
741
+ return [{"op": i, "name": op["op"], "removes": DESTRUCTIVE[op["op"]]}
742
+ for i, op in enumerate(ops) if op.get("op") in DESTRUCTIVE]
743
+
744
+
745
+ # Operation fields that name a file. Altium compares open projects and documents by their path text
746
+ # (SameText): an op naming "C:/a/b.PrjPcb" did not find the project open as "C:\a\b.PrjPcb" and
747
+ # opened it a second time, after which reads, DRC and outputs met either copy (measured).
748
+ PATH_KEYS = frozenset({"path", "project", "lib", "outjob"})
749
+ _ABSOLUTE = re.compile(r"^(?:[A-Za-z]:[\\/]|[\\/]{2})")
750
+
751
+
752
+ def windows_path(value: Any) -> Any:
753
+ """An absolute path as Altium writes it: backslashes, no `.`, `..` or doubled separators.
754
+ Anything else (names such as "Free Documents", empty defaults) passes unchanged."""
755
+ return ntpath.normpath(value) if isinstance(value, str) and _ABSOLUTE.match(value) else value
756
+
757
+
758
+ def normalise_paths(ops: list[dict]) -> list[dict]:
759
+ """The operations with every file field in Windows form; the input is not changed."""
760
+ return [{key: windows_path(value) if key in PATH_KEYS else value for key, value in op.items()}
761
+ if isinstance(op, dict) else op for op in ops]
762
+
763
+
764
+ def _folder(path: str) -> str:
765
+ return str(path).replace("/", "\\").rsplit("\\", 1)[0] if "\\" in str(path).replace("/", "\\") else ""
766
+
767
+
768
+ def folder_of(path: str) -> str:
769
+ """The folder of a project or document path, in the Windows form answer rules compare against."""
770
+ return _folder(path)
771
+
772
+
773
+ def server_prompt_answers(folder: str, save_none: bool) -> list[dict]:
774
+ """Answers to Altium 365's server prompts only, for closes that must neither save nor commit
775
+ anything: "Save changes to Server?" gets "Do nothing"; "Confirm Save to Server" gets "Do nothing"
776
+ (or "Save None", when the caller's documents were saved first), then OK."""
777
+ confirm = [["Do nothing", "Save None"], ["OK"]] if save_none else [["Do nothing"], ["OK"]]
778
+ return [{"title": "Unsaved Changes", "press": [["Do nothing"]], "folder": folder},
779
+ {"title": "Confirm Save to Server", "press": confirm, "folder": folder},
780
+ {"title": "Confirm Not Saving", "press": [["OK"]], "folder": folder, "documents": "optional"}]
781
+
782
+
783
+ def prompt_answers(ops: list[dict]) -> list[dict]:
784
+ """The prompts a request may answer, derived from what its operations do.
785
+
786
+ Each rule names a dialog (by the start of its title), the check boxes to clear first, the
787
+ steps to press in order (each step lists alternative captions; the first one the dialog has
788
+ is pressed), and the folder every document the dialog names must sit in. A prompt no rule
789
+ covers is captured and cancelled, as before. Nothing here ever commits to a server: "Save to
790
+ Server" is always cleared, and server prompts are answered "Do nothing".
791
+
792
+ "Unsaved Changes" comes in two forms, measured on Altium Designer Develop 26.10: a document
793
+ modified in memory ("Save changes?": Save, Don't Save, Cancel, with a "Save to Server" box),
794
+ and, in an Altium 365 working copy, a document saved but not committed ("Save changes to
795
+ Server?": Save, Do nothing, Cancel). "Save" there commits to the server.
796
+
797
+ "Confirm Save to Server for (n) Modified Documents" lists documents with a decision each. With
798
+ two documents it offered "Do nothing"; with six (drawings and BOM changed in memory, OutJobs on
799
+ disk) its buttons were Save All, Save None, Save All to Server, OK and Cancel (measured). "Save
800
+ None" saves nothing and commits nothing; "Save All to Server" is never pressed.
801
+ """
802
+ discard = [["Don't Save", "Do nothing"]]
803
+ rules: list[dict] = []
804
+ for op in ops:
805
+ name = op.get("op")
806
+ if name == "project_close":
807
+ folder = _folder(op.get("path", ""))
808
+ if op.get("save", "all") == "none":
809
+ rules.append({"title": "Unsaved Changes", "uncheck": ["Save to Server"], "press": discard,
810
+ "folder": folder})
811
+ else:
812
+ # Everything was saved first; only the server form can come, and it is not a commit.
813
+ rules.append({"title": "Unsaved Changes", "press": [["Do nothing"]], "folder": folder})
814
+ rules.append({"title": "Confirm Save to Server", "press": [["Do nothing", "Save None"], ["OK"]],
815
+ "folder": folder})
816
+ # It confirms the answer just given to a dialog that named the documents; it names none itself.
817
+ rules.append({"title": "Confirm Not Saving", "press": [["OK"]], "folder": folder, "documents": "optional"})
818
+ elif name == "doc_close" and op.get("save") is False:
819
+ # Only this document: another sheet's prompt in the same folder is not this request's to answer.
820
+ rules.append({"title": "Unsaved Changes", "uncheck": ["Save to Server"], "press": discard,
821
+ "folder": _folder(op.get("path", "")), "files": [str(op.get("path", ""))]})
822
+ elif name == "project_netlist" and op.get("reopen"):
823
+ # The reopen saves every document first; only the server's prompts can follow.
824
+ rules += server_prompt_answers(_folder(op.get("path", "")), save_none=True)
825
+ elif name == "outjob_run" and op.get("project"):
826
+ # Generating outputs leaves in-memory changes only (new GUIDs on load); the server refuses
827
+ # a run while the project holds unsaved edits, so discarding here loses no work.
828
+ rules.append({"title": "Unsaved Changes", "uncheck": ["Save to Server"], "press": discard,
829
+ "folder": _folder(op["project"])})
830
+ return rules
831
+
832
+
833
+ def validate(ops: Any) -> list[dict]:
834
+ if not isinstance(ops, list) or not ops:
835
+ raise OpError("ops must be a non-empty list")
836
+ for i, op in enumerate(ops):
837
+ if not isinstance(op, dict) or op.get("op") not in OPS:
838
+ raise OpError(f"ops[{i}]: unknown operation {op.get('op') if isinstance(op, dict) else op!r}")
839
+ return ops
840
+
841
+
842
+ def render(ops: list[dict]) -> str:
843
+ """Render a validated list of operations as the generated `Body` procedure."""
844
+ validate(ops)
845
+ lines = ["procedure Body;", "begin"]
846
+ for index, op in enumerate(ops):
847
+ try:
848
+ calls = OPS[op["op"]](op)
849
+ except OpError as exc:
850
+ raise OpError(f"ops[{index}] ({op['op']}): {exc}") from None
851
+ lines.append(f" OpBegin({index}, {pascal_string(op['op'])});")
852
+ lines += [f" {call}" for call in calls]
853
+ lines += [" Done;", "end;"]
854
+ return "\n".join(lines)
855
+
856
+
857
+ def is_read_only(ops: list[dict]) -> bool:
858
+ return all(op.get("op") in READ_ONLY for op in ops)