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.
- enginehall-2.0.0.dist-info/METADATA +511 -0
- enginehall-2.0.0.dist-info/RECORD +75 -0
- enginehall-2.0.0.dist-info/WHEEL +4 -0
- enginehall-2.0.0.dist-info/entry_points.txt +4 -0
- enginehall-2.0.0.dist-info/licenses/LICENSE +51 -0
- pcba/THIRD_PARTY_NOTICES.md +72 -0
- pcba/__init__.py +6 -0
- pcba/adapters/__init__.py +10 -0
- pcba/adapters/altium_bridge/MANIFEST.json +5 -0
- pcba/adapters/altium_bridge/__init__.py +1 -0
- pcba/adapters/altium_bridge/bridge.pas +3342 -0
- pcba/adapters/altium_drive.py +1160 -0
- pcba/adapters/altium_house.py +591 -0
- pcba/adapters/altium_ops.py +858 -0
- pcba/adapters/altium_outjob.py +247 -0
- pcba/adapters/altium_uia.py +304 -0
- pcba/adapters/altium_vcs.py +197 -0
- pcba/adapters/altium_win32.py +457 -0
- pcba/adapters/cubemx_drive.py +248 -0
- pcba/baseline.py +151 -0
- pcba/changes.py +81 -0
- pcba/cli.py +891 -0
- pcba/components.py +323 -0
- pcba/config.py +180 -0
- pcba/data/components/generic.json +61 -0
- pcba/discover.py +350 -0
- pcba/doctor.py +131 -0
- pcba/house.py +123 -0
- pcba/mcp/__init__.py +5 -0
- pcba/mcp/altium_server.py +1358 -0
- pcba/mcp/protocol.py +224 -0
- pcba/mcp/server.py +332 -0
- pcba/model/__init__.py +38 -0
- pcba/model/design.py +232 -0
- pcba/model/finding.py +148 -0
- pcba/model/pinmap.py +200 -0
- pcba/parsers/__init__.py +0 -0
- pcba/parsers/altium/__init__.py +37 -0
- pcba/parsers/altium/_sheet.py +293 -0
- pcba/parsers/altium/geometry.py +103 -0
- pcba/parsers/altium/netlist.py +302 -0
- pcba/parsers/altium/pinmap.py +336 -0
- pcba/parsers/altium/project.py +249 -0
- pcba/parsers/altium/records.py +227 -0
- pcba/parsers/firmware/__init__.py +5 -0
- pcba/parsers/firmware/index.py +250 -0
- pcba/parsers/ioc/__init__.py +49 -0
- pcba/parsers/ioc/mcu_db.py +180 -0
- pcba/parsers/ioc/reader.py +182 -0
- pcba/parsers/ioc/validate.py +192 -0
- pcba/parsers/kicad/__init__.py +103 -0
- pcba/parsers/kicad/board.py +269 -0
- pcba/parsers/kicad/kicad_cli.py +190 -0
- pcba/parsers/kicad/pinmap.py +90 -0
- pcba/parsers/kicad/schematic.py +517 -0
- pcba/parsers/kicad/sexpr.py +136 -0
- pcba/publish.py +269 -0
- pcba/report/__init__.py +7 -0
- pcba/report/json_report.py +39 -0
- pcba/report/sarif.py +128 -0
- pcba/report/text.py +304 -0
- pcba/requirements.py +320 -0
- pcba/review.py +177 -0
- pcba/rules/__init__.py +312 -0
- pcba/rules/cross_domain.py +496 -0
- pcba/rules/kicad_board.py +775 -0
- pcba/rules/kicad_common.py +167 -0
- pcba/rules/kicad_components.py +603 -0
- pcba/rules/kicad_schematic.py +621 -0
- pcba/rules/locate.py +22 -0
- pcba/rules/schematic.py +123 -0
- pcba/rules/signals.py +149 -0
- pcba/rules/spice.py +59 -0
- pcba/rules/stm32.py +155 -0
- 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)
|