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,247 @@
1
+ """OutJobs the Altium drive writes: what Altium generates (Gerber, drill, pick-and-place, BOM,
2
+ STEP, netlist) and what it publishes to PDF (schematic prints, Draftsman drawings, 3D prints).
3
+
4
+ Pure text: each writer renders INI that Altium accepts and runs with the bridge's `outjob_run`;
5
+ nothing here talks to Altium. The configuration strings are Altium's own, taken from the OutJobs
6
+ it writes. `pcba.mcp.altium_server` plans and runs them. Class C like the rest of the drive.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ from pathlib import Path
14
+
15
+ from pcba.adapters import altium_ops
16
+
17
+ OUTPUT_TYPES = {
18
+ "gerber": ("Gerber", "Gerber Files", "Fabrication"),
19
+ "drill": ("NC Drill", "NC Drill Files", "Fabrication"),
20
+ "pick_place": ("Pick Place", "Pick and Place", "Assembly"),
21
+ "bom": ("SimpleBOM", "Bill of Materials", "Report"),
22
+ "step": ("ExportSTEP", "STEP", "Export"),
23
+ "netlist": ("ProtelNetlist", "Protel Netlist", "Netlist"),
24
+ }
25
+
26
+ # Gerber layers to plot, as Altium's V7 layer ids (the encoding of its own example OutJobs):
27
+ # 0x0100nnnn signal (1 = top, 0xFFFF = bottom), 0x0101nnnn internal planes, 0x0102nnnn mechanical,
28
+ # 0x0103nnnn overlay (6, 7), paste (8, 9), solder mask (10, 11), keep-out (13), drill drawing (24).
29
+ # Without an explicit list Altium plots only layers that hold primitives, which leaves out the
30
+ # solder-mask and paste layers that pads imply.
31
+ GERBER_LAYERS = (
32
+ [0x01000001, 0x0100FFFF] + [0x01000000 + i for i in range(2, 9)] # top, bottom, mid 1-7
33
+ + [0x01010000 + i for i in range(1, 9)] # planes 1-8
34
+ + [0x01030000 + i for i in (6, 7, 8, 9, 10, 11, 13)] # overlays, paste, mask, keep-out
35
+ + [0x01020000 + i for i in (1, 13, 15)] # mechanical 1, 13, 15
36
+ )
37
+ GERBER_PLOT = ("Plot.Set=SerializeLayerHash.Version~2,ClassName~TLayerToBoolean,"
38
+ + ",".join(f"{i}~1" for i in GERBER_LAYERS))
39
+ GERBER_DEFAULT = (
40
+ "AddToAllLayerClasses.Set= |AddToAllPlots.Set=SerializeLayerHash.Version~2,ClassName~TLayerToBoolean|"
41
+ "CentrePlots=False|"
42
+ "DrillDrawingSymbol=GraphicsSymbol|DrillDrawingSymbolSize=500000|EmbeddedApertures=True|FilmBorderSize=10000000|"
43
+ "FilmXSize=200000000|FilmYSize=160000000|FlashAllFills=False|FlashPadShapes=True|G54OnApertureChange=False|"
44
+ "GenerateDRCRulesFile=True|GenerateReliefShapes=True|GerberUnit=Metric|IncludeUnconnectedMidLayerPads=False|"
45
+ "LeadingAndTrailingZeroesMode=SuppressLeadingZeroes|MaxApertureSize=2500000|MinusApertureTolerance=50|"
46
+ "MirrorDrillDrawingPlots=False|MirrorDrillGuidePlots=False|NumberOfDecimals=5|OptimizeChangeLocationCommands=True|"
47
+ "OriginPosition=Relative|Panelize=False|" + GERBER_PLOT + "|PlotPositivePlaneLayers=False|"
48
+ "PlotUsedDrillDrawingLayerPairs=True|"
49
+ "PlotUsedDrillGuideLayerPairs=False|PlusApertureTolerance=50|Record=GerberView|SoftwareArcs=False|Sorted=False"
50
+ )
51
+ DRILL_DEFAULT = (
52
+ "BoardEdgeRoutToolDia=2000000|GenerateBoardEdgeRout=False|GenerateDrilledSlotsG85=False|GenerateEIADrillFile=False|"
53
+ "GenerateSeparatePlatedNonPlatedFiles=True|NumberOfDecimals=5|NumberOfUnits=2|OptimizeChangeLocationCommands=True|"
54
+ "OriginPosition=Relative|Record=DrillView|Units=Metric|ZeroesMode=SuppressTrailingZeroes"
55
+ )
56
+ CONFIG = {
57
+ "gerber": GERBER_DEFAULT,
58
+ "drill": DRILL_DEFAULT,
59
+ "pick_place": "Record=PickPlaceView|Units=Metric|GenerateCSVFormat=True|GenerateTextFormat=False",
60
+ }
61
+
62
+
63
+ def _job_outputs(text: str) -> list[tuple[str, str]]:
64
+ """What an OutJob generates: its OutputType, OutputDocumentPath and OutputEnabled entries, which
65
+ Altium keeps as written when it normalises a job (a restored or hand-edited job differs here)."""
66
+ keys = ("OutputType", "OutputDocumentPath", "OutputEnabled")
67
+ found = []
68
+ for line in text.splitlines():
69
+ key, _, value = line.strip().partition("=")
70
+ if key.startswith(keys) and key.rstrip("0123456789") in keys:
71
+ found.append((key, value))
72
+ return sorted(found)
73
+
74
+
75
+ def _write_job(path: Path, lines: list[str]) -> None:
76
+ """Write an OutJob only when its settings changed since the drive last wrote it.
77
+
78
+ Altium normalises an OutJob it saves (defaults, absolute paths, section order), so the file
79
+ never equals the drive's text again; rewriting it on every run made each output run a change of
80
+ an Altium 365 working copy. The hash of the text last written is kept in
81
+ `<project folder>/.pcba/outjobs.json`; the bridge no longer saves OutJobs at all."""
82
+ text = "\r\n".join(lines)
83
+ digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
84
+ store = path.parent / ".pcba" / "outjobs.json"
85
+ try:
86
+ known = json.loads(store.read_text(encoding="utf-8")) if store.is_file() else {}
87
+ except (OSError, ValueError):
88
+ known = {}
89
+ if path.is_file() and known.get(path.name) == digest and \
90
+ _job_outputs(path.read_text(encoding="utf-8", errors="replace")) == _job_outputs(text):
91
+ return
92
+ path.write_text(text, encoding="utf-8", newline="")
93
+ known[path.name] = digest
94
+ store.parent.mkdir(exist_ok=True)
95
+ store.write_text(json.dumps(known, indent=1, sort_keys=True) + "\n", encoding="utf-8")
96
+
97
+
98
+ def write_outjob(path: Path, outputs: list[str]) -> None:
99
+ """Write an OutJob (INI text) Altium accepts: one Folder Structure container."""
100
+ lines = ["[OutputJobFile]", "Version=1.0", "", "[OutputGroup1]", "Name=", "Description=",
101
+ "TargetOutputMedium=Folder Structure", "VariantName=[No Variations]", "VariantScope=0",
102
+ "OutputMedium1=Folder Structure", "OutputMedium1_Type=GeneratedFiles"]
103
+ for index, key in enumerate(outputs, 1):
104
+ kind, name, category = OUTPUT_TYPES[key]
105
+ lines += [f"OutputType{index}={kind}", f"OutputName{index}={name}", f"OutputCategory{index}={category}",
106
+ f"OutputDocumentPath{index}=", f"OutputVariantName{index}=", f"OutputEnabled{index}=1",
107
+ f"OutputEnabled{index}_OutputMedium1=1", f"OutputDefault{index}=0"]
108
+ if key in CONFIG:
109
+ lines += [f"Configuration{index}_Name1=OutputConfigurationParameter1",
110
+ f"Configuration{index}_Item1={CONFIG[key]}"]
111
+ lines += ["", "[GeneratedFilesSettings]", "RelativeOutputPath1=\\", "OpenOutputs1=0", "AddToProject1=0",
112
+ "TimestampFolder1=0", "UseOutputName1=1", ""]
113
+ _write_job(path, lines)
114
+
115
+
116
+ SCH_PRINT_VIEW = ("Record=SchPrintView|ShowNoERC=True|ShowParamSet=True|ShowProbe=True|ShowBlanket=True|ShowNote=True|"
117
+ "ShowNoteCollapsed=True|ExpandDesignator=True|ExpandNetLabel=False|ExpandPort=False|ExpandSheetNum=False|"
118
+ "ExpandDocNum=False|PrintArea=0|PrintAreaRect.X1=0|PrintAreaRect.Y1=0|PrintAreaRect.X2=0|PrintAreaRect.Y2=0")
119
+ SCH_PAGE = ("Record=PageOptions|CenterHorizontal=True|CenterVertical=True|PrintScale=1.00|XCorrection=1.00|"
120
+ "YCorrection=1.00|PrintKind=1|BorderSize=5000000|LeftOffset=0|BottomOffset=0|Orientation=2|PaperLength=1000|"
121
+ "PaperWidth=1000|Scale=100|PaperSource=7|PrintQuality=-4|MediaType=1|DitherType=10|PrintScaleMode=1")
122
+
123
+
124
+ PAPERS = ("A4", "A3", "A2", "Letter", "Tabloid")
125
+
126
+
127
+ def write_pdf_outjob(path: Path, pdf: Path, paper: str | None = None) -> None:
128
+ """An OutJob that publishes every schematic sheet to one PDF, without opening it or asking. Each page
129
+ is the sheet's own size, or with `paper` that paper (landscape), the sheet scaled to fit."""
130
+ page = SCH_PAGE + (f"|PaperKind={paper}" if paper else "")
131
+ lines = ["[OutputJobFile]", "Version=1.0", "", "[OutputGroup1]", "Name=", "Description=", "TargetOutputMedium=PDF",
132
+ "VariantName=[No Variations]", "VariantScope=0", "OutputMedium1=PDF", "OutputMedium1_Type=Publish",
133
+ "OutputType1=Schematic Print", "OutputName1=Schematic Prints", "OutputCategory1=Documentation",
134
+ "OutputDocumentPath1=[Project Physical Documents]", "OutputVariantName1=", "OutputEnabled1=1",
135
+ "OutputEnabled1_OutputMedium1=1", "OutputDefault1=0", f"PageOptions1={page}",
136
+ "Configuration1_Name1=OutputConfigurationParameter1", f"Configuration1_Item1={SCH_PRINT_VIEW}",
137
+ "", "[PublishSettings]", f"OutputFilePath1={pdf}", "ReleaseManaged1=0", "OutputBasePath1=",
138
+ "OutputPathMedia1=", "OutputPathMediaValue1=", "OutputPathOutputer1=", "OutputPathOutputerPrefix1=",
139
+ "OutputPathOutputerValue1=", f"OutputFileName1={pdf.name}", "OutputFileNameMulti1=",
140
+ "UseOutputNameForMulti1=0", "OutputFileNameSpecial1=", "OpenOutput1=0", "PromptOverwrite1=0",
141
+ "PublishMethod1=0", "ZoomLevel1=50", f"FitSCHPrintSizeToDoc1={0 if paper else 1}",
142
+ "FitPCBPrintSizeToDoc1=0", "GenerateNetsInfo1=1", "MarkPins1=1", "MarkNetLabels1=1",
143
+ "MarkPortsId1=1", "GenerateTOC=1", "ShowComponentParameters1=1", ""]
144
+ _write_job(path, lines)
145
+
146
+
147
+ DWF_PAGE = ("Record=PageOptions|CenterHorizontal=True|CenterVertical=True|PrintScale=1.00|XCorrection=1.00|"
148
+ "YCorrection=1.00|PrintKind=1|BorderSize=5000000|LeftOffset=0|BottomOffset=0|Orientation=2|PaperLength=1000|"
149
+ "PaperWidth=1000|Scale=100|PaperSource=7|PrintQuality=-3|MediaType=1|DitherType=10|PrintScaleMode=1|"
150
+ "PaperKind=A4|PaperIndex=9")
151
+
152
+
153
+ def drawing_medium(doc: Path) -> str:
154
+ """The Publish medium of one Draftsman document; the bridge publishes any medium named PDF..."""
155
+ return f"PDF {doc.stem}"
156
+
157
+
158
+ def write_drawings_outjob(path: Path, drawings: list[tuple[Path, Path]]) -> None:
159
+ """An OutJob that publishes each Draftsman document (.PCBDwf) to its own PDF: one Publish medium
160
+ per drawing, the drawing's own sheet size (FitPCBPrintSizeToDoc), no viewer, no prompt."""
161
+ lines = ["[OutputJobFile]", "Version=1.0", "", "[OutputGroup1]", "Name=", "Description=",
162
+ f"TargetOutputMedium={drawing_medium(drawings[0][0])}", "VariantName=[No Variations]", "VariantScope=0"]
163
+ for m, (doc, _) in enumerate(drawings, 1):
164
+ lines += [f"OutputMedium{m}={drawing_medium(doc)}", f"OutputMedium{m}_Type=Publish"]
165
+ for n, (doc, _) in enumerate(drawings, 1):
166
+ lines += [f"OutputType{n}=PCBDrawing", f"OutputName{n}={doc.stem}", f"OutputCategory{n}=Documentation",
167
+ f"OutputDocumentPath{n}={doc.name}", f"OutputVariantName{n}=", f"OutputEnabled{n}=1"]
168
+ lines += [f"OutputEnabled{n}_OutputMedium{m}={1 if m == n else 0}" for m in range(1, len(drawings) + 1)]
169
+ lines += [f"OutputDefault{n}=0", f"PageOptions{n}={DWF_PAGE}"]
170
+ lines += ["", "[PublishSettings]"]
171
+ for m, (_, pdf) in enumerate(drawings, 1):
172
+ lines += [f"OutputFilePath{m}={pdf}", f"ReleaseManaged{m}=0", f"OutputBasePath{m}=", f"OutputPathMedia{m}=",
173
+ f"OutputPathMediaValue{m}=", f"OutputPathOutputer{m}=", f"OutputPathOutputerPrefix{m}=",
174
+ f"OutputPathOutputerValue{m}=", f"OutputFileName{m}={pdf.name}", f"OutputFileNameMulti{m}=",
175
+ f"UseOutputNameForMulti{m}=0", f"OutputFileNameSpecial{m}=", f"OpenOutput{m}=0",
176
+ f"PromptOverwrite{m}=0", f"PublishMethod{m}=0", f"ZoomLevel{m}=50", f"FitSCHPrintSizeToDoc{m}=1",
177
+ f"FitPCBPrintSizeToDoc{m}=1", f"GenerateNetsInfo{m}=0", f"MarkPins{m}=0", f"MarkNetLabels{m}=0",
178
+ f"MarkPortsId{m}=0", f"GenerateTOC{m}=0", f"ShowComponentParameters{m}=0"]
179
+ lines.append("")
180
+ _write_job(path, lines)
181
+
182
+
183
+ # Altium's 3D view configuration for prints (board core and prepreg, mask, copper, silk, component
184
+ # bodies and their STEP models; white workspace), as Altium writes it into an OutJob's PCB 3D Print.
185
+ # Altium's own example hides STEP models (SHOWCOMPONENTSTEPMODELS FALSE), which drew a board with
186
+ # manufacturer models as bare pads.
187
+ PCB3D_VIEW_CONFIG = (r"RECORD\3Board\2CFGALL.CONFIGURATIONKIND\33"
188
+ r"\2CFGALL.CONFIGURATIONDESC\3Enter%20description%20of%20new%20view%20configuration"
189
+ r"\2CFG3D.POSITIVETOPSOLDERMASK\3TRUE\2CFG3D.POSITIVEBOTTOMSOLDERMASK\3TRUE"
190
+ r"\2CFG3D.SHOWCOMPONENTBODIES\3TRUE\2CFG3D.SHOWCOMPONENTDEFAULTMODELS\3FALSE"
191
+ r"\2CFG3D.SHOWCOMPONENTSTEPMODELS\3TRUE\2CFG3D.SHOWBOARDCORE\3TRUE\2CFG3D.SHOWBOARDPREPREG\3TRUE"
192
+ r"\2CFG3D.SHOWTOPSILKSCREEN\3TRUE\2CFG3D.SHOWBOTSILKSCREEN\3TRUE\2CFG3D.WORKSPACECOLOR\316777215"
193
+ r"\2CFG3D.BOARDCORECOLOR\30\2CFG3D.BOARDPREPREGCOLOR\30\2CFG3D.TOPSOLDERMASKCOLOR\33307556"
194
+ r"\2CFG3D.BOTSOLDERMASKCOLOR\33307556\2CFG3D.COPPERCOLOR\32402753"
195
+ r"\2CFG3D.TOPSILKSCREENCOLOR\316448250\2CFG3D.BOTSILKSCREENCOLOR\316448250"
196
+ r"\2CFG3D.WORKSPACELUMINANCEVARIATION\330\2CFG3D.WORKSPACECOLOROPACITY\31.000000"
197
+ r"\2CFG3D.BOARDCORECOLOROPACITY\30.500000\2CFG3D.BOARDPREPREGCOLOROPACITY\30.500000"
198
+ r"\2CFG3D.TOPSOLDERMASKCOLOROPACITY\30.600000\2CFG3D.BOTSOLDERMASKCOLOROPACITY\30.600000"
199
+ r"\2CFG3D.COPPERCOLOROPACITY\31.000000\2CFG3D.TOPSILKSCREENCOLOROPACITY\31.000000"
200
+ r"\2CFG3D.BOTSILKSCREENCOLOROPACITY\31.000000\2CFG3D.BOARDTHICKNESSSCALING\31.000000")
201
+ PCB3D_PAGE = ("Record=PageOptions|CenterHorizontal=True|CenterVertical=True|PrintScale=1.61|XCorrection=1.00|"
202
+ "YCorrection=1.00|PrintKind=1|BorderSize=5000000|LeftOffset=0|BottomOffset=0|Orientation=2|"
203
+ "PaperLength=1000|PaperWidth=1000|Scale=100|PaperSource=7|PrintQuality=-4|MediaType=1|DitherType=10|"
204
+ "PrintScaleMode=1|PaperKind=A4")
205
+ # Camera orientation as Altium's quaternion (w, x, y, z): straight views look along Z; the angled
206
+ # views tilt and turn the board, the bottom one also turned over about X. The last field scales
207
+ # the zoom so an angled board fits the page.
208
+ PCB3D_VIEWS = (
209
+ ("Top", (1, 0, 0, 0), True, 1.0),
210
+ ("Bottom", (1, 0, 0, 0), False, 1.0),
211
+ ("Top angled", (0.897718250751495, -0.291343718767166, -0.308464288711548, 0.118618682026863), True, 0.68),
212
+ ("Bottom angled", (0.2913437, 0.8977183, 0.1186187, 0.3084643), False, 0.68),
213
+ )
214
+
215
+
216
+ def write_3d_outjob(path: Path, pdf: Path, origin: tuple[int, int], outline: list[tuple[int, int]]) -> None:
217
+ """An OutJob that publishes Altium's own 3D renderings of the board (PCB 3D Print: top, bottom
218
+ and two angled views) to one PDF. The camera follows the board outline (Altium internal units,
219
+ outline relative to the board origin)."""
220
+ xs = [pt[0] for pt in outline]
221
+ ys = [pt[1] for pt in outline]
222
+ margin = 14 * altium_ops.UNITS_PER_MM
223
+ view_x, view_y = max(xs) - min(xs) + margin, max(ys) - min(ys) + margin
224
+ look_x, look_y = origin[0] + (min(xs) + max(xs)) / 2, origin[1] + (min(ys) + max(ys)) / 2
225
+ lines = ["[OutputJobFile]", "Version=1.0", "", "[OutputGroup1]", "Name=", "Description=", "TargetOutputMedium=PDF",
226
+ "VariantName=[No Variations]", "VariantScope=0", "OutputMedium1=PDF", "OutputMedium1_Type=Publish"]
227
+ for n, (name, (w, x, y, z), top, zoom) in enumerate(PCB3D_VIEWS, 1):
228
+ item = (f"Record=Pcb3DPrintView|ResX=300|ResY=300|ViewX={view_x:.0f}|ViewY={view_y:.0f}|LookAtX={look_x:.0f}|"
229
+ f"LookAtY={look_y:.0f}|LookAtZ=0|QuatX={x}|QuatY={y}|QuatZ={z}|QuatW={w}|"
230
+ f"Zoom={1018 * zoom / view_x:.6E}|"
231
+ f"UnitsPercent=True|UnitsDPI=True|LockResAspect=True|ViewConfigType=.config_3d|"
232
+ f"CustomCamera={zoom != 1.0}|ViewFromTop={top}|ViewConfig={PCB3D_VIEW_CONFIG}")
233
+ lines += [f"OutputType{n}=PCB 3D Print", f"OutputName{n}=PCB 3D Print {name}",
234
+ f"OutputCategory{n}=Documentation", f"OutputDocumentPath{n}=", f"OutputVariantName{n}=",
235
+ f"OutputEnabled{n}=1",
236
+ # Not a flag: the output's position within the medium (0 = not in it), so the
237
+ # views become pages 1 to 4 of the PDF, as Altium writes it in its own OutJobs.
238
+ f"OutputEnabled{n}_OutputMedium1={n}", f"OutputDefault{n}=0", f"PageOptions{n}={PCB3D_PAGE}",
239
+ f"Configuration{n}_Name1=OutputConfigurationParameter1", f"Configuration{n}_Item1={item}"]
240
+ lines += ["", "[PublishSettings]", f"OutputFilePath1={pdf}", "ReleaseManaged1=0", "OutputBasePath1=",
241
+ "OutputPathMedia1=", "OutputPathMediaValue1=", "OutputPathOutputer1=", "OutputPathOutputerPrefix1=",
242
+ "OutputPathOutputerValue1=", f"OutputFileName1={pdf.name}", "OutputFileNameMulti1=",
243
+ "UseOutputNameForMulti1=0", "OutputFileNameSpecial1=", "OpenOutput1=0", "PromptOverwrite1=0",
244
+ "PublishMethod1=0", "ZoomLevel1=50", "FitSCHPrintSizeToDoc1=1", "FitPCBPrintSizeToDoc1=1",
245
+ "GenerateNetsInfo1=0", "MarkPins1=0", "MarkNetLabels1=0", "MarkPortsId1=0", "GenerateTOC=0",
246
+ "ShowComponentParameters1=0", ""]
247
+ _write_job(path, lines)
@@ -0,0 +1,304 @@
1
+ """Windows UI Automation for Altium's dialogs, through ctypes vtable calls (no comtypes).
2
+
3
+ Altium draws its prompts with three toolkits: VCL forms (`TMessageForm`, readable only in part
4
+ through Win32), standard `#32770` message boxes, and WPF windows (`HwndWrapper[…]`: "Unsaved
5
+ Changes", "Confirm Save to Server", "Version Control", "Save … to Server"). Win32 sees nothing
6
+ inside a WPF window: no child windows, no text. UI Automation sees all three, so this module is
7
+ how the drive reads a dialog (title, text, buttons, check boxes, edit fields) and answers it by
8
+ the caption of a button instead of cancelling it.
9
+
10
+ Stdlib only, like `altium_win32`: the engine keeps its single runtime dependency. Each call
11
+ creates and releases its own COM objects; nothing is cached across dialogs.
12
+
13
+ Vtable indices are those of UIAutomationClient.h (Windows 10 SDK): IUIAutomation,
14
+ IUIAutomationElement, IUIAutomationElementArray and the Invoke, Value, Toggle and
15
+ LegacyIAccessible patterns. Measured against Altium Designer Develop 26.10.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import ctypes
21
+ import sys
22
+ import uuid
23
+ from dataclasses import dataclass, field
24
+
25
+ CONTROL = {
26
+ 50000: "button", 50002: "checkbox", 50004: "edit", 50005: "hyperlink", 50007: "listitem",
27
+ 50008: "list", 50020: "text", 50024: "treeitem", 50025: "custom", 50029: "dataitem",
28
+ 50032: "window", 50033: "pane", 50028: "datagrid", 50036: "table",
29
+ }
30
+ TREESCOPE_DESCENDANTS = 4
31
+ INVOKE, VALUE, TOGGLE, LEGACY = 10000, 10002, 10015, 10018
32
+ TOGGLE_ON = 1
33
+
34
+
35
+ @dataclass(slots=True)
36
+ class Element:
37
+ """One UI Automation element of a dialog, as plain data."""
38
+
39
+ kind: str
40
+ name: str
41
+ automation_id: str = ""
42
+ cls: str = ""
43
+ enabled: bool = True
44
+ checked: bool | None = None
45
+ value: str | None = None
46
+ rect: tuple[int, int, int, int] = (0, 0, 0, 0)
47
+
48
+
49
+ @dataclass(slots=True)
50
+ class Dialog:
51
+ """What a person sees in a dialog: its title, the sentences, and what can be pressed."""
52
+
53
+ title: str
54
+ framework: str
55
+ texts: list[str] = field(default_factory=list)
56
+ buttons: list[str] = field(default_factory=list)
57
+ checkboxes: list[dict] = field(default_factory=list)
58
+ edits: list[dict] = field(default_factory=list)
59
+ items: list[dict] = field(default_factory=list)
60
+
61
+ def to_dict(self) -> dict:
62
+ out = {"title": self.title, "framework": self.framework, "texts": self.texts, "buttons": self.buttons}
63
+ for key in ("checkboxes", "edits", "items"):
64
+ if getattr(self, key):
65
+ out[key] = getattr(self, key)
66
+ return out
67
+
68
+
69
+ def caption(text: str) -> str:
70
+ """A caption as the drive compares it: no `&` accelerator, typographic quotes made plain
71
+ (Altium writes "Don’t Save" with U+2019), whitespace collapsed, lower case."""
72
+ plain = text.replace("&", "").replace("’", "'").replace("‘", "'").replace("…", "...")
73
+ return " ".join(plain.split()).lower()
74
+
75
+
76
+ def summarise(title: str, framework: str, elements: list[Element]) -> Dialog:
77
+ """Turn UI Automation elements into what a person reads. WPF repeats each button's and check
78
+ box's caption as a text element inside it; those repeats are dropped from `texts`."""
79
+ d = Dialog(title=title, framework=framework)
80
+ labels = {caption(e.name) for e in elements if e.kind in ("button", "checkbox") and e.name}
81
+ for e in elements:
82
+ if e.kind == "button" and e.name:
83
+ if e.name not in d.buttons:
84
+ d.buttons.append(e.name)
85
+ elif e.kind == "checkbox":
86
+ d.checkboxes.append({"name": e.name, "checked": e.checked})
87
+ elif e.kind == "edit":
88
+ d.edits.append({"name": e.name, "id": e.automation_id, "value": e.value})
89
+ elif e.kind == "text" and e.name.strip() and caption(e.name) not in labels:
90
+ if e.name.strip() not in d.texts:
91
+ d.texts.append(e.name.strip())
92
+ elif e.kind in ("listitem", "dataitem", "treeitem") and e.name:
93
+ d.items.append({"kind": e.kind, "name": e.name})
94
+ return d
95
+
96
+
97
+ if sys.platform == "win32": # pragma: no cover - exercised only on Windows with Altium
98
+ import ctypes.wintypes as wt
99
+
100
+ ole32 = ctypes.OleDLL("ole32")
101
+ oleaut32 = ctypes.WinDLL("oleaut32")
102
+ HRESULT = ctypes.c_long
103
+
104
+ class GUID(ctypes.Structure):
105
+ _fields_ = [("Data1", ctypes.c_ulong), ("Data2", ctypes.c_ushort), ("Data3", ctypes.c_ushort),
106
+ ("Data4", ctypes.c_ubyte * 8)]
107
+
108
+ @classmethod
109
+ def of(cls, text: str) -> GUID:
110
+ g = cls()
111
+ ctypes.memmove(ctypes.byref(g), uuid.UUID(text).bytes_le, 16)
112
+ return g
113
+
114
+ CLSID_CUIAutomation = GUID.of("ff48dba4-60ef-4201-aa87-54103eef594e")
115
+ IID_IUIAutomation = GUID.of("30cbe57d-d9d0-452a-ab13-7ac5ac4825ee")
116
+ IID = {
117
+ INVOKE: GUID.of("fb377fbe-8ea6-46d5-9c73-6499642d3059"),
118
+ VALUE: GUID.of("a94cd8b1-0844-4cd6-9d2d-640537ab39e9"),
119
+ TOGGLE: GUID.of("94cf8058-9b8d-4ab9-8bfd-4cd0a33c8c70"),
120
+ LEGACY: GUID.of("828055ad-355b-4435-86d5-3b51c14a9b1b"),
121
+ }
122
+
123
+ def _call(ptr, index: int, *argtypes):
124
+ vtbl = ctypes.cast(ptr, ctypes.POINTER(ctypes.POINTER(ctypes.c_void_p))).contents
125
+ fn = ctypes.WINFUNCTYPE(HRESULT, ctypes.c_void_p, *argtypes)(vtbl[index])
126
+
127
+ def invoke(*args):
128
+ hr = fn(ptr, *args)
129
+ if hr < 0:
130
+ raise OSError(f"UI Automation call {index} failed: HRESULT 0x{hr & 0xFFFFFFFF:08X}")
131
+ return hr
132
+ return invoke
133
+
134
+ def _release(ptr) -> None:
135
+ if ptr:
136
+ vtbl = ctypes.cast(ptr, ctypes.POINTER(ctypes.POINTER(ctypes.c_void_p))).contents
137
+ ctypes.WINFUNCTYPE(ctypes.c_ulong, ctypes.c_void_p)(vtbl[2])(ptr)
138
+
139
+ def _out_ptr(ptr, index: int, *args, argtypes=()) -> ctypes.c_void_p:
140
+ out = ctypes.c_void_p()
141
+ _call(ptr, index, *argtypes, ctypes.POINTER(ctypes.c_void_p))(*args, ctypes.byref(out))
142
+ return out
143
+
144
+ def _bstr(ptr, index: int) -> str:
145
+ out = _out_ptr(ptr, index)
146
+ if not out.value:
147
+ return ""
148
+ try:
149
+ return ctypes.wstring_at(out.value)
150
+ finally:
151
+ oleaut32.SysFreeString(out)
152
+
153
+ def _int(ptr, index: int) -> int:
154
+ out = ctypes.c_int()
155
+ _call(ptr, index, ctypes.POINTER(ctypes.c_int))(ctypes.byref(out))
156
+ return out.value
157
+
158
+ def _rect(ptr) -> tuple[int, int, int, int]:
159
+ r = wt.RECT()
160
+ _call(ptr, 43, ctypes.POINTER(wt.RECT))(ctypes.byref(r)) # get_CurrentBoundingRectangle
161
+ return (r.left, r.top, r.right, r.bottom)
162
+
163
+ def _pattern(el, pattern: int) -> ctypes.c_void_p:
164
+ out = ctypes.c_void_p()
165
+ _call(el, 14, ctypes.c_int, ctypes.POINTER(GUID), ctypes.POINTER(ctypes.c_void_p))(
166
+ pattern, ctypes.byref(IID[pattern]), ctypes.byref(out)) # GetCurrentPatternAs
167
+ return out
168
+
169
+ class _Session:
170
+ """One COM apartment and one IUIAutomation object for the duration of a `with` block."""
171
+
172
+ def __enter__(self):
173
+ try:
174
+ ole32.CoInitializeEx(None, 0x2) # apartment threaded; S_FALSE if already initialised
175
+ except OSError:
176
+ pass # RPC_E_CHANGED_MODE: the thread already has an apartment, which is fine
177
+ self.uia = ctypes.c_void_p()
178
+ ole32.CoCreateInstance(ctypes.byref(CLSID_CUIAutomation), None, 1,
179
+ ctypes.byref(IID_IUIAutomation), ctypes.byref(self.uia))
180
+ self.owned: list = []
181
+ return self
182
+
183
+ def __exit__(self, *exc):
184
+ for ptr in self.owned:
185
+ _release(ptr)
186
+ _release(self.uia)
187
+
188
+ def root(self, hwnd: int):
189
+ el = _out_ptr(self.uia, 6, hwnd, argtypes=(wt.HWND,)) # ElementFromHandle
190
+ self.owned.append(el)
191
+ return el
192
+
193
+ def descendants(self, el) -> list:
194
+ cond = _out_ptr(self.uia, 21) # CreateTrueCondition
195
+ self.owned.append(cond)
196
+ arr = _out_ptr(el, 6, TREESCOPE_DESCENDANTS, cond, argtypes=(ctypes.c_int, ctypes.c_void_p))
197
+ self.owned.append(arr)
198
+ if not arr.value:
199
+ return []
200
+ out = []
201
+ for i in range(_int(arr, 3)): # get_Length
202
+ item = _out_ptr(arr, 4, i, argtypes=(ctypes.c_int,)) # GetElement
203
+ self.owned.append(item)
204
+ out.append(item)
205
+ return out
206
+
207
+ def _element(el) -> Element:
208
+ kind = CONTROL.get(_int(el, 21), "other")
209
+ e = Element(kind=kind, name=_bstr(el, 23), automation_id=_bstr(el, 29), cls=_bstr(el, 30),
210
+ enabled=bool(_int(el, 28)), rect=_rect(el))
211
+ if kind == "checkbox":
212
+ try:
213
+ toggle = _pattern(el, TOGGLE)
214
+ if toggle.value:
215
+ try:
216
+ e.checked = _int(toggle, 4) == TOGGLE_ON # get_CurrentToggleState
217
+ finally:
218
+ _release(toggle)
219
+ except OSError:
220
+ pass # no Toggle pattern on this control: its state stays unknown (None)
221
+ if kind == "edit":
222
+ try:
223
+ value = _pattern(el, VALUE)
224
+ if value.value:
225
+ try:
226
+ e.value = _bstr(value, 4) # get_CurrentValue
227
+ finally:
228
+ _release(value)
229
+ except OSError:
230
+ pass # no Value pattern on this control: its text stays unknown (None)
231
+ return e
232
+
233
+ def elements(hwnd: int) -> list[Element]:
234
+ """Every element below a top-level window, in UI Automation's order."""
235
+ with _Session() as s:
236
+ return [_element(el) for el in s.descendants(s.root(hwnd))]
237
+
238
+ def describe(hwnd: int) -> Dialog:
239
+ """Read a dialog the way a person would: title, sentences, buttons, check boxes, fields."""
240
+ with _Session() as s:
241
+ root = s.root(hwnd)
242
+ return summarise(_bstr(root, 23), _bstr(root, 40), [_element(el) for el in s.descendants(root)])
243
+
244
+ def _find(s: _Session, hwnd: int, kind: str, name: str | None, index: int = 0):
245
+ wanted = caption(name or "")
246
+ hits = []
247
+ for el in s.descendants(s.root(hwnd)):
248
+ if CONTROL.get(_int(el, 21)) != kind:
249
+ continue
250
+ if name is None or caption(_bstr(el, 23)) == wanted:
251
+ hits.append(el)
252
+ return hits[index] if len(hits) > index else None
253
+
254
+ def press(hwnd: int, label: str) -> bool:
255
+ """Press the button named `label` (compared with `caption()`). Invoke pattern first; the
256
+ legacy default action for owner-drawn buttons."""
257
+ with _Session() as s:
258
+ el = _find(s, hwnd, "button", label)
259
+ if el is None:
260
+ return False
261
+ for pattern, method in ((INVOKE, 3), (LEGACY, 4)): # Invoke / DoDefaultAction
262
+ try:
263
+ p = _pattern(el, pattern)
264
+ except OSError:
265
+ continue
266
+ if p.value:
267
+ try:
268
+ _call(p, method)()
269
+ return True
270
+ finally:
271
+ _release(p)
272
+ return False
273
+
274
+ def set_checked(hwnd: int, name: str, checked: bool) -> bool | None:
275
+ """Set a check box by name; returns its final state, None if it was not found."""
276
+ with _Session() as s:
277
+ el = _find(s, hwnd, "checkbox", name)
278
+ if el is None:
279
+ return None
280
+ p = _pattern(el, TOGGLE)
281
+ if not p.value:
282
+ return None
283
+ try:
284
+ for _ in range(3):
285
+ if (_int(p, 4) == TOGGLE_ON) == checked:
286
+ return checked
287
+ _call(p, 3)() # Toggle
288
+ return (_int(p, 4) == TOGGLE_ON)
289
+ finally:
290
+ _release(p)
291
+
292
+ else: # pragma: no cover - the adapter refuses non-Windows platforms before reaching here
293
+
294
+ def elements(hwnd: int) -> list[Element]:
295
+ raise OSError("UI Automation needs Windows")
296
+
297
+ def describe(hwnd: int) -> Dialog:
298
+ raise OSError("UI Automation needs Windows")
299
+
300
+ def press(hwnd: int, label: str) -> bool:
301
+ raise OSError("UI Automation needs Windows")
302
+
303
+ def set_checked(hwnd: int, name: str, checked: bool) -> bool | None:
304
+ raise OSError("UI Automation needs Windows")