ecadlib 0.3.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.
- ecadlib/__init__.py +3 -0
- ecadlib/__main__.py +5 -0
- ecadlib/a365_sync.py +637 -0
- ecadlib/agent.py +1407 -0
- ecadlib/agent_queue.py +780 -0
- ecadlib/agent_watch.py +732 -0
- ecadlib/altium365.py +385 -0
- ecadlib/altium_dblib_sync.py +530 -0
- ecadlib/altium_export.py +209 -0
- ecadlib/altium_forms.py +768 -0
- ecadlib/altium_lib.py +1089 -0
- ecadlib/altium_menu.py +1751 -0
- ecadlib/altium_pkg.py +396 -0
- ecadlib/altium_project.py +1095 -0
- ecadlib/app.py +1458 -0
- ecadlib/approvals.py +395 -0
- ecadlib/assets/demo_bom.csv +8 -0
- ecadlib/assets/kicad_action_plugin.py +607 -0
- ecadlib/assets/kicad_reread.py +60 -0
- ecadlib/assets/pcblib_template.json.gz +0 -0
- ecadlib/bom_inputs.py +339 -0
- ecadlib/bom_triage.py +519 -0
- ecadlib/cad_model.py +528 -0
- ecadlib/cfb.py +171 -0
- ecadlib/cli.py +185 -0
- ecadlib/clihelp.py +149 -0
- ecadlib/cliutil.py +119 -0
- ecadlib/completion.py +173 -0
- ecadlib/costs.py +276 -0
- ecadlib/crosscheck.py +412 -0
- ecadlib/datasheet.py +2168 -0
- ecadlib/dedupe.py +572 -0
- ecadlib/detect.py +170 -0
- ecadlib/doctor.py +450 -0
- ecadlib/ds_route.py +541 -0
- ecadlib/eagle_export.py +1409 -0
- ecadlib/easy_setup.py +338 -0
- ecadlib/easyeda_convert.py +386 -0
- ecadlib/easyeda_pro_export.py +972 -0
- ecadlib/eval/__init__.py +0 -0
- ecadlib/eval/baselines/kicad-v1.json +404 -0
- ecadlib/eval/build_set.py +299 -0
- ecadlib/eval/cli.py +119 -0
- ecadlib/eval/gate.py +358 -0
- ecadlib/eval/golden/demo.txt +379 -0
- ecadlib/eval/golden/specs.txt +3120 -0
- ecadlib/eval/golden/testpack.txt +463 -0
- ecadlib/eval/golden.py +446 -0
- ecadlib/eval/replay/kicad-v1.json.gz +0 -0
- ecadlib/eval/runner.py +456 -0
- ecadlib/eval/score.py +179 -0
- ecadlib/eval/sets/kicad-v1.json +630 -0
- ecadlib/firstrun.py +400 -0
- ecadlib/footprint_adv.py +1006 -0
- ecadlib/footprint_bench.py +218 -0
- ecadlib/footprint_check.py +562 -0
- ecadlib/footprint_gen.py +582 -0
- ecadlib/footprint_rules.py +1092 -0
- ecadlib/forge.py +267 -0
- ecadlib/fp_share.py +95 -0
- ecadlib/fp_tidy.py +389 -0
- ecadlib/fsutil.py +62 -0
- ecadlib/house_style.py +1051 -0
- ecadlib/httpcache.py +124 -0
- ecadlib/hub.py +1490 -0
- ecadlib/hub_copy.py +161 -0
- ecadlib/hub_tools.py +79 -0
- ecadlib/kicad_dblib.py +257 -0
- ecadlib/kicad_export.py +837 -0
- ecadlib/kicad_import.py +913 -0
- ecadlib/kicad_index.py +160 -0
- ecadlib/kicad_menu.py +193 -0
- ecadlib/kicad_pcm.py +475 -0
- ecadlib/launcher.py +156 -0
- ecadlib/librarian.py +963 -0
- ecadlib/lifecycle.py +691 -0
- ecadlib/llm/__init__.py +276 -0
- ecadlib/llm/cli.py +70 -0
- ecadlib/llm/openai_compat.py +214 -0
- ecadlib/llm/profiles.py +245 -0
- ecadlib/llm/rails.py +570 -0
- ecadlib/llm/run.py +126 -0
- ecadlib/llm/sampling.py +49 -0
- ecadlib/mcp_server.py +723 -0
- ecadlib/messages.py +419 -0
- ecadlib/migrate.py +407 -0
- ecadlib/notify.py +329 -0
- ecadlib/package_schema.json +1238 -0
- ecadlib/package_specs/CP_EIA-3216-18_Kemet-A.json +27 -0
- ecadlib/package_specs/C_0402_1005Metric.json +22 -0
- ecadlib/package_specs/C_0805_2012Metric.json +22 -0
- ecadlib/package_specs/DFN-8-1EP_3x3mm_P0.5mm_EP1.66x2.38mm.json +40 -0
- ecadlib/package_specs/D_SMA.json +27 -0
- ecadlib/package_specs/D_SMB.json +27 -0
- ecadlib/package_specs/D_SOD-123.json +43 -0
- ecadlib/package_specs/D_SOD-323.json +43 -0
- ecadlib/package_specs/LED_0603_1608Metric.json +23 -0
- ecadlib/package_specs/LED_0805_2012Metric.json +23 -0
- ecadlib/package_specs/LQFP-48_7x7mm_P0.5mm.json +31 -0
- ecadlib/package_specs/L_0805_2012Metric.json +22 -0
- ecadlib/package_specs/MSOP-8_3x3mm_P0.65mm.json +31 -0
- ecadlib/package_specs/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm.json +39 -0
- ecadlib/package_specs/QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm.json +37 -0
- ecadlib/package_specs/R_0402_1005Metric.json +22 -0
- ecadlib/package_specs/R_0603_1608Metric.json +22 -0
- ecadlib/package_specs/R_0805_2012Metric.json +22 -0
- ecadlib/package_specs/R_1206_3216Metric.json +22 -0
- ecadlib/package_specs/SOIC-16_3.9x9.9mm_P1.27mm.json +32 -0
- ecadlib/package_specs/SOIC-8_3.9x4.9mm_P1.27mm.json +32 -0
- ecadlib/package_specs/SOT-223-3_TabPin2.json +56 -0
- ecadlib/package_specs/SOT-23-5.json +57 -0
- ecadlib/package_specs/SOT-23-6.json +62 -0
- ecadlib/package_specs/SOT-23.json +48 -0
- ecadlib/package_specs/TSSOP-16_4.4x5mm_P0.65mm.json +31 -0
- ecadlib/package_specs/VQFN-16-1EP_3x3mm_P0.5mm_EP1.68x1.68mm.json +39 -0
- ecadlib/package_specs/advanced/BGA-100_11.0x11.0mm_Layout10x10_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-121_9.0x9.0mm_Layout11x11_P0.8mm_Ball0.4mm_Pad0.35mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-144_7.0x7.0mm_Layout13x13_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +29 -0
- ecadlib/package_specs/advanced/BGA-169_11.0x11.0mm_Layout13x13_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +23 -0
- ecadlib/package_specs/advanced/BGA-256_11.0x11.0mm_Layout20x20_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-256_17.0x17.0mm_Layout16x16_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-324_15.0x15.0mm_Layout18x18_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-36_3.396x3.466mm_Layout6x6_P0.4mm_Ball0.25mm_Pad0.2mm_NSMD.json +24 -0
- ecadlib/package_specs/advanced/BGA-672_27.0x27.0mm_Layout26x26_P1.0mm_Ball0.6mm_Pad0.5mm_NSMD.json +32 -0
- ecadlib/package_specs/advanced/BGA-68_5.0x5.0mm_Layout9x9_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +30 -0
- ecadlib/package_specs/advanced/BGA-90_8.0x13.0mm_Layout2x3x15_P0.8mm.json +26 -0
- ecadlib/package_specs/advanced/DFN-8-1EP_3x3mm_P0.5mm_EP1.65x2.38mm_ThermalVias.json +60 -0
- ecadlib/package_specs/advanced/Example_Custom_LPad_Slot.json +24 -0
- ecadlib/package_specs/advanced/JST_GH_SM04B-GHS-TB_1x04-1MP_P1.25mm_Horizontal.json +49 -0
- ecadlib/package_specs/advanced/JST_PH_B4B-PH-K_1x04_P2.00mm_Vertical.json +33 -0
- ecadlib/package_specs/advanced/JST_PH_S4B-PH-SM4-TB_1x04-1MP_P2.00mm_Horizontal.json +55 -0
- ecadlib/package_specs/advanced/JST_SH_SM04B-SRSS-TB_1x04-1MP_P1.00mm_Horizontal.json +49 -0
- ecadlib/package_specs/advanced/JST_XH_B2B-XH-A_1x02_P2.50mm_Vertical.json +34 -0
- ecadlib/package_specs/advanced/JST_XH_B4B-XH-A_1x04_P2.50mm_Vertical.json +34 -0
- ecadlib/package_specs/advanced/JST_XH_B6B-XH-A_1x06_P2.50mm_Vertical.json +33 -0
- ecadlib/package_specs/advanced/LFBGA-144_10x10mm_Layout12x12_P0.8mm.json +23 -0
- ecadlib/package_specs/advanced/Molex_KK-254_AE-6410-04A_1x04_P2.54mm_Vertical.json +34 -0
- ecadlib/package_specs/advanced/Molex_PicoBlade_53398-0471_1x04-1MP_P1.25mm_Vertical.json +54 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P1.00mm_Vertical_SMD_Pin1Left.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P1.27mm_Vertical_SMD_Pin1Left.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical_SMD_Pin1Left.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Left.json +25 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Right.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x10_P1.00mm_Vertical.json +15 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P1.00mm_Vertical_SMD.json +13 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical.json +18 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical_SMD.json +24 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P2.00mm_Vertical_SMD.json +13 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical_SMD.json +24 -0
- ecadlib/package_specs/advanced/PinHeader_2x20_P2.54mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm_ThermalVias.json +87 -0
- ecadlib/package_specs/advanced/UFBGA-132_7x7mm_Layout12x12_P0.5mm.json +43 -0
- ecadlib/package_specs/advanced/USB_C_Receptacle_HRO_TYPE-C-31-M-12.json +218 -0
- ecadlib/package_specs/standard.json.gz +0 -0
- ecadlib/parity.py +1204 -0
- ecadlib/parts_export.py +154 -0
- ecadlib/partview.py +215 -0
- ecadlib/plugins.py +231 -0
- ecadlib/providers.py +226 -0
- ecadlib/rebuild.py +471 -0
- ecadlib/release_check.py +437 -0
- ecadlib/reports.py +274 -0
- ecadlib/resolve.py +556 -0
- ecadlib/review_cli.py +292 -0
- ecadlib/rules.py +979 -0
- ecadlib/rules_infer.py +1205 -0
- ecadlib/sexpr.py +149 -0
- ecadlib/sheetsafe.py +65 -0
- ecadlib/sim_models.py +1087 -0
- ecadlib/sourcing.py +1161 -0
- ecadlib/stdparts.py +755 -0
- ecadlib/step_gen.py +929 -0
- ecadlib/symbol_layout.py +798 -0
- ecadlib/team.py +558 -0
- ecadlib/team_setup.py +288 -0
- ecadlib/term.py +420 -0
- ecadlib/term_views.py +64 -0
- ecadlib/testpack.py +334 -0
- ecadlib/ui/__init__.py +1 -0
- ecadlib/ui/__main__.py +3 -0
- ecadlib/ui/server.py +1029 -0
- ecadlib/ui/static/hairline.js +2 -0
- ecadlib/ui/static/index.html +2098 -0
- ecadlib/ui/static/setup.html +294 -0
- ecadlib/upkeep.py +717 -0
- ecadlib/upstream.py +573 -0
- ecadlib/userconfig.py +257 -0
- ecadlib/visual_check.py +1603 -0
- ecadlib/ws_status.py +203 -0
- ecadlib-0.3.0.dist-info/METADATA +2245 -0
- ecadlib-0.3.0.dist-info/RECORD +199 -0
- ecadlib-0.3.0.dist-info/WHEEL +5 -0
- ecadlib-0.3.0.dist-info/entry_points.txt +2 -0
- ecadlib-0.3.0.dist-info/licenses/LICENSE +21 -0
- ecadlib-0.3.0.dist-info/licenses/NOTICE +53 -0
- ecadlib-0.3.0.dist-info/top_level.txt +1 -0
ecadlib/altium_lib.py
ADDED
|
@@ -0,0 +1,1089 @@
|
|
|
1
|
+
"""Altium .PcbLib (footprints) and .SchLib (symbols) writer, pure Python (+ a minimal reader for checks).
|
|
2
|
+
|
|
3
|
+
python3 -m ecadlib.altium_lib pcblib ecadlib/package_specs/*.json -o out/altium_models/PcbLib
|
|
4
|
+
python3 -m ecadlib.altium_lib schlib examples/symbols/*.json -o out/altium_models/SchLib
|
|
5
|
+
python3 -m ecadlib.altium_lib dump out/altium_models/PcbLib/SOIC-8_3.9x4.9mm_P1.27mm.PcbLib
|
|
6
|
+
|
|
7
|
+
One footprint per .PcbLib / one symbol per .SchLib by default (what altium365.py uploads), but
|
|
8
|
+
write_pcblib/write_schlib take lists. Files are OLE2 compound files written by ecadlib.cfb.
|
|
9
|
+
|
|
10
|
+
Portions are ported from AltiumSharp (github.com/issus/AltiumSharp, Apache-2.0, Copyright the
|
|
11
|
+
AltiumSharp authors): the PcbLib/SchLib stream layout (PcbLibWriter.cs, SchLibWriter.cs), the
|
|
12
|
+
primitive record layouts (13-byte common header, 49-byte track, 50-byte fill, region, 202-byte pad
|
|
13
|
+
main block with its 141-byte extended-tail template, 596-byte pad size/shape block, the SchLib pin
|
|
14
|
+
record) and v7 layer ids. ecadlib/assets/pcblib_template.json.gz holds the Library/Data board
|
|
15
|
+
header and FileVersionInfo of an Altium-saved PcbLib from AltiumSharp's TestData (see NOTICE).
|
|
16
|
+
Field values were cross-checked against real Altium files (README "Altium library writer").
|
|
17
|
+
|
|
18
|
+
Also from AltiumSharp: through-hole pad holes (size, round/square/slot, slot length, hole rotation, plated),
|
|
19
|
+
the 60-byte arc, the ComponentBody record (extruded or embedded STEP) and the Library/Models storage with
|
|
20
|
+
Altium's STEP checksum, the 252-byte stroke text record (.Designator / .Comment) with its WideStrings entry,
|
|
21
|
+
SchLib pin symbols (dot, clock, active-low ...) and display modes (De Morgan). Each was checked byte for byte
|
|
22
|
+
against Altium-saved records (AltiumSharp TestData; display modes against an Altium 24 sample library).
|
|
23
|
+
|
|
24
|
+
Conventions (mirroring Altium-made libraries in AltiumSharp's TestData):
|
|
25
|
+
Top Layer (1) SMD pads, Multi-Layer (74) through-hole pads; 3D bodies on Mechanical 13;
|
|
26
|
+
Top Overlay (33) silkscreen; Mechanical 13 (69) fab/body outline;
|
|
27
|
+
Mechanical 15 (71) courtyard; EP paste windows as fills on Top Paste (35).
|
|
28
|
+
Units: 1 mil = 10000 internal units, so 1 mm = 10^8/254 units (rounded to the nearest unit,
|
|
29
|
+
2.54 nm). KiCad's Y axis points down, Altium's up: y is negated on import.
|
|
30
|
+
Schematic units: 1 = 10 mil (DXP); pins on a 100 mil (10 unit) grid."""
|
|
31
|
+
import argparse, functools, gzip, hashlib, json, math, re, struct, sys, time, uuid, zlib
|
|
32
|
+
from fractions import Fraction
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
from . import cfb, sexpr
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------- units & low-level encoders
|
|
38
|
+
TOP, MID1, BOTTOM, TOP_OVERLAY, BOTTOM_OVERLAY, TOP_PASTE, TOP_SOLDER, MECH13, MECH15, MULTI = 1, 2, 32, 33, 34, 35, 37, 69, 71, 74
|
|
39
|
+
BOTTOM_PASTE, BOTTOM_SOLDER = 36, 38
|
|
40
|
+
KEEPOUT = 56
|
|
41
|
+
MECH16 = 72
|
|
42
|
+
KICAD_LAYER = {"F.SilkS": TOP_OVERLAY, "F.Fab": MECH13, "F.CrtYd": MECH15}
|
|
43
|
+
LAYER_NAME = {TOP: "TOP", BOTTOM: "BOTTOM", TOP_OVERLAY: "TOPOVERLAY", BOTTOM_OVERLAY: "BOTTOMOVERLAY", TOP_PASTE: "TOPPASTE",
|
|
44
|
+
BOTTOM_PASTE: "BOTTOMPASTE", TOP_SOLDER: "TOPSOLDER", BOTTOM_SOLDER: "BOTTOMSOLDER", MECH13: "MECHANICAL13", MECH15: "MECHANICAL15", KEEPOUT: "KEEPOUT"}
|
|
45
|
+
SHAPE = {"round": 1, "circle": 1, "oval": 1, "rect": 2, "octagonal": 3, "roundrect": 9}
|
|
46
|
+
HOLE = {"round": 0, "square": 1, "slot": 2}
|
|
47
|
+
FLAGS = 0x0C # bit3 "saved" (always set) | bit2 unlocked
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def mm(v):
|
|
51
|
+
"""mm -> internal units (exact rational conversion, rounded to the nearest unit)."""
|
|
52
|
+
return round(Fraction(str(v)) * 100_000_000 / 254)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def to_mm(u):
|
|
56
|
+
return u * 254 / 100_000_000
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def v7_layer(layer):
|
|
60
|
+
"""Redundant 'v7 saved layer id' Altium stores in primitive tails (AltiumSharp V7LayerId)."""
|
|
61
|
+
if layer == 32:
|
|
62
|
+
return 0x0100FFFF
|
|
63
|
+
if 1 <= layer <= 31:
|
|
64
|
+
return 0x01000000 + layer
|
|
65
|
+
if 39 <= layer <= 54:
|
|
66
|
+
return 0x01010000 + layer - 38
|
|
67
|
+
if 57 <= layer <= 72:
|
|
68
|
+
return 0x01020000 + layer - 56
|
|
69
|
+
return 0x01030000 + {33: 6, 34: 7, 35: 8, 36: 9, 37: 10, 38: 11, 55: 12, 56: 13, 73: 14}.get(layer, 15)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def enc(s):
|
|
73
|
+
return str(s).encode("cp1252", "replace")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def block(b, flag=0):
|
|
77
|
+
return struct.pack("<I", (flag << 24) | len(b)) + b
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def sblock(s): # [u32 len][u8 len][bytes] (Pascal string block)
|
|
81
|
+
b = enc(s)[:255]
|
|
82
|
+
return block(bytes([len(b)]) + b)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def pblock(text, utf8=False): # [u32 len][cp1252 text + NUL] (C-string parameter block)
|
|
86
|
+
"""utf8: a |KEY=VALUE| whose value is not plain ASCII also gets Altium's '|%UTF8%KEY=<utf-8>||' twin in front, as
|
|
87
|
+
Altium writes it for every non-ASCII value ('80µA', '90°' as well as CJK): the plain cp1252 copy turns 'Ω' into '?'
|
|
88
|
+
and is read in the reader's ANSI code page, the twin is what Altium reads back."""
|
|
89
|
+
if not utf8:
|
|
90
|
+
return block(enc(text) + b"\0")
|
|
91
|
+
out = b""
|
|
92
|
+
for seg in text.split("|")[1:] if text.startswith("|") else [text]:
|
|
93
|
+
k, eq, v = seg.partition("=")
|
|
94
|
+
if eq and not v.isascii():
|
|
95
|
+
out += b"|%UTF8%" + k.encode("utf-8") + b"=" + v.encode("utf-8") + b"||"
|
|
96
|
+
out += (b"|" if text.startswith("|") else b"") + enc(seg)
|
|
97
|
+
return block(out + b"\0")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def schblock(text): # SchLib parameter block: non-ASCII values get a %UTF8% twin
|
|
101
|
+
return pblock(text, utf8=True)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def params(pairs):
|
|
105
|
+
for k, v in pairs:
|
|
106
|
+
if "|" in str(v) or "\0" in str(v):
|
|
107
|
+
raise ValueError(f"'|' or NUL in parameter {k}={v!r}")
|
|
108
|
+
return "".join(f"|{k}={v}" for k, v in pairs)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def clean(s):
|
|
112
|
+
"""Text going into a |KEY=VALUE| block may not contain '|' or line breaks (TOC streams are line based)."""
|
|
113
|
+
return re.sub(r"\s+", " ", re.sub(r"\s*\|\s*", "; ", str(s))).strip()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def lib_name(s):
|
|
117
|
+
"""Library entry name as Altium stores it (cp1252 in the binary records). 'Ω' becomes 'Ohm';
|
|
118
|
+
anything else cp1252 can't hold is an error rather than a silent '?' that makes the entry unreadable."""
|
|
119
|
+
s = str(s).replace("\u2126", "Ohm").replace("\u03a9", "Ohm")
|
|
120
|
+
try:
|
|
121
|
+
s.encode("cp1252")
|
|
122
|
+
except UnicodeEncodeError as e:
|
|
123
|
+
raise ValueError(f"name {s!r} has characters Altium libraries can't store ({s[e.start:e.end]!r}); rename it") from None
|
|
124
|
+
return s
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def file_stem(s):
|
|
128
|
+
"""Filename for one-entry-per-file libraries: path separators and Windows-illegal characters -> '_'."""
|
|
129
|
+
return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", lib_name(s)).rstrip(". ") or "_"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def section_key(name):
|
|
133
|
+
"""OLE storage name for a library entry: <=31 chars, no OLE/Windows-illegal characters."""
|
|
134
|
+
return re.sub(r'[<>:"/\\|?*!\x00]', "_", name)[:31] or "_"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
PCBLIB_RESERVED = {"FILEHEADER", "SECTIONKEYS", "LIBRARY", "FILEVERSIONINFO"}
|
|
138
|
+
SCHLIB_RESERVED = {"FILEHEADER", "SECTIONKEYS", "STORAGE"}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _section_keys(names, reserved, what):
|
|
142
|
+
"""Storage names for a library's entries: section_key(name), except that a name whose key an earlier entry already
|
|
143
|
+
has (two names sharing their first 31 characters, 'PinHeader_1x05_P2.54mm_Vertical' + '..._SMD_Pin1Left') gets
|
|
144
|
+
the key cut shorter and ending in '~1', '~2' ...; the SectionKeys stream maps every such name to its key."""
|
|
145
|
+
upper = [n.upper() for n in names]
|
|
146
|
+
dup = sorted({n for n, u in zip(names, upper) if upper.count(u) > 1})
|
|
147
|
+
if dup:
|
|
148
|
+
raise ValueError(f"{what} names occur more than once (names are not case-sensitive): {dup}")
|
|
149
|
+
bad = [n for n in names if section_key(n).upper() in reserved]
|
|
150
|
+
if bad:
|
|
151
|
+
raise ValueError(f"{what} name(s) {bad} clash with a reserved library stream/storage name")
|
|
152
|
+
keys, used = [], set()
|
|
153
|
+
for n in names:
|
|
154
|
+
k, i = section_key(n), 0
|
|
155
|
+
while k.upper() in used or k.upper() in reserved:
|
|
156
|
+
i += 1
|
|
157
|
+
k = section_key(n)[:31 - len(f"~{i}")] + f"~{i}"
|
|
158
|
+
used.add(k.upper()); keys.append(k)
|
|
159
|
+
return keys
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def unique_id(seed): # Altium's 8-letter UNIQUEID, deterministic per seed
|
|
163
|
+
h = hashlib.sha256(seed.encode()).digest()
|
|
164
|
+
return "".join(chr(65 + b % 26) for b in h[:8])
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def guid(seed):
|
|
168
|
+
return uuid.uuid5(uuid.NAMESPACE_URL, "ecadlib:" + seed)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _now():
|
|
172
|
+
import os
|
|
173
|
+
t = time.localtime(int(os.environ.get("SOURCE_DATE_EPOCH", time.time())))
|
|
174
|
+
return time.strftime("%d/%m/%Y", t), time.strftime("%H:%M:%S", t)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ---------------------------------------------------------------- PCB primitive records
|
|
178
|
+
def common(layer, polygon=0xFFFF, flags=FLAGS):
|
|
179
|
+
"""13-byte header: layer, flags, net, polygon, component, reserved."""
|
|
180
|
+
return struct.pack("<BHHHHI", layer, flags, 0xFFFF, polygon, 0xFFFF, 0xFFFFFFFF)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def track_rec(x1, y1, x2, y2, w, layer):
|
|
184
|
+
body = common(layer) + struct.pack("<5ihihIB3s", x1, y1, x2, y2, w, 0, 0, 0, v7_layer(layer), 0, b"")
|
|
185
|
+
return b"\x04" + block(body)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def fill_rec(x1, y1, x2, y2, layer, rot=0.0):
|
|
189
|
+
body = common(layer) + struct.pack("<4idiBIB3s", x1, y1, x2, y2, rot, 0, 0, v7_layer(layer), 0, b"")
|
|
190
|
+
return b"\x06" + block(body)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def v7_name(layer):
|
|
194
|
+
"""V7_LAYER name in region / body parameter text: TOP, MID1.., MECHANICAL1..16, TOPOVERLAY ..."""
|
|
195
|
+
if layer in LAYER_NAME:
|
|
196
|
+
return LAYER_NAME[layer]
|
|
197
|
+
if 57 <= layer <= 72:
|
|
198
|
+
return f"MECHANICAL{layer - 56}"
|
|
199
|
+
if 2 <= layer <= 31:
|
|
200
|
+
return f"MID{layer - 1}"
|
|
201
|
+
raise ValueError(f"layer {layer}: no Altium V7_LAYER name for it")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def region_rec(pts, layer, kind=0, keepout=None):
|
|
205
|
+
"""keepout: restriction bits (31 = all objects) for a keep-out region (flags bit 9, as in TestData KEEPOUT_REGION)."""
|
|
206
|
+
ptxt = (f"V7_LAYER={v7_name(layer)}|NAME= |KIND={kind}|SUBPOLYINDEX=-1|UNIONINDEX=0|ARCRESOLUTION=0.5mil"
|
|
207
|
+
"|ISSHAPEBASED=FALSE|CAVITYHEIGHT=0mil" + (f"|KEEPOUTRESTRICTIONS={keepout}" if keepout is not None else ""))
|
|
208
|
+
body = common(layer, flags=FLAGS | (0x200 if keepout is not None else 0)) + b"\0\0\0\0\0" + pblock(ptxt) + struct.pack("<I", len(pts))
|
|
209
|
+
body += b"".join(struct.pack("<2d", float(x), float(y)) for x, y in pts)
|
|
210
|
+
return b"\x0b" + block(body)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def arc_rec(cx, cy, r, a0, a1, w, layer):
|
|
214
|
+
"""Arc counter-clockwise from a0 to a1 (degrees); 0..360 is a full circle (AltiumSharp WriteArc)."""
|
|
215
|
+
body = common(layer) + struct.pack("<3i2di", cx, cy, r, float(a0), float(a1), w)
|
|
216
|
+
body += struct.pack("<hiBIB3s", 0, 0, 0, v7_layer(layer), 0, b"")
|
|
217
|
+
return b"\x01" + block(body)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# Cached text extents (Default stroke font) Altium stored for these special strings at 50 mil height
|
|
221
|
+
# (TestData TEXT_DESIGNATOR / TEXT_COMMENT); other strings get an estimate. Altium recomputes them.
|
|
222
|
+
TEXT_BOX = {".Designator": (5098381, 766451), ".Comment": (3682173, 599838)}
|
|
223
|
+
CHAR_W = 0.927 # stroke-font advance per character / height (same records)
|
|
224
|
+
ARIAL = "Arial".encode("utf-16-le").ljust(64, b"\0")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def text_rec(text, x, y, h, sw, layer, rot=0.0, ws=0, font=1, mirror=False, box=None):
|
|
228
|
+
"""Stroke-font text (record 5, AltiumSharp WriteText): 252-byte block, then the text as a string block.
|
|
229
|
+
Anchored at its bottom-left corner (justification 3, as Altium writes it); ws = WideStrings index."""
|
|
230
|
+
bw, bh = box or [round(v * h / 500000) for v in TEXT_BOX.get(text, (round(CHAR_W * 500000 * len(text)), 600000))]
|
|
231
|
+
b = common(layer) + struct.pack("<3ihd?i6x", x, y, h, font, rot, mirror, sw) + ARIAL
|
|
232
|
+
b += struct.pack("<?3i?2iBi5i4B", 0, 0, ws, 0, 0, bw, bh, 3, 0, 10500000, 2100000, 200000, 200000, 0, 0, 1, 1, 0) + ARIAL
|
|
233
|
+
b += struct.pack("<?I2B2i4x2i", 1, v7_layer(layer), 0, 0, -1 << 31, -1 << 31, x, y)
|
|
234
|
+
assert len(b) == 252
|
|
235
|
+
return b"\x05" + block(b) + sblock(text)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
KTEXT = {"reference": (".Designator", TOP_OVERLAY, 3), "value": (".Comment", MECH13, 8)} # + nominal characters shown
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def texts_from_kicad(t):
|
|
242
|
+
"""KiCad Reference / Value (fp_text or KiCad 8+ property) -> [(text, x, y, height, stroke, rot, layer)] in mm, y up:
|
|
243
|
+
.Designator on Top Overlay when KiCad has the reference on silk (F.SilkS / B.SilkS), else on Mechanical 13 with the
|
|
244
|
+
rest of the fab drawing (a Fab reference is assembly text, not silk), .Comment on Mechanical 13. KiCad centres text on (at); Altium anchors stroke text at
|
|
245
|
+
the bottom-left, so the anchor is moved left by half the estimated width (all of it for right-justified text) of a
|
|
246
|
+
nominal designator (3 characters, "U12") or comment (8 characters) and down by half the height, rotated with the text."""
|
|
247
|
+
out = []
|
|
248
|
+
for n in list(sexpr.find(t, "fp_text")) + [p for p in sexpr.find(t, "property") if p[1] in ("Reference", "Value")]:
|
|
249
|
+
kind = n[1].lower()
|
|
250
|
+
if kind not in KTEXT or not sexpr.first(n, "at"):
|
|
251
|
+
continue
|
|
252
|
+
at, eff = sexpr.first(n, "at"), sexpr.first(n, "effects") or []
|
|
253
|
+
font, just = sexpr.first(eff, "font") or [], sexpr.first(eff, "justify") or []
|
|
254
|
+
size, th = sexpr.first(font, "size"), sexpr.first(font, "thickness")
|
|
255
|
+
h = float(size[2]) if size else 1.0
|
|
256
|
+
rot = float(at[3]) % 360 if len(at) > 3 and at[3] != "unlocked" else 0.0 # KiCad 6/7: (at x y [rot] unlocked)
|
|
257
|
+
rot = rot - 180 if 90 < rot <= 270 else rot # KiCad keeps footprint text upright; Altium doesn't
|
|
258
|
+
txt, layer, chars = KTEXT[kind]
|
|
259
|
+
src = (sexpr.first(n, "layer") or [None, ""])[1]
|
|
260
|
+
if kind == "reference" and "SilkS" not in str(src):
|
|
261
|
+
layer = MECH13
|
|
262
|
+
a, dx, dy = math.radians(rot), -CHAR_W * h * chars * (0 if "left" in just else 1 if "right" in just else 0.5), -h / 2
|
|
263
|
+
x, y = float(at[1]) + dx * math.cos(a) - dy * math.sin(a), -float(at[2]) + dx * math.sin(a) + dy * math.cos(a)
|
|
264
|
+
out.append((txt, round(x, 4), round(y, 4), h, float(th[1]) if th else round(0.15 * h, 3), rot, layer))
|
|
265
|
+
return out
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def body_params(layer, height, model_id, checksum, standoff=0, step=None):
|
|
269
|
+
"""ComponentBody parameter text in Altium's key order (AltiumSharp BuildComponentBodyParamText).
|
|
270
|
+
step=None: extruded body from standoff to height; else (name, rotx, roty, rotz, dz) of an embedded STEP model."""
|
|
271
|
+
mil = lambda u: f"{to_mil(u)}mil"
|
|
272
|
+
kv = [("V7_LAYER", v7_name(layer)), ("NAME", " "), ("KIND", 0), ("SUBPOLYINDEX", -1), ("UNIONINDEX", 0),
|
|
273
|
+
("ARCRESOLUTION", "0.5mil"), ("ISSHAPEBASED", "FALSE"), ("CAVITYHEIGHT", "0mil"), ("STANDOFFHEIGHT", mil(standoff)),
|
|
274
|
+
("OVERALLHEIGHT", mil(height)), ("BODYPROJECTION", 0), ("ARCRESOLUTION", "0.5mil"), ("BODYCOLOR3D", 8421504),
|
|
275
|
+
("BODYOPACITY3D", "1.000"), ("IDENTIFIER", ""), ("TEXTURE", ""), ("TEXTURECENTERX", "0mil"), ("TEXTURECENTERY", "0mil"),
|
|
276
|
+
("TEXTURESIZEX", "0mil"), ("TEXTURESIZEY", "0mil"), ("TEXTUREROTATION", " 0.00000000000000E+0000"),
|
|
277
|
+
("MODELID", model_id), ("MODEL.CHECKSUM", checksum), ("MODEL.EMBED", "TRUE" if step else "FALSE"),
|
|
278
|
+
("MODEL.NAME", step[0] if step else ""), ("MODEL.2D.X", "0mil"), ("MODEL.2D.Y", "0mil"), ("MODEL.2D.ROTATION", "0.000")]
|
|
279
|
+
if step:
|
|
280
|
+
kv += [("MODEL.3D.ROTX", f"{step[1]:.3f}"), ("MODEL.3D.ROTY", f"{step[2]:.3f}"), ("MODEL.3D.ROTZ", f"{step[3]:.3f}"),
|
|
281
|
+
("MODEL.3D.DZ", mil(step[4])), ("MODEL.MODELTYPE", 1), ("MODEL.MODELSOURCE", "Undefined")]
|
|
282
|
+
else:
|
|
283
|
+
kv += [("MODEL.3D.ROTX", "0.000"), ("MODEL.3D.ROTY", "0.000"), ("MODEL.3D.ROTZ", "0.000"), ("MODEL.3D.DZ", "0mil"),
|
|
284
|
+
("MODEL.MODELTYPE", 0), ("MODEL.EXTRUDED.MINZ", mil(standoff)), ("MODEL.EXTRUDED.MAXZ", mil(height))]
|
|
285
|
+
return params(kv)[1:] # no leading '|'
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def body_rec(pts, ptxt, layer=MECH13):
|
|
289
|
+
"""ComponentBody (record 12): common header, 5 reserved bytes, parameter block, outline as doubles."""
|
|
290
|
+
body = common(layer) + b"\0" * 5 + pblock(ptxt) + struct.pack("<I", len(pts))
|
|
291
|
+
body += b"".join(struct.pack("<2d", float(x), float(y)) for x, y in pts)
|
|
292
|
+
return b"\x0c" + block(body)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def step_checksum(data):
|
|
296
|
+
"""Altium's 3D model checksum: position-weighted byte sum (AltiumSharp PcbModel.ComputeChecksum), as signed int32."""
|
|
297
|
+
c = sum(b * (i or 1) for i, b in enumerate(data)) & 0xFFFFFFFF
|
|
298
|
+
return c - (1 << 32) if c >= 1 << 31 else c
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# Canonical pad SubRecord-5 tail, offsets 61..201 (AltiumSharp PcbLibWriter.PadExtendedTailTemplate,
|
|
302
|
+
# itself captured from an Altium pad). Thermal relief 10/10 mil x4, plane relief/clearance 20 mil,
|
|
303
|
+
# mask modes "from rule" (101/102 = 1), hole tolerances unset (0x7FFFFFFF).
|
|
304
|
+
PAD_TAIL = bytes.fromhex(
|
|
305
|
+
"00000000000000a08601000400a0860100400d0300400d030000000000409c00"
|
|
306
|
+
"0000000000000000010100000000000000000000000f000301000000409c0000"
|
|
307
|
+
"00649a922610c7e441a32b2917a5352e677fab2120c30b3247adce6cb7b8c97e"
|
|
308
|
+
"6800000000ffffff7fffffff7f00011a00000000000000000000000101000000"
|
|
309
|
+
"00000000000000000000000000")
|
|
310
|
+
assert len(PAD_TAIL) == 141
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def pad_rec(p, guid_a, guid_b):
|
|
314
|
+
"""p: designator, x, y, w, h (internal units), shape ('rect'|'roundrect'|'round'), corner (%),
|
|
315
|
+
layer, paste / mask (None = from rule, else manual expansion in internal units).
|
|
316
|
+
Through-hole: layer MULTI, hole (diameter / slot width), hole_shape ('round'|'square'|'slot'),
|
|
317
|
+
slot (slot length), hole_rot (degrees), plated (default True)."""
|
|
318
|
+
shape = SHAPE[p["shape"]]
|
|
319
|
+
rr = shape == 9
|
|
320
|
+
base = 1 if rr else shape # Altium keeps 'round' in the main block for rounded-rect pads
|
|
321
|
+
hs = HOLE[p.get("hole_shape", "round")]
|
|
322
|
+
main = common(p.get("layer", TOP)) + struct.pack(
|
|
323
|
+
"<9i3Bd?", p["x"], p["y"], p["w"], p["h"], p["w"], p["h"], p["w"], p["h"], p.get("hole", 0), base, base, base,
|
|
324
|
+
float(p.get("rot", 0.0)), p.get("plated", True))
|
|
325
|
+
t = bytearray(PAD_TAIL)
|
|
326
|
+
put = lambda off, fmt, v: struct.pack_into(fmt, t, off - 61, v)
|
|
327
|
+
put(62, "<B", 0) # pad stack mode: simple
|
|
328
|
+
if p.get("paste") is not None: # manual paste-mask expansion
|
|
329
|
+
put(86, "<i", p["paste"]); put(101, "<B", 2)
|
|
330
|
+
if p.get("mask") is not None: # manual solder-mask expansion (KiCad's Altium importer / pyaltiumlib layout)
|
|
331
|
+
put(90, "<i", p["mask"]); put(102, "<B", 2)
|
|
332
|
+
put(114, "<I", v7_layer(p.get("layer", TOP)))
|
|
333
|
+
put(185, "<B", 1 if p.get("layer") == MULTI else 3) # reserved marker: 3 on SMD, 1 on multi-layer pads (TestData)
|
|
334
|
+
t[126 - 61:142 - 61], t[142 - 61:158 - 61] = guid_a.bytes_le, guid_b.bytes_le
|
|
335
|
+
out = sblock(p["designator"]) + sblock("") + sblock("|&|0") + block(b"\0") + block(main + bytes(t))
|
|
336
|
+
ox, oy = p.get("offset") or (0, 0) # copper offset from the hole centre (internal units, pad frame, y up)
|
|
337
|
+
if not rr and not hs and not (ox or oy):
|
|
338
|
+
return b"\x02" + out + block(b"")
|
|
339
|
+
ss = struct.pack("<29i", *[p["w"]] * 29) + struct.pack("<29i", *[p["h"]] * 29) + bytes([base]) * 29
|
|
340
|
+
ss += b"\0" + bytes([hs]) + struct.pack("<id", p.get("slot", 0), float(p.get("hole_rot", 0.0)))
|
|
341
|
+
ss += struct.pack("<32i", *[ox] * 32) + struct.pack("<32i", *[oy] * 32) # per layer: 32 X, then 32 Y
|
|
342
|
+
if rr: # reserved, hole shape, slot length, hole rotation, offsets, then:
|
|
343
|
+
ss += b"\x01" + bytes([9]) * 32 + bytes([max(0, min(100, int(round(p.get("corner", 50)))))]) * 32
|
|
344
|
+
else: # no rounded rect: flag 0, layer shapes 1, corners 0 (TestData slot pads)
|
|
345
|
+
ss += b"\0" + b"\x01" * 32 + b"\0" * 32
|
|
346
|
+
assert len(ss) == 596
|
|
347
|
+
return b"\x02" + out + block(ss)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# ---------------------------------------------------------------- footprints: model & converters
|
|
351
|
+
def footprint_from_kicad(text, height_mm=0.0, description=None, notes=None):
|
|
352
|
+
"""Convert a .kicad_mod (as written by footprint_gen) into the writer's footprint dict (mm, y up).
|
|
353
|
+
SMD pads are converted here; through-hole and NPTH pads (round, square, slots), custom (polygon) pads and keep-out
|
|
354
|
+
zones go through kicad_import's converter. notes: list that collects what could not be carried over exactly."""
|
|
355
|
+
from . import kicad_import as K
|
|
356
|
+
notes = [] if notes is None else notes
|
|
357
|
+
t = sexpr.parse(text)
|
|
358
|
+
fp = {"name": t[1], "description": clean(description if description is not None else (sexpr.first(t, "descr") or ["", ""])[1]),
|
|
359
|
+
"height_mm": height_mm, "pads": [], "tracks": [], "fills": [], "regions": [], "texts": texts_from_kicad(t)}
|
|
360
|
+
for p in sexpr.find(t, "pad"):
|
|
361
|
+
at, size = sexpr.first(p, "at"), sexpr.first(p, "size")
|
|
362
|
+
x, y, w, h = float(at[1]), -float(at[2]), float(size[1]), float(size[2])
|
|
363
|
+
rot = float(at[3]) if len(at) > 3 else 0.0
|
|
364
|
+
layers = (sexpr.first(p, "layers") or ["layers"])[1:]
|
|
365
|
+
if p[2] != "smd" or p[3] == "custom":
|
|
366
|
+
K._pad(fp, p, notes); continue
|
|
367
|
+
if "F.Cu" not in layers: # paste-only aperture (EP window) -> Top Paste fill
|
|
368
|
+
if layers != ["F.Paste"] or rot:
|
|
369
|
+
raise ValueError(f"{fp['name']}: unsupported non-copper pad on {layers}")
|
|
370
|
+
fp["fills"].append((x - w / 2, y - h / 2, x + w / 2, y + h / 2, TOP_PASTE)); continue
|
|
371
|
+
rr = sexpr.first(p, "roundrect_rratio")
|
|
372
|
+
pad = {"designator": p[1], "x": x, "y": y, "w": w, "h": h, "rot": rot, "shape": p[3],
|
|
373
|
+
"corner": float(rr[1]) * 200 if rr else 0, # KiCad r/min(w,h) -> Altium r/(min(w,h)/2) %
|
|
374
|
+
"paste": None if "F.Paste" in layers else "none"}
|
|
375
|
+
if sexpr.first(p, "solder_mask_margin"): # e.g. BGA NSMD: mask opening = land + margin
|
|
376
|
+
pad["mask"] = float(sexpr.first(p, "solder_mask_margin")[1])
|
|
377
|
+
if "F.Paste" in layers and sexpr.first(p, "solder_paste_margin"): # e.g. SMD-defined BGA land: paste = land
|
|
378
|
+
pad["paste"] = float(sexpr.first(p, "solder_paste_margin")[1])
|
|
379
|
+
if p[3] not in SHAPE:
|
|
380
|
+
raise ValueError(f"{fp['name']}: pad shape {p[3]} not supported")
|
|
381
|
+
fp["pads"].append(pad)
|
|
382
|
+
for kind in ("fp_line", "fp_rect", "fp_poly"):
|
|
383
|
+
for g in sexpr.find(t, kind):
|
|
384
|
+
layer = KICAD_LAYER.get((sexpr.first(g, "layer") or [None, None])[1])
|
|
385
|
+
if layer is None:
|
|
386
|
+
continue
|
|
387
|
+
w = float((sexpr.first(sexpr.first(g, "stroke") or [], "width") or [None, 0.1])[1])
|
|
388
|
+
if kind == "fp_poly":
|
|
389
|
+
pts = [(float(a[1]), -float(a[2])) for a in sexpr.find(sexpr.first(g, "pts"), "xy")]
|
|
390
|
+
else:
|
|
391
|
+
(x1, y1), (x2, y2) = [(float(a[1]), -float(a[2])) for a in (sexpr.first(g, "start"), sexpr.first(g, "end"))]
|
|
392
|
+
pts = [(x1, y1), (x2, y2)] if kind == "fp_line" else [(x1, y1), (x2, y1), (x2, y2), (x1, y2)]
|
|
393
|
+
fill = (sexpr.first(g, "fill") or [None, "none"])[1]
|
|
394
|
+
if kind != "fp_line" and fill in ("solid", "yes"):
|
|
395
|
+
fp["regions"].append((pts, layer))
|
|
396
|
+
else:
|
|
397
|
+
segs = zip(pts, pts[1:] + pts[:1]) if kind != "fp_line" else [pts]
|
|
398
|
+
fp["tracks"] += [(a[0], a[1], b[0], b[1], w, layer) for a, b in segs]
|
|
399
|
+
if sexpr.find(t, "zone"):
|
|
400
|
+
K._zones(fp, t, notes)
|
|
401
|
+
return fp
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def footprint_from_spec(spec, density=None):
|
|
405
|
+
"""footprint_gen.build(spec) -> footprint dict (uses the same geometry as the .kicad_mod). density: A/B/C, or
|
|
406
|
+
None for the spec's own (then B)."""
|
|
407
|
+
from . import footprint_gen as F
|
|
408
|
+
txt, rep = F.build(spec, density)
|
|
409
|
+
d = f"{spec.get('description', spec['name'])}; IPC-7351B {rep['ipc_family']} density {rep['density']}; generated by ecadlib"
|
|
410
|
+
notes = []
|
|
411
|
+
fp = footprint_from_kicad(txt, F.dim(spec.get("height_max", 0))[2] if spec.get("height_max") else 0.0, d, notes)
|
|
412
|
+
if notes:
|
|
413
|
+
rep = dict(rep, altium_notes=notes)
|
|
414
|
+
return fp, rep
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _model_id(seed):
|
|
418
|
+
return "{" + str(guid(seed)).upper() + "}"
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _pcb_component(fp, models=None):
|
|
422
|
+
"""-> (storage dict, primitive count, pad count) for one footprint. Embedded STEP models are
|
|
423
|
+
appended to `models` as (params text, STEP bytes) for the Library/Models storage."""
|
|
424
|
+
name = fp["name"]
|
|
425
|
+
gb = guid(name + "/stack")
|
|
426
|
+
data = bytearray(sblock(name)); n = 0
|
|
427
|
+
for i, p in enumerate(fp["pads"]):
|
|
428
|
+
q = dict(p, x=mm(p["x"]), y=mm(p["y"]), w=mm(p["w"]), h=mm(p["h"]), hole=mm(p.get("hole", 0)), slot=mm(p.get("slot", 0)),
|
|
429
|
+
offset=tuple(mm(v) for v in p["offset"]) if p.get("offset") else None)
|
|
430
|
+
if q["shape"] != "roundrect":
|
|
431
|
+
q["corner"] = 0
|
|
432
|
+
if p.get("paste") == "none": # suppress paste: manual expansion of -max(w,h)/2 closes the aperture
|
|
433
|
+
q["paste"] = -max(q["w"], q["h"]) // 2 - mm(0.01)
|
|
434
|
+
elif isinstance(p.get("paste"), float): # manual expansion in mm
|
|
435
|
+
q["paste"] = mm(p["paste"])
|
|
436
|
+
if p.get("mask") == "none": # tented: a manual expansion of -max(w,h)/2 closes the mask opening
|
|
437
|
+
q["mask"] = -max(q["w"], q["h"]) // 2 - mm(0.01)
|
|
438
|
+
elif p.get("mask") is not None: # manual expansion in mm
|
|
439
|
+
q["mask"] = mm(p["mask"])
|
|
440
|
+
data += pad_rec(q, guid(f"{name}/pad{i}"), gb); n += 1
|
|
441
|
+
for x1, y1, x2, y2, w, layer in fp["tracks"]:
|
|
442
|
+
data += track_rec(mm(x1), mm(y1), mm(x2), mm(y2), mm(w), layer); n += 1
|
|
443
|
+
for x1, y1, x2, y2, layer in fp["fills"]:
|
|
444
|
+
data += fill_rec(mm(x1), mm(y1), mm(x2), mm(y2), layer); n += 1
|
|
445
|
+
for pts, layer, *ko in fp["regions"]: # optional third item: keep-out restriction bits
|
|
446
|
+
data += region_rec([(mm(x), mm(y)) for x, y in pts], layer, keepout=ko[0] if ko else None); n += 1
|
|
447
|
+
for cx, cy, r, a0, a1, w, layer in fp.get("arcs", []):
|
|
448
|
+
data += arc_rec(mm(cx), mm(cy), mm(r), a0, a1, mm(w), layer); n += 1
|
|
449
|
+
wide = []
|
|
450
|
+
for txt, x, y, h, w, rot, layer in fp.get("texts", []):
|
|
451
|
+
data += text_rec(txt, mm(x), mm(y), mm(h), mm(w), layer, rot, len(wide)); n += 1
|
|
452
|
+
wide.append((f"ENCODEDTEXT{len(wide)}", ",".join(str(ord(c)) for c in txt)))
|
|
453
|
+
for k, b in enumerate(fp.get("bodies", [])): # last: pyaltiumlib reads a trailing block after a body
|
|
454
|
+
pts, st = [(mm(x), mm(y)) for x, y in b["pts"]], b.get("step")
|
|
455
|
+
if st:
|
|
456
|
+
ck = step_checksum(st["data"])
|
|
457
|
+
mid = _model_id(f"{name}/model/{ck}")
|
|
458
|
+
models.append((params([("EMBED", "TRUE"), ("MODELSOURCE", "Undefined"), ("ID", mid), ("ROTX", f"{st['rot'][0]:.3f}"),
|
|
459
|
+
("ROTY", f"{st['rot'][1]:.3f}"), ("ROTZ", f"{st['rot'][2]:.3f}"), ("DZ", mm(st.get("dz", 0))),
|
|
460
|
+
("CHECKSUM", ck), ("NAME", clean(st["name"]))])[1:], st["data"]))
|
|
461
|
+
ptxt = body_params(fp.get("body_layer", MECH13), mm(b["height"]), mid, ck & 0xFFFFFFFF, mm(b.get("standoff", 0)),
|
|
462
|
+
(clean(st["name"]), *st["rot"], mm(st.get("dz", 0))))
|
|
463
|
+
else: # extruded: Altium-made bodies carry a model id and checksum too
|
|
464
|
+
ptxt = body_params(fp.get("body_layer", MECH13), mm(b["height"]), _model_id(f"{name}/body{k}"),
|
|
465
|
+
int.from_bytes(hashlib.sha256(f"{name}/body{k}".encode()).digest()[:4], "little"), mm(b.get("standoff", 0)))
|
|
466
|
+
data += body_rec(pts, ptxt, fp.get("body_layer", MECH13)); n += 1
|
|
467
|
+
height = f"{to_mil(mm(fp.get('height_mm') or 0))}mil"
|
|
468
|
+
par = params([("PATTERN", name), ("HEIGHT", height), ("DESCRIPTION", clean(fp.get("description", ""))),
|
|
469
|
+
("ITEMGUID", ""), ("REVISIONGUID", "")])
|
|
470
|
+
st = {"Header": struct.pack("<I", n), "Parameters": pblock(par), "WideStrings": pblock(params(wide)), "Data": bytes(data)}
|
|
471
|
+
if fp["pads"]: # pads are written first, so PRIMITIVEINDEX = pad index
|
|
472
|
+
st["UniqueIDPrimitiveInformation"] = {"Header": struct.pack("<I", len(fp["pads"])), "Data": b"".join(
|
|
473
|
+
pblock(f"|PRIMITIVEINDEX={i}|PRIMITIVEOBJECTID=Pad|UNIQUEID={unique_id(f'{name}/pad{i}')}") for i in range(len(fp["pads"])))}
|
|
474
|
+
return st, len(fp["pads"]), height[:-3]
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def to_mil(u):
|
|
478
|
+
s = f"{u / 10000:.4f}".rstrip("0").rstrip(".")
|
|
479
|
+
return s if s not in ("", "-0") else "0"
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
@functools.lru_cache(maxsize=1)
|
|
483
|
+
def _template():
|
|
484
|
+
return json.loads(gzip.decompress((Path(__file__).with_name("assets") / "pcblib_template.json.gz").read_bytes()))
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def pcblib_bytes(footprints, filename="ecadlib.PcbLib", stamp=None):
|
|
488
|
+
"""stamp: (DATE, TIME) for the library header; default now (or SOURCE_DATE_EPOCH)."""
|
|
489
|
+
footprints = [dict(fp, name=lib_name(fp["name"])) for fp in footprints]
|
|
490
|
+
names = [fp["name"] for fp in footprints]
|
|
491
|
+
keys = _section_keys(names, PCBLIB_RESERVED, "footprint")
|
|
492
|
+
tpl, (date, tm) = _template(), stamp or _now() # cached: treat tpl as read-only
|
|
493
|
+
hdr = re.sub(r"\|FILENAME=[^|]*", "|FILENAME=" + clean(filename), tpl["library_params"], count=1)
|
|
494
|
+
hdr = re.sub(r"\|DATE=[^|]*", "|DATE=" + date, hdr, count=1)
|
|
495
|
+
hdr = re.sub(r"\|TIME=[^|]*", "|TIME=" + tm, hdr, count=1)
|
|
496
|
+
seed = "|".join(names)
|
|
497
|
+
tree = {"FileHeader": struct.pack("<IB", 27, 27) + b"PCB 6.0 Binary Library File" + struct.pack("<d", 5.01)
|
|
498
|
+
+ struct.pack("<IB", 8, 8) + unique_id(seed).encode()}
|
|
499
|
+
long = [(n, k) for n, k in zip(names, keys) if len(n) >= 31 or k != n]
|
|
500
|
+
if long: # binary: [u32 count] then (name, key) string blocks
|
|
501
|
+
tree["SectionKeys"] = struct.pack("<I", len(long)) + b"".join(sblock(n) + sblock(k) for n, k in sorted(long, key=lambda t: t[0].upper()))
|
|
502
|
+
toc, comps, models = "", {}, []
|
|
503
|
+
for fp, key in zip(footprints, keys):
|
|
504
|
+
st, npads, h = _pcb_component(fp, models)
|
|
505
|
+
comps[key] = st
|
|
506
|
+
toc += f"Name={fp['name']}|Pad Count={npads}|Height={h}|Description={clean(fp.get('description', ''))}\r\n"
|
|
507
|
+
hs = lambda n: struct.pack("<I", n)
|
|
508
|
+
empty = lambda: {"Header": hs(0), "Data": b""}
|
|
509
|
+
tree["Library"] = {
|
|
510
|
+
"Header": hs(1),
|
|
511
|
+
"Data": pblock(hdr) + hs(len(footprints)) + b"".join(sblock(n) for n in names),
|
|
512
|
+
"EmbeddedFonts": hs(0),
|
|
513
|
+
"LayerKindMapping": {"Header": hs(1), "Data": hs(8) + "1.0\0".encode("utf-16-le") + hs(0) + hs(0)},
|
|
514
|
+
"PadViaLibrary": {"Header": hs(0), "Data": pblock(
|
|
515
|
+
f"|PADVIALIBRARY.LIBRARYID={{{str(guid(seed + '/pvl')).upper()}}}|PADVIALIBRARY.LIBRARYNAME=<Local>|PADVIALIBRARY.DISPLAYUNITS=1")},
|
|
516
|
+
"ComponentParamsTOC": {"Header": hs(1), "Data": pblock(toc)},
|
|
517
|
+
"Models": {"Header": hs(len(models)), "Data": b"".join(pblock(t) for t, _ in models),
|
|
518
|
+
**{str(i): zlib.compress(d, 9) for i, (_, d) in enumerate(models)}},
|
|
519
|
+
"ModelsNoEmbed": empty(), "Textures": empty()}
|
|
520
|
+
tree["FileVersionInfo"] = {"Header": hs(1), "Data": pblock(tpl["file_version_info"])}
|
|
521
|
+
tree.update(comps)
|
|
522
|
+
return cfb.to_bytes(tree)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def remap_layers(fp, layers):
|
|
526
|
+
"""Footprint dict with ecadlib's layers moved to the library rules' ones. layers: {role: layer id} from
|
|
527
|
+
rules.layers() (courtyard, assembly, body3d, silkscreen, designator); None or {} leaves fp as it is."""
|
|
528
|
+
if not layers:
|
|
529
|
+
return fp
|
|
530
|
+
graphics = {MECH15: layers.get("courtyard", MECH15), MECH13: layers.get("assembly", MECH13),
|
|
531
|
+
TOP_OVERLAY: layers.get("silkscreen", TOP_OVERLAY)}
|
|
532
|
+
g = lambda l: graphics.get(l, l)
|
|
533
|
+
text = lambda t: (layers.get("designator", TOP_OVERLAY) if t[0] == ".Designator" and t[6] == TOP_OVERLAY else g(t[6]))
|
|
534
|
+
out = dict(fp, tracks=[(*t[:5], g(t[5])) for t in fp.get("tracks", [])],
|
|
535
|
+
fills=[(*f[:4], g(f[4])) for f in fp.get("fills", [])],
|
|
536
|
+
regions=[(r[0], g(r[1]), *r[2:]) for r in fp.get("regions", [])],
|
|
537
|
+
arcs=[(*a[:6], g(a[6])) for a in fp.get("arcs", [])],
|
|
538
|
+
texts=[(*t[:6], text(t)) for t in fp.get("texts", [])])
|
|
539
|
+
if layers.get("body3d", MECH13) != MECH13:
|
|
540
|
+
out["body_layer"] = layers["body3d"]
|
|
541
|
+
return out
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def write_pcblib(path, footprints, layers=None):
|
|
545
|
+
"""layers: {role: layer id} (rules.layers(rules)) to put courtyard, assembly, 3D body and silkscreen where the
|
|
546
|
+
library keeps them; None = ecadlib's layers (Mechanical 15 / 13, Top Overlay)."""
|
|
547
|
+
from .fsutil import write_bytes_atomic
|
|
548
|
+
path = Path(path)
|
|
549
|
+
fps = [remap_layers(fp, layers) for fp in footprints]
|
|
550
|
+
old = _pcblib_stamp(path)
|
|
551
|
+
if old and path.read_bytes() == pcblib_bytes(fps, path.name, old):
|
|
552
|
+
return path # the same library: leave the file (and the date in it) alone, so a re-run
|
|
553
|
+
write_bytes_atomic(path, pcblib_bytes(fps, path.name)) # changes nothing on disk or in git
|
|
554
|
+
return path
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _pcblib_stamp(path):
|
|
558
|
+
"""(DATE, TIME) in an existing .PcbLib's library header, or None."""
|
|
559
|
+
if not Path(path).is_file():
|
|
560
|
+
return None
|
|
561
|
+
try:
|
|
562
|
+
import olefile
|
|
563
|
+
with olefile.OleFileIO(str(path)) as o:
|
|
564
|
+
hdr = o.openstream("Library/Data").read(4096)
|
|
565
|
+
except Exception: # not ours, damaged, not OLE: just write the new file
|
|
566
|
+
return None
|
|
567
|
+
d, t = re.search(rb"\|DATE=([^|]*)", hdr), re.search(rb"\|TIME=([^|]*)", hdr)
|
|
568
|
+
return (d.group(1).decode("latin-1"), t.group(1).decode("latin-1")) if d and t else None
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
# ---------------------------------------------------------------- schematic symbols
|
|
572
|
+
ETYPE = {"input": 0, "io": 1, "bidir": 1, "output": 2, "open_collector": 3, "passive": 4, "hiz": 5, "tristate": 5,
|
|
573
|
+
"open_emitter": 6, "power": 7, "power_in": 7, "power_out": 7, "ground": 7, "nc": 4}
|
|
574
|
+
ORIENT = {"right": 0, "top": 1, "left": 2, "bottom": 3} # direction the pin points away from the body
|
|
575
|
+
BLUE, BODY_FILL, PARAM_COLOR = 128, 11599871, 8388608
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _coord(pairs, name, v): # DXP units (10 mil); Altium omits zeros, adds _Frac for sub-units
|
|
579
|
+
whole, frac = int(v), round((v - int(v)) * 100000)
|
|
580
|
+
if whole:
|
|
581
|
+
pairs.append((name, whole))
|
|
582
|
+
if frac:
|
|
583
|
+
pairs.append((name + "_Frac", frac))
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
# Pin IEEE symbols (Altium TIeeeSymbol ids). Altium's pin dialog offers clock on the inside edge; dot and active-low
|
|
587
|
+
# input/output on the outside edge; signal-flow arrows, analog/digital in and not-logic outside; the rest inside.
|
|
588
|
+
PIN_SYMBOL = {"dot": 1, "right_left_signal_flow": 2, "clock": 3, "active_low_input": 4, "analog_signal_in": 5,
|
|
589
|
+
"not_logic_connection": 6, "postponed_output": 8, "open_collector": 9, "hiz": 10, "high_current": 11, "pulse": 12,
|
|
590
|
+
"schmitt": 13, "active_low_output": 17, "open_collector_pull_up": 22, "open_emitter": 23, "open_emitter_pull_up": 24,
|
|
591
|
+
"digital_signal_in": 25, "shift_left": 30, "open_output": 32, "left_right_signal_flow": 33, "bidirectional_signal_flow": 34}
|
|
592
|
+
PIN_SLOTS = ("inside_edge", "outside_edge", "inside", "outside") # binary pin record order
|
|
593
|
+
# KiCad pin shapes; falling-edge clock has no Altium symbol and becomes clock + active-low input
|
|
594
|
+
PIN_SHAPE = {"line": {}, "inverted": {"outside_edge": "dot"}, "clock": {"inside_edge": "clock"},
|
|
595
|
+
"inverted_clock": {"inside_edge": "clock", "outside_edge": "dot"}, "input_low": {"outside_edge": "active_low_input"},
|
|
596
|
+
"clock_low": {"inside_edge": "clock", "outside_edge": "active_low_input"}, "output_low": {"outside_edge": "active_low_output"},
|
|
597
|
+
"edge_clock_high": {"inside_edge": "clock", "outside_edge": "active_low_input"},
|
|
598
|
+
"non_logic": {"outside": "not_logic_connection"}}
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def pin_symbols(shape):
|
|
602
|
+
"""None, a PIN_SHAPE name or {slot: PIN_SYMBOL name or id} -> (inside edge, outside edge, inside, outside) ids."""
|
|
603
|
+
d = PIN_SHAPE.get(shape) if isinstance(shape, str) else shape or {}
|
|
604
|
+
if not isinstance(d, dict) or set(d) - set(PIN_SLOTS):
|
|
605
|
+
raise ValueError(f"pin shape {shape!r}: use one of {list(PIN_SHAPE)} or a dict with keys {list(PIN_SLOTS)}")
|
|
606
|
+
out = tuple(PIN_SYMBOL.get(d.get(k, 0), d.get(k, 0)) for k in PIN_SLOTS)
|
|
607
|
+
if any(v not in (0, *PIN_SYMBOL.values()) for v in out):
|
|
608
|
+
raise ValueError(f"pin shape {shape!r}: symbols are {list(PIN_SYMBOL)}")
|
|
609
|
+
return out
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def overline(s):
|
|
613
|
+
"""KiCad '~{RESET}' overbar -> Altium 'R\\E\\S\\E\\T\\' (a backslash after each overlined character)."""
|
|
614
|
+
return "" if s == "~" else re.sub(r"~\{([^}]*)\}", lambda m: "".join(c + "\\" for c in m.group(1)), s)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def pin_rec(num, name, etype, orient, x, y, length, show=True, hidden=False, part=1, show_name=None, show_num=None,
|
|
618
|
+
symbols=(0, 0, 0, 0), mode=0, desc=""):
|
|
619
|
+
"""symbols: (inside edge, outside edge, inside, outside) IEEE symbol ids; mode: owner display mode (0 = normal)."""
|
|
620
|
+
sn, sd = show if show_name is None else show_name, show if show_num is None else show_num
|
|
621
|
+
cong = orient | 0x20 | (0x04 if hidden else 0) | (0x08 if sn else 0) | (0x10 if sd else 0) # 0x04 hidden, 0x08 name, 0x10 number
|
|
622
|
+
d = enc(desc)[:255]
|
|
623
|
+
b = struct.pack("<iBhB4B", 2, 0, part, mode, *symbols) + bytes([len(d)]) + d # owner part, display mode, symbols, description
|
|
624
|
+
b += struct.pack("<BBBhhhI", 1, ETYPE[etype], cong, length, x, y, 0)
|
|
625
|
+
b += b"".join(bytes([len(enc(s))]) + enc(s) for s in (name, num, "", "|&|", ""))
|
|
626
|
+
return block(b, flag=1)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def ic_symbol(name, pins, description="", designator="U?", footprint=None, comment="*", layout=None, rules=None):
|
|
630
|
+
"""Rectangular IC: pins = [{'num','name','type','side' in left/right/top/bottom, optional 'shape'}], listed
|
|
631
|
+
top->bottom (left/right) or left->right (top/bottom). 'shape' is a PIN_SHAPE name ('inverted', 'clock', ...) or
|
|
632
|
+
{slot: PIN_SYMBOL name} (see pin_symbols); '~{EN}' in a name is overlined. Body grows to fit pin names (100 mil grid).
|
|
633
|
+
layout: None = pins grouped by function (ecadlib.symbol_layout, 'side' ignored) when there are at least the rules'
|
|
634
|
+
[symbols] layout_pins pins (default 1: always), True = always, False = never. rules: loaded rules or [symbols] overrides (None = defaults)."""
|
|
635
|
+
bad = [p.get("num") for p in pins if p.get("side", "left") not in ORIENT]
|
|
636
|
+
if bad:
|
|
637
|
+
raise ValueError(f"{name}: pins {bad} have a side other than {list(ORIENT)}")
|
|
638
|
+
if layout is not False:
|
|
639
|
+
from . import symbol_layout as SL
|
|
640
|
+
if layout or SL.wants_layout(pins, rules):
|
|
641
|
+
return SL.build(name, pins, description, designator, footprint, comment, rules)
|
|
642
|
+
side = {s: [p for p in pins if p.get("side", "left") == s] for s in ORIENT}
|
|
643
|
+
rows = max(len(side["left"]), len(side["right"]), 1)
|
|
644
|
+
cols = max(len(side["top"]), len(side["bottom"]))
|
|
645
|
+
nlen = lambda p: len(overline(p["name"]).replace("\\", "")) # overline backslashes take no room
|
|
646
|
+
chars = max([nlen(p) for p in side["left"]] or [0]) + max([nlen(p) for p in side["right"]] or [0])
|
|
647
|
+
w = max(40, 10 * -(-(chars * 7 + 20) // 10), 10 * (cols + 1)) # ~7 units (70 mil) per 10 pt char
|
|
648
|
+
room = lambda ps: 10 * -(-(max(nlen(p) for p in ps) * 7 + 10) // 10) if ps else 0
|
|
649
|
+
t_room, b_room = room(side["top"]), room(side["bottom"]) # vertical names inside the body
|
|
650
|
+
top, bot = 0, -(t_room + 10 * (rows + 1) + b_room)
|
|
651
|
+
prims = [("rect", 0, top, w, bot)]
|
|
652
|
+
L = 30
|
|
653
|
+
for i, p in enumerate(side["left"]): # below the top pins' names
|
|
654
|
+
prims.append(("pin", p, "left", 0, -t_room - 10 * (i + 1), L))
|
|
655
|
+
for i, p in enumerate(side["right"]):
|
|
656
|
+
prims.append(("pin", p, "right", w, -t_room - 10 * (i + 1), L))
|
|
657
|
+
for i, p in enumerate(side["top"]):
|
|
658
|
+
prims.append(("pin", p, "top", 10 * (i + 1), top, L))
|
|
659
|
+
for i, p in enumerate(side["bottom"]):
|
|
660
|
+
prims.append(("pin", p, "bottom", 10 * (i + 1), bot, L))
|
|
661
|
+
return {"name": name, "description": description, "designator": designator, "comment": comment,
|
|
662
|
+
"footprint": footprint, "prims": prims, "label_at": ((0, top + 2), (0, bot - 8))}
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def resistor_symbol(name="Resistor", description="Resistor", footprint=None):
|
|
666
|
+
pins = [{"num": "1", "name": "1", "type": "passive"}, {"num": "2", "name": "2", "type": "passive"}]
|
|
667
|
+
prims = [("rect", -15, 4, 15, -4), ("pin", pins[0], "left", -15, 0, 15, False), ("pin", pins[1], "right", 15, 0, 15, False)]
|
|
668
|
+
return {"name": name, "description": description, "designator": "R?", "comment": "*", "footprint": footprint,
|
|
669
|
+
"prims": prims, "label_at": ((-15, 7), (-15, -14))}
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def capacitor_symbol(name="Capacitor", description="Capacitor (non-polarised)", footprint=None):
|
|
673
|
+
pins = [{"num": "1", "name": "1", "type": "passive"}, {"num": "2", "name": "2", "type": "passive"}]
|
|
674
|
+
prims = [("line", -3, 8, -3, -8, 2), ("line", 3, 8, 3, -8, 2),
|
|
675
|
+
("pin", pins[0], "left", -3, 0, 27, False), ("pin", pins[1], "right", 3, 0, 27, False)]
|
|
676
|
+
return {"name": name, "description": description, "designator": "C?", "comment": "*", "footprint": footprint,
|
|
677
|
+
"prims": prims, "label_at": ((-15, 11), (-15, -18))}
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def symbol_from_json(d, rules=None):
|
|
681
|
+
"""Symbol JSON -> symbol dict. An IC's optional "layout": true/false forces the grouped layout on/off (see ic_symbol)."""
|
|
682
|
+
kind = d.get("template", "ic")
|
|
683
|
+
if kind == "resistor":
|
|
684
|
+
return resistor_symbol(d.get("name", "Resistor"), d.get("description", "Resistor"), d.get("footprint"))
|
|
685
|
+
if kind == "capacitor":
|
|
686
|
+
return capacitor_symbol(d.get("name", "Capacitor"), d.get("description", "Capacitor"), d.get("footprint"))
|
|
687
|
+
return ic_symbol(d["name"], d["pins"], d.get("description", ""), d.get("designator", "U?"), d.get("footprint"),
|
|
688
|
+
layout=d.get("layout"), rules=rules)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _xy(kv, pts): # polyline/polygon vertices: LocationCount, X1/Y1 (+ _Frac), ...
|
|
692
|
+
kv.append(("LocationCount", len(pts)))
|
|
693
|
+
for k, (x, y) in enumerate(pts, 1):
|
|
694
|
+
_coord(kv, f"X{k}", x); _coord(kv, f"Y{k}", y)
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _fill(kv, fill): # 'body' = Altium symbol fill, 'line' = solid in line colour, else hollow
|
|
698
|
+
kv += [("Color", BLUE), ("AreaColor", {"body": BODY_FILL, "line": BLUE}.get(fill, 16777215))]
|
|
699
|
+
kv += [("IsSolid", "T")] if fill in ("body", "line") else []
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _sch_component(sym):
|
|
703
|
+
"""-> (Data stream, record count, pin count). sym['parts'] (default 1) and sym['prim_parts'] (owner part
|
|
704
|
+
per primitive) describe multi-part symbols; sym['modes'] (default 1) and sym['prim_modes'] (display mode per
|
|
705
|
+
primitive, 0 = normal, 1 = first alternate/De Morgan) describe alternate views; sym['params'] = [(name, value)]
|
|
706
|
+
become hidden parameters."""
|
|
707
|
+
nm, recs, idx_sheet = sym["name"], [], 0
|
|
708
|
+
uid = lambda k: unique_id(f"{nm}/{k}")
|
|
709
|
+
parts = sym.get("prim_parts") or [1] * len(sym["prims"])
|
|
710
|
+
modes = sym.get("prim_modes") or [0] * len(sym["prims"])
|
|
711
|
+
npins = sum(1 for p, m in zip(sym["prims"], modes) if p[0] == "pin" and not m) # AllPinCount: normal view only (AD24)
|
|
712
|
+
comp = [("RECORD", 1), ("LibReference", nm)]
|
|
713
|
+
if sym.get("description"):
|
|
714
|
+
comp.append(("ComponentDescription", clean(sym["description"])))
|
|
715
|
+
comp += [("PartCount", sym.get("parts", 1) + 1), ("DisplayModeCount", sym.get("modes", 1)), ("IndexInSheet", -1), ("OwnerPartId", -1), ("CurrentPartId", 1),
|
|
716
|
+
("LibraryPath", "*"), ("SourceLibraryName", "*"), ("SheetPartFileName", "*"), ("TargetFileName", "*"),
|
|
717
|
+
("UniqueID", uid("comp")), ("AreaColor", BODY_FILL), ("Color", BLUE), ("PartIDLocked", "T"),
|
|
718
|
+
("DesignItemId", nm), ("AllPinCount", npins)]
|
|
719
|
+
recs.append(schblock(params(comp)))
|
|
720
|
+
for i, pr in enumerate(sym["prims"]):
|
|
721
|
+
common_ = [("IsNotAccesible", "T")] + ([("IndexInSheet", i)] if i else []) + [("OwnerPartId", parts[i])]
|
|
722
|
+
common_ += [("OwnerPartDisplayMode", modes[i])] if modes[i] else []
|
|
723
|
+
if pr[0] == "rect": # ("rect", x1, y1, x2, y2[, line width, fill])
|
|
724
|
+
kv = [("RECORD", 14)] + common_
|
|
725
|
+
for k, v in zip(("Location.X", "Location.Y", "Corner.X", "Corner.Y"), (min(pr[1], pr[3]), min(pr[2], pr[4]), max(pr[1], pr[3]), max(pr[2], pr[4]))):
|
|
726
|
+
_coord(kv, k, v)
|
|
727
|
+
kv += [("LineWidth", pr[5])] if len(pr) > 5 and pr[5] else []
|
|
728
|
+
_fill(kv, pr[6] if len(pr) > 6 else "body")
|
|
729
|
+
recs.append(schblock(params(kv + [("UniqueID", uid(i))])))
|
|
730
|
+
elif pr[0] in ("polyline", "polygon"): # (kind, [(x, y)...], line width[, fill])
|
|
731
|
+
kv = [("RECORD", 6 if pr[0] == "polyline" else 7)] + common_ + [("LineWidth", pr[2])]
|
|
732
|
+
if pr[0] == "polygon":
|
|
733
|
+
_fill(kv, pr[3])
|
|
734
|
+
else:
|
|
735
|
+
kv.append(("Color", BLUE))
|
|
736
|
+
_xy(kv, pr[1])
|
|
737
|
+
recs.append(schblock(params(kv + [("UniqueID", uid(i))])))
|
|
738
|
+
elif pr[0] == "arc": # ("arc", cx, cy, r, start deg, end deg, line width), counter-clockwise
|
|
739
|
+
kv = [("RECORD", 12)] + common_
|
|
740
|
+
_coord(kv, "Location.X", pr[1]); _coord(kv, "Location.Y", pr[2]); _coord(kv, "Radius", pr[3])
|
|
741
|
+
kv += [("LineWidth", pr[6])] + ([("StartAngle", f"{pr[4]:.3f}")] if pr[4] else []) + [("EndAngle", f"{pr[5]:.3f}"), ("Color", BLUE)]
|
|
742
|
+
recs.append(schblock(params(kv + [("UniqueID", uid(i))])))
|
|
743
|
+
elif pr[0] == "circle": # ("circle", cx, cy, r, line width, fill)
|
|
744
|
+
kv = [("RECORD", 8)] + common_
|
|
745
|
+
_coord(kv, "Location.X", pr[1]); _coord(kv, "Location.Y", pr[2]); _coord(kv, "Radius", pr[3]); _coord(kv, "SecondaryRadius", pr[3])
|
|
746
|
+
kv.append(("LineWidth", pr[4])); _fill(kv, pr[5])
|
|
747
|
+
recs.append(schblock(params(kv + [("UniqueID", uid(i))])))
|
|
748
|
+
elif pr[0] == "text": # ("text", x, y, text, orientation 0..3)
|
|
749
|
+
kv = [("RECORD", 4)] + common_
|
|
750
|
+
_coord(kv, "Location.X", pr[1]); _coord(kv, "Location.Y", pr[2])
|
|
751
|
+
kv += ([("Orientation", pr[4])] if pr[4] else []) + [("Color", BLUE), ("FontID", 1), ("Text", clean(pr[3]))]
|
|
752
|
+
recs.append(schblock(params(kv + [("UniqueID", uid(i))])))
|
|
753
|
+
elif pr[0] == "line":
|
|
754
|
+
kv = [("RECORD", 13)] + common_
|
|
755
|
+
for k, v in zip(("Location.X", "Location.Y", "Corner.X", "Corner.Y"), pr[1:5]):
|
|
756
|
+
_coord(kv, k, v)
|
|
757
|
+
kv += [("LineWidth", pr[5]), ("Color", BLUE), ("UniqueID", uid(i))]
|
|
758
|
+
recs.append(schblock(params(kv)))
|
|
759
|
+
else:
|
|
760
|
+
_, p, side, x, y, length, *show = pr
|
|
761
|
+
recs.append(pin_rec(p["num"], overline(p["name"]), p.get("type", "passive"), ORIENT[side], x, y, length, show[0] if show else True,
|
|
762
|
+
p.get("hidden", False), parts[i], p.get("show_name"), p.get("show_num"), pin_symbols(p.get("shape")), modes[i],
|
|
763
|
+
clean(p.get("description", ""))))
|
|
764
|
+
if p.get("net"): # hidden power pin: connects to this net (Altium 'HiddenNetName')
|
|
765
|
+
recs.append(schblock(params([("RECORD", 41), ("OwnerIndex", len(recs) - 1), ("OwnerPartId", -1), ("Color", PARAM_COLOR),
|
|
766
|
+
("FontID", 1), ("Text", clean(p["net"])), ("Name", "HiddenNetName")])))
|
|
767
|
+
(dx, dy), (cx, cy) = sym["label_at"]
|
|
768
|
+
for rec, nm_, text, (x, y) in ((34, "Designator", sym.get("designator", "U?"), (dx, dy)), (41, "Comment", sym.get("comment", "*"), (cx, cy))):
|
|
769
|
+
kv = [("RECORD", rec), ("IndexInSheet", -1), ("OwnerPartId", -1)]
|
|
770
|
+
_coord(kv, "Location.X", x); _coord(kv, "Location.Y", y)
|
|
771
|
+
kv += [("Color", PARAM_COLOR), ("FontID", 1), ("Text", clean(text)), ("Name", nm_)]
|
|
772
|
+
kv += [("ReadOnlyState", 1)] if rec == 34 else []
|
|
773
|
+
kv += [("UniqueID", uid(nm_))]
|
|
774
|
+
recs.append(schblock(params(kv)))
|
|
775
|
+
for k, (pn, pv) in enumerate(sym.get("params", [])):
|
|
776
|
+
kv = [("RECORD", 41), ("IndexInSheet", -1), ("OwnerPartId", -1)]
|
|
777
|
+
_coord(kv, "Location.X", cx); _coord(kv, "Location.Y", cy - 10 * (k + 1))
|
|
778
|
+
kv += [("Color", PARAM_COLOR), ("FontID", 1), ("IsHidden", "T"), ("Text", clean(pv)), ("Name", clean(pn)), ("UniqueID", uid(f"param{k}"))]
|
|
779
|
+
recs.append(schblock(params(kv)))
|
|
780
|
+
recs.append(schblock("|RECORD=44"))
|
|
781
|
+
if sym.get("footprint"):
|
|
782
|
+
il = len(recs) - 1
|
|
783
|
+
fpn = clean(sym["footprint"])
|
|
784
|
+
recs.append(schblock(params([("RECORD", 45), ("OwnerIndex", il), ("IndexInSheet", -1), ("ModelName", fpn),
|
|
785
|
+
("ModelType", "PCBLIB"), ("DatafileCount", 1), ("ModelDatafileEntity0", fpn),
|
|
786
|
+
("ModelDatafileKind0", "PCBLib"), ("IsCurrent", "T"), ("UniqueID", uid("impl"))])))
|
|
787
|
+
recs += [schblock(f"|RECORD=46|OwnerIndex={il + 1}"), schblock(f"|RECORD=48|OwnerIndex={il + 1}")]
|
|
788
|
+
return b"".join(recs), len(recs), npins
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def schlib_bytes(symbols):
|
|
792
|
+
symbols = [dict(s, name=lib_name(s["name"]), **({"footprint": lib_name(s["footprint"])} if s.get("footprint") else {}))
|
|
793
|
+
for s in symbols]
|
|
794
|
+
names = [s["name"] for s in symbols]
|
|
795
|
+
keys = _section_keys(names, SCHLIB_RESERVED, "symbol")
|
|
796
|
+
tree, total, lib = {}, 0, []
|
|
797
|
+
for i, (s, k) in enumerate(zip(symbols, keys)):
|
|
798
|
+
data, n, _ = _sch_component(s)
|
|
799
|
+
tree[k] = {"Data": data}
|
|
800
|
+
total += n
|
|
801
|
+
lib += [(f"LibRef{i}", s["name"])] + ([(f"CompDescr{i}", clean(s["description"]))] if s.get("description") else []) + [(f"PartCount{i}", s.get("parts", 1) + 1)]
|
|
802
|
+
hdr = [("HEADER", "Protel for Windows - Schematic Library Editor Binary File Version 5.0"), ("Weight", total + 1),
|
|
803
|
+
("MinorVersion", 9), ("UniqueID", unique_id("|".join(names))), ("FontIdCount", 1), ("Size1", 10),
|
|
804
|
+
("FontName1", "Times New Roman"), ("UseMBCS", "T"), ("IsBOC", "T"), ("SheetStyle", 9), ("BorderOn", "T"),
|
|
805
|
+
("SheetNumberSpaceSize", 12), ("AreaColor", 16317695), ("SnapGridOn", "T"), ("SnapGridSize", 10),
|
|
806
|
+
("VisibleGridOn", "T"), ("VisibleGridSize", 10), ("CustomX", 18000), ("CustomY", 18000),
|
|
807
|
+
("UseCustomSheet", "T"), ("ReferenceZonesOn", "T"), ("Display_Unit", 0), ("CompCount", len(symbols))] + lib
|
|
808
|
+
tree["FileHeader"] = schblock(params(hdr))
|
|
809
|
+
long = [(n, k) for n, k in zip(names, keys) if k != n]
|
|
810
|
+
if long:
|
|
811
|
+
kv = [("KeyCount", len(long))]
|
|
812
|
+
for i, (n, k) in enumerate(long):
|
|
813
|
+
kv += [(f"LibRef{i}", n), (f"SectionKey{i}", k)]
|
|
814
|
+
tree["SectionKeys"] = schblock(params(kv))
|
|
815
|
+
tree["Storage"] = schblock("|HEADER=Icon storage")
|
|
816
|
+
return cfb.to_bytes(tree)
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def write_schlib(path, symbols):
|
|
820
|
+
from .fsutil import write_bytes_atomic
|
|
821
|
+
path = Path(path)
|
|
822
|
+
data = schlib_bytes(symbols)
|
|
823
|
+
if not (path.is_file() and path.read_bytes() == data): # unchanged: not rewritten
|
|
824
|
+
write_bytes_atomic(path, data)
|
|
825
|
+
return path
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
# ---------------------------------------------------------------- minimal reader (verification / dump)
|
|
829
|
+
def _blk(d, i):
|
|
830
|
+
n = struct.unpack_from("<I", d, i)[0] & 0xFFFFFF
|
|
831
|
+
return d[i + 4:i + 4 + n], i + 4 + n
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def _str(b):
|
|
835
|
+
return b[1:1 + b[0]].decode("cp1252") if b else ""
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def _param_stream(o, path):
|
|
839
|
+
return parse_param_bytes(_blk(o.openstream(path).read(), 0)[0])
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def parse_params(text):
|
|
843
|
+
out = {}
|
|
844
|
+
for kv in text.split("|"):
|
|
845
|
+
if "=" in kv:
|
|
846
|
+
k, v = kv.split("=", 1)
|
|
847
|
+
out[k.upper()] = v
|
|
848
|
+
return out
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def parse_param_bytes(b):
|
|
852
|
+
"""A |KEY=VALUE| block as Altium writes it: cp1252, except '%UTF8%KEY' twins (UTF-8) that hold the real text when
|
|
853
|
+
cp1252 can't (Altium then writes '?' in the plain KEY); the twin wins, under the plain key."""
|
|
854
|
+
out, utf = {}, {}
|
|
855
|
+
for kv in b.rstrip(b"\0").split(b"|"):
|
|
856
|
+
if b"=" in kv:
|
|
857
|
+
k, v = kv.split(b"=", 1)
|
|
858
|
+
k = k.decode("cp1252", "replace").upper()
|
|
859
|
+
if k.startswith("%UTF8%"):
|
|
860
|
+
utf[k[6:]] = v.decode("utf-8", "replace")
|
|
861
|
+
else:
|
|
862
|
+
out[k] = v.decode("cp1252", "replace")
|
|
863
|
+
out.update(utf)
|
|
864
|
+
return out
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
V7_AT = {"track": 41, "fill": 42, "arc": 52, "text": 226, "pad": 114} # v7 layer id offset in the record's (main) block
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def extended_layer(layer, block_, kind=None, params_=None):
|
|
871
|
+
"""Altium's layer id for a record on Mechanical 17..32 (66 + n, as Altium's layer enum and pyaltiumlib number them).
|
|
872
|
+
The one-byte layer of such records says Mechanical 16 (72); the real layer is in the v7 layer id (0x0102_00nn) of the
|
|
873
|
+
record's tail, or V7_LAYER=MECHANICALnn in a region / body's parameters. Other layers come back unchanged."""
|
|
874
|
+
if layer != MECH16:
|
|
875
|
+
return layer
|
|
876
|
+
n = 0
|
|
877
|
+
if params_ is not None:
|
|
878
|
+
m = re.search(r"(?:^|\|)V7_LAYER=MECHANICAL(\d+)", params_, re.I)
|
|
879
|
+
n = int(m.group(1)) if m else 0
|
|
880
|
+
elif kind in V7_AT and len(block_) >= V7_AT[kind] + 4:
|
|
881
|
+
v = struct.unpack_from("<I", block_, V7_AT[kind])[0]
|
|
882
|
+
n = v & 0xFFFF if v >> 16 == 0x0102 else 0
|
|
883
|
+
return 66 + n if 17 <= n <= 32 else layer
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def parse_pcb_data(d):
|
|
887
|
+
"""Footprint Data stream -> (name, [records]); each record keeps its raw blocks for re-encoding checks."""
|
|
888
|
+
b, i = _blk(d, 0)
|
|
889
|
+
name, recs = _str(b), []
|
|
890
|
+
while i < len(d):
|
|
891
|
+
t = d[i]; i += 1
|
|
892
|
+
if t == 2:
|
|
893
|
+
blocks = []
|
|
894
|
+
for _ in range(6):
|
|
895
|
+
b, i = _blk(d, i); blocks.append(b)
|
|
896
|
+
m = blocks[4]
|
|
897
|
+
x, y, w, h = struct.unpack_from("<4i", m, 13)
|
|
898
|
+
ss = blocks[5]
|
|
899
|
+
shape = ss[532] if len(ss) >= 596 and ss[531] else m[49]
|
|
900
|
+
recs.append({"type": "pad", "designator": _str(blocks[0]), "layer": extended_layer(m[0], m, "pad"), "x": x, "y": y, "w": w, "h": h,
|
|
901
|
+
"hole": struct.unpack_from("<i", m, 45)[0], "shape": shape, "rot": struct.unpack_from("<d", m, 52)[0],
|
|
902
|
+
"corner": ss[564] if len(ss) >= 596 and ss[531] else None,
|
|
903
|
+
"paste_mode": m[101] if len(m) > 102 else None, "paste": struct.unpack_from("<i", m, 86)[0] if len(m) > 90 else None,
|
|
904
|
+
"mask_mode": m[102] if len(m) > 102 else None, "mask": struct.unpack_from("<i", m, 90)[0] if len(m) > 94 else None,
|
|
905
|
+
"plated": bool(m[60]), "hole_shape": ss[262] if len(ss) >= 596 else 0,
|
|
906
|
+
"slot": struct.unpack_from("<i", ss, 263)[0] if len(ss) >= 596 else 0,
|
|
907
|
+
"hole_rot": struct.unpack_from("<d", ss, 267)[0] if len(ss) >= 596 else 0.0,
|
|
908
|
+
"offset": (struct.unpack_from("<i", ss, 275)[0], struct.unpack_from("<i", ss, 403)[0]) if len(ss) >= 596 else (0, 0),
|
|
909
|
+
"raw": blocks})
|
|
910
|
+
elif t == 5:
|
|
911
|
+
b1, i = _blk(d, i); b2, i = _blk(d, i)
|
|
912
|
+
x, y, h = struct.unpack_from("<3i", b1, 13)
|
|
913
|
+
recs.append({"type": "text", "layer": extended_layer(b1[0], b1, "text"), "text": _str(b2), "x": x, "y": y, "h": h, "font": struct.unpack_from("<h", b1, 25)[0],
|
|
914
|
+
"rot": struct.unpack_from("<d", b1, 27)[0], "mirror": bool(b1[35]), "w": struct.unpack_from("<i", b1, 36)[0],
|
|
915
|
+
"ws": struct.unpack_from("<i", b1, 115)[0], "box": struct.unpack_from("<2i", b1, 124), "just": b1[132], "raw": [b1, b2]})
|
|
916
|
+
elif t in (1, 3, 4, 6, 11, 12):
|
|
917
|
+
b, i = _blk(d, i)
|
|
918
|
+
r = {"type": {1: "arc", 3: "via", 4: "track", 6: "fill", 11: "region", 12: "body"}[t], "layer": b[0], "raw": [b]}
|
|
919
|
+
if t == 4:
|
|
920
|
+
r.update(zip(("x1", "y1", "x2", "y2", "w"), struct.unpack_from("<5i", b, 13)))
|
|
921
|
+
elif t == 6:
|
|
922
|
+
r.update(zip(("x1", "y1", "x2", "y2"), struct.unpack_from("<4i", b, 13)))
|
|
923
|
+
elif t == 1:
|
|
924
|
+
r.update(zip(("cx", "cy", "r", "a0", "a1", "w"), struct.unpack_from("<3i2di", b, 13)))
|
|
925
|
+
elif t in (11, 12):
|
|
926
|
+
pl = struct.unpack_from("<I", b, 18)[0]
|
|
927
|
+
n = struct.unpack_from("<I", b, 22 + pl)[0]
|
|
928
|
+
r["params"] = b[22:22 + pl - 1].decode("cp1252", "replace")
|
|
929
|
+
r["pts"] = [struct.unpack_from("<2d", b, 26 + pl + 16 * k) for k in range(n)]
|
|
930
|
+
r["layer"] = extended_layer(r["layer"], b, r["type"], r.get("params"))
|
|
931
|
+
recs.append(r)
|
|
932
|
+
else:
|
|
933
|
+
raise ValueError(f"unknown PCB record id {t} at {i - 1}")
|
|
934
|
+
return name, recs
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def read_pcblib(path):
|
|
938
|
+
"""Raises ValueError for a damaged or unknown-format file (OSError when it is not an OLE file at all)."""
|
|
939
|
+
import olefile
|
|
940
|
+
with olefile.OleFileIO(str(path)) as o:
|
|
941
|
+
try:
|
|
942
|
+
return _read_pcblib(o)
|
|
943
|
+
except (struct.error, zlib.error, IndexError, KeyError, EOFError) as e:
|
|
944
|
+
raise ValueError(f"{Path(path).name}: damaged or unsupported .PcbLib ({type(e).__name__}: {e})") from e
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def _read_pcblib(o):
|
|
948
|
+
d = o.openstream("Library/Data").read()
|
|
949
|
+
hb, i = _blk(d, 0)
|
|
950
|
+
n = struct.unpack_from("<I", d, i)[0]; i += 4
|
|
951
|
+
names = []
|
|
952
|
+
for _ in range(n):
|
|
953
|
+
b, i = _blk(d, i); names.append(_str(b))
|
|
954
|
+
keymap = {}
|
|
955
|
+
if o.exists("SectionKeys"):
|
|
956
|
+
s = o.openstream("SectionKeys").read(); k = struct.unpack_from("<I", s)[0]; j = 4
|
|
957
|
+
for _ in range(k):
|
|
958
|
+
a, j = _blk(s, j); b, j = _blk(s, j); keymap[_str(a)] = _str(b)
|
|
959
|
+
out = []
|
|
960
|
+
for nm in names:
|
|
961
|
+
key = keymap.get(nm, section_key(nm))
|
|
962
|
+
pr = _param_stream(o, f"{key}/Parameters")
|
|
963
|
+
nm2, recs = parse_pcb_data(o.openstream(f"{key}/Data").read())
|
|
964
|
+
out.append({"name": nm, "storage": key, "params": pr, "records": recs,
|
|
965
|
+
"header_count": struct.unpack("<I", o.openstream(f"{key}/Header").read())[0]})
|
|
966
|
+
if o.exists("Library/Models/Header") and out: # embedded STEP models: [(params, decompressed bytes)]
|
|
967
|
+
md, j, models = o.openstream("Library/Models/Data").read(), 0, []
|
|
968
|
+
while j < len(md):
|
|
969
|
+
b, j = _blk(md, j)
|
|
970
|
+
models.append((parse_param_bytes(b), zlib.decompress(o.openstream(f"Library/Models/{len(models)}").read())))
|
|
971
|
+
out[0]["models"] = models
|
|
972
|
+
return out
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def read_schlib(path):
|
|
976
|
+
"""Raises ValueError for a damaged or unknown-format file (OSError when it is not an OLE file at all)."""
|
|
977
|
+
import olefile
|
|
978
|
+
with olefile.OleFileIO(str(path)) as o:
|
|
979
|
+
try:
|
|
980
|
+
return _read_schlib(o)
|
|
981
|
+
except (struct.error, zlib.error, IndexError, KeyError, EOFError) as e:
|
|
982
|
+
raise ValueError(f"{Path(path).name}: damaged or unsupported .SchLib ({type(e).__name__}: {e})") from e
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
def _read_schlib(o):
|
|
986
|
+
hdr = _param_stream(o, "FileHeader")
|
|
987
|
+
keymap = {}
|
|
988
|
+
if o.exists("SectionKeys"):
|
|
989
|
+
sk = _param_stream(o, "SectionKeys")
|
|
990
|
+
keymap = {sk[f"LIBREF{i}"]: sk[f"SECTIONKEY{i}"] for i in range(int(sk["KEYCOUNT"]))}
|
|
991
|
+
out = []
|
|
992
|
+
for i in range(int(hdr.get("COMPCOUNT", 0))):
|
|
993
|
+
nm = hdr[f"LIBREF{i}"]
|
|
994
|
+
d = o.openstream(f"{keymap.get(nm, section_key(nm))}/Data").read()
|
|
995
|
+
j, recs = 0, []
|
|
996
|
+
while j < len(d):
|
|
997
|
+
n = struct.unpack_from("<I", d, j)[0]; flag, n = n >> 24, n & 0xFFFFFF
|
|
998
|
+
b = d[j + 4:j + 4 + n]; j += 4 + n
|
|
999
|
+
if flag == 1: # binary pin
|
|
1000
|
+
k = 12 + 1 + b[12]
|
|
1001
|
+
ftype, etype, cong, length, x, y = struct.unpack_from("<BBBhhh", b, k)
|
|
1002
|
+
k += 13
|
|
1003
|
+
strs = []
|
|
1004
|
+
for _ in range(5):
|
|
1005
|
+
strs.append(b[k + 1:k + 1 + b[k]].decode("cp1252")); k += 1 + b[k]
|
|
1006
|
+
recs.append({"RECORD": "2", "name": strs[0], "num": strs[1], "etype": etype, "orient": cong & 3,
|
|
1007
|
+
"x": x, "y": y, "length": length, "conglomerate": cong, "part": struct.unpack_from("<h", b, 5)[0],
|
|
1008
|
+
"mode": b[7], "symbols": tuple(b[8:12])})
|
|
1009
|
+
else:
|
|
1010
|
+
recs.append(parse_param_bytes(b))
|
|
1011
|
+
out.append({"name": nm, "records": recs})
|
|
1012
|
+
return hdr, out
|
|
1013
|
+
|
|
1014
|
+
|
|
1015
|
+
# ---------------------------------------------------------------- CLI
|
|
1016
|
+
def main(argv=None):
|
|
1017
|
+
ap = argparse.ArgumentParser(description="Write Altium .PcbLib/.SchLib files (one entry per file)")
|
|
1018
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
1019
|
+
p = sub.add_parser("pcblib", help="package JSON specs -> <out>/<name>.PcbLib")
|
|
1020
|
+
p.add_argument("specs", nargs="+"); p.add_argument("-o", "--out", default="out/altium_models/PcbLib")
|
|
1021
|
+
p.add_argument("--density", choices="ABC"); p.add_argument("--single", help="write all footprints into this one file")
|
|
1022
|
+
p.add_argument("--rules", help="library rules file: layers and density (default: ecadlib.toml found from here)")
|
|
1023
|
+
s = sub.add_parser("schlib", help="symbol JSON files -> <out>/<name>.SchLib")
|
|
1024
|
+
s.add_argument("symbols", nargs="+"); s.add_argument("-o", "--out", default="out/altium_models/SchLib")
|
|
1025
|
+
d = sub.add_parser("dump", help="print what the minimal reader sees in a .PcbLib/.SchLib")
|
|
1026
|
+
d.add_argument("files", nargs="+")
|
|
1027
|
+
a = ap.parse_args(argv)
|
|
1028
|
+
|
|
1029
|
+
if a.cmd == "dump":
|
|
1030
|
+
for f in a.files:
|
|
1031
|
+
if f.lower().endswith(".pcblib"):
|
|
1032
|
+
for fp in read_pcblib(f):
|
|
1033
|
+
pads = [r for r in fp["records"] if r["type"] == "pad"]
|
|
1034
|
+
kinds = {}
|
|
1035
|
+
for r in fp["records"]:
|
|
1036
|
+
kinds[(r["type"], r["layer"])] = kinds.get((r["type"], r["layer"]), 0) + 1
|
|
1037
|
+
print(f"{f}: {fp['name']} (storage {fp['storage']}) {len(pads)} pads, {dict(sorted(kinds.items()))}")
|
|
1038
|
+
for r in pads:
|
|
1039
|
+
hole = (f" layer {r['layer']} hole {to_mm(r['hole']):.4f}" + (f" slot {to_mm(r['slot']):.4f} rot {r['hole_rot']:g}" if r['hole_shape'] == 2 else
|
|
1040
|
+
f" square" if r['hole_shape'] == 1 else "") + ("" if r['plated'] else " NPTH")) if r['hole'] else ""
|
|
1041
|
+
print(f" pad {r['designator']:>3} ({to_mm(r['x']):8.4f},{to_mm(r['y']):8.4f}) {to_mm(r['w']):.4f}x{to_mm(r['h']):.4f} "
|
|
1042
|
+
f"rot {r['rot']:g} shape {r['shape']} corner {r['corner']} paste {'rule' if r['paste_mode'] == 1 else r['paste']}{hole}")
|
|
1043
|
+
for r in fp["records"]:
|
|
1044
|
+
if r["type"] == "arc":
|
|
1045
|
+
print(f" arc layer {r['layer']} c ({to_mm(r['cx']):.4f},{to_mm(r['cy']):.4f}) r {to_mm(r['r']):.4f} {r['a0']:g}..{r['a1']:g} w {to_mm(r['w']):.4f}")
|
|
1046
|
+
elif r["type"] == "text":
|
|
1047
|
+
print(f" text {r['text']} layer {r['layer']} at ({to_mm(r['x']):.4f},{to_mm(r['y']):.4f}) h {to_mm(r['h']):.4f} "
|
|
1048
|
+
f"stroke {to_mm(r['w']):.4f} rot {r['rot']:g} font {r['font']}")
|
|
1049
|
+
elif r["type"] == "body":
|
|
1050
|
+
bp = parse_params(r["params"])
|
|
1051
|
+
print(f" body {bp['V7_LAYER']} {len(r['pts'])} pts height {bp['OVERALLHEIGHT']} standoff {bp['STANDOFFHEIGHT']} "
|
|
1052
|
+
f"model type {bp['MODEL.MODELTYPE']} {bp.get('MODEL.NAME') or ''}")
|
|
1053
|
+
for mp, d in fp.get("models", []):
|
|
1054
|
+
print(f" model {mp['NAME']} {len(d)} B checksum {mp['CHECKSUM']} (computed {step_checksum(d)})")
|
|
1055
|
+
else:
|
|
1056
|
+
_, syms = read_schlib(f)
|
|
1057
|
+
for sy in syms:
|
|
1058
|
+
pins = [r for r in sy["records"] if r["RECORD"] == "2"]
|
|
1059
|
+
modes = int(sy["records"][0].get("DISPLAYMODECOUNT", 1))
|
|
1060
|
+
print(f"{f}: {sy['name']} {len(pins)} pins" + (f" in {modes} display modes" if modes > 1 else "") + ": " + ", ".join(
|
|
1061
|
+
f"{r['num']}:{r['name']}" + (f"[{','.join(map(str, r['symbols']))}]" if any(r["symbols"]) else "")
|
|
1062
|
+
+ (f"@{r['mode']}" if r["mode"] else "") for r in pins))
|
|
1063
|
+
return
|
|
1064
|
+
out = Path(a.out); out.mkdir(parents=True, exist_ok=True)
|
|
1065
|
+
if a.cmd == "pcblib":
|
|
1066
|
+
from . import footprint_gen as F, rules as RU
|
|
1067
|
+
rules = RU.load(a.rules, start=out)
|
|
1068
|
+
lay = RU.layers(rules)
|
|
1069
|
+
fps = []
|
|
1070
|
+
for sp in a.specs:
|
|
1071
|
+
spec = json.loads(Path(sp).read_text())
|
|
1072
|
+
F.validate(spec)
|
|
1073
|
+
fp, rep = footprint_from_spec(spec, RU.density_for(spec, rules, a.density))
|
|
1074
|
+
fps.append(fp)
|
|
1075
|
+
if not a.single:
|
|
1076
|
+
path = write_pcblib(out / f"{file_stem(fp['name'])}.PcbLib", [fp], lay)
|
|
1077
|
+
print(f"{fp['name']:<44} {len(fp['pads']):>3} pads {len(fp['tracks']):>3} tracks {len(fp['fills'])} fills "
|
|
1078
|
+
f"{len(fp['regions'])} regions -> {path} ({path.stat().st_size} B)")
|
|
1079
|
+
if a.single:
|
|
1080
|
+
print(f"{len(fps)} footprints -> {write_pcblib(out / a.single, fps, lay)}")
|
|
1081
|
+
else:
|
|
1082
|
+
for f in a.symbols:
|
|
1083
|
+
sym = symbol_from_json(json.loads(Path(f).read_text()))
|
|
1084
|
+
path = write_schlib(out / f"{file_stem(sym['name'])}.SchLib", [sym])
|
|
1085
|
+
print(f"{sym['name']:<24} {sum(1 for p in sym['prims'] if p[0] == 'pin'):>3} pins -> {path}")
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
if __name__ == "__main__":
|
|
1089
|
+
main()
|