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,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")
|