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.
Files changed (199) hide show
  1. ecadlib/__init__.py +3 -0
  2. ecadlib/__main__.py +5 -0
  3. ecadlib/a365_sync.py +637 -0
  4. ecadlib/agent.py +1407 -0
  5. ecadlib/agent_queue.py +780 -0
  6. ecadlib/agent_watch.py +732 -0
  7. ecadlib/altium365.py +385 -0
  8. ecadlib/altium_dblib_sync.py +530 -0
  9. ecadlib/altium_export.py +209 -0
  10. ecadlib/altium_forms.py +768 -0
  11. ecadlib/altium_lib.py +1089 -0
  12. ecadlib/altium_menu.py +1751 -0
  13. ecadlib/altium_pkg.py +396 -0
  14. ecadlib/altium_project.py +1095 -0
  15. ecadlib/app.py +1458 -0
  16. ecadlib/approvals.py +395 -0
  17. ecadlib/assets/demo_bom.csv +8 -0
  18. ecadlib/assets/kicad_action_plugin.py +607 -0
  19. ecadlib/assets/kicad_reread.py +60 -0
  20. ecadlib/assets/pcblib_template.json.gz +0 -0
  21. ecadlib/bom_inputs.py +339 -0
  22. ecadlib/bom_triage.py +519 -0
  23. ecadlib/cad_model.py +528 -0
  24. ecadlib/cfb.py +171 -0
  25. ecadlib/cli.py +185 -0
  26. ecadlib/clihelp.py +149 -0
  27. ecadlib/cliutil.py +119 -0
  28. ecadlib/completion.py +173 -0
  29. ecadlib/costs.py +276 -0
  30. ecadlib/crosscheck.py +412 -0
  31. ecadlib/datasheet.py +2168 -0
  32. ecadlib/dedupe.py +572 -0
  33. ecadlib/detect.py +170 -0
  34. ecadlib/doctor.py +450 -0
  35. ecadlib/ds_route.py +541 -0
  36. ecadlib/eagle_export.py +1409 -0
  37. ecadlib/easy_setup.py +338 -0
  38. ecadlib/easyeda_convert.py +386 -0
  39. ecadlib/easyeda_pro_export.py +972 -0
  40. ecadlib/eval/__init__.py +0 -0
  41. ecadlib/eval/baselines/kicad-v1.json +404 -0
  42. ecadlib/eval/build_set.py +299 -0
  43. ecadlib/eval/cli.py +119 -0
  44. ecadlib/eval/gate.py +358 -0
  45. ecadlib/eval/golden/demo.txt +379 -0
  46. ecadlib/eval/golden/specs.txt +3120 -0
  47. ecadlib/eval/golden/testpack.txt +463 -0
  48. ecadlib/eval/golden.py +446 -0
  49. ecadlib/eval/replay/kicad-v1.json.gz +0 -0
  50. ecadlib/eval/runner.py +456 -0
  51. ecadlib/eval/score.py +179 -0
  52. ecadlib/eval/sets/kicad-v1.json +630 -0
  53. ecadlib/firstrun.py +400 -0
  54. ecadlib/footprint_adv.py +1006 -0
  55. ecadlib/footprint_bench.py +218 -0
  56. ecadlib/footprint_check.py +562 -0
  57. ecadlib/footprint_gen.py +582 -0
  58. ecadlib/footprint_rules.py +1092 -0
  59. ecadlib/forge.py +267 -0
  60. ecadlib/fp_share.py +95 -0
  61. ecadlib/fp_tidy.py +389 -0
  62. ecadlib/fsutil.py +62 -0
  63. ecadlib/house_style.py +1051 -0
  64. ecadlib/httpcache.py +124 -0
  65. ecadlib/hub.py +1490 -0
  66. ecadlib/hub_copy.py +161 -0
  67. ecadlib/hub_tools.py +79 -0
  68. ecadlib/kicad_dblib.py +257 -0
  69. ecadlib/kicad_export.py +837 -0
  70. ecadlib/kicad_import.py +913 -0
  71. ecadlib/kicad_index.py +160 -0
  72. ecadlib/kicad_menu.py +193 -0
  73. ecadlib/kicad_pcm.py +475 -0
  74. ecadlib/launcher.py +156 -0
  75. ecadlib/librarian.py +963 -0
  76. ecadlib/lifecycle.py +691 -0
  77. ecadlib/llm/__init__.py +276 -0
  78. ecadlib/llm/cli.py +70 -0
  79. ecadlib/llm/openai_compat.py +214 -0
  80. ecadlib/llm/profiles.py +245 -0
  81. ecadlib/llm/rails.py +570 -0
  82. ecadlib/llm/run.py +126 -0
  83. ecadlib/llm/sampling.py +49 -0
  84. ecadlib/mcp_server.py +723 -0
  85. ecadlib/messages.py +419 -0
  86. ecadlib/migrate.py +407 -0
  87. ecadlib/notify.py +329 -0
  88. ecadlib/package_schema.json +1238 -0
  89. ecadlib/package_specs/CP_EIA-3216-18_Kemet-A.json +27 -0
  90. ecadlib/package_specs/C_0402_1005Metric.json +22 -0
  91. ecadlib/package_specs/C_0805_2012Metric.json +22 -0
  92. ecadlib/package_specs/DFN-8-1EP_3x3mm_P0.5mm_EP1.66x2.38mm.json +40 -0
  93. ecadlib/package_specs/D_SMA.json +27 -0
  94. ecadlib/package_specs/D_SMB.json +27 -0
  95. ecadlib/package_specs/D_SOD-123.json +43 -0
  96. ecadlib/package_specs/D_SOD-323.json +43 -0
  97. ecadlib/package_specs/LED_0603_1608Metric.json +23 -0
  98. ecadlib/package_specs/LED_0805_2012Metric.json +23 -0
  99. ecadlib/package_specs/LQFP-48_7x7mm_P0.5mm.json +31 -0
  100. ecadlib/package_specs/L_0805_2012Metric.json +22 -0
  101. ecadlib/package_specs/MSOP-8_3x3mm_P0.65mm.json +31 -0
  102. ecadlib/package_specs/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm.json +39 -0
  103. ecadlib/package_specs/QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm.json +37 -0
  104. ecadlib/package_specs/R_0402_1005Metric.json +22 -0
  105. ecadlib/package_specs/R_0603_1608Metric.json +22 -0
  106. ecadlib/package_specs/R_0805_2012Metric.json +22 -0
  107. ecadlib/package_specs/R_1206_3216Metric.json +22 -0
  108. ecadlib/package_specs/SOIC-16_3.9x9.9mm_P1.27mm.json +32 -0
  109. ecadlib/package_specs/SOIC-8_3.9x4.9mm_P1.27mm.json +32 -0
  110. ecadlib/package_specs/SOT-223-3_TabPin2.json +56 -0
  111. ecadlib/package_specs/SOT-23-5.json +57 -0
  112. ecadlib/package_specs/SOT-23-6.json +62 -0
  113. ecadlib/package_specs/SOT-23.json +48 -0
  114. ecadlib/package_specs/TSSOP-16_4.4x5mm_P0.65mm.json +31 -0
  115. ecadlib/package_specs/VQFN-16-1EP_3x3mm_P0.5mm_EP1.68x1.68mm.json +39 -0
  116. ecadlib/package_specs/advanced/BGA-100_11.0x11.0mm_Layout10x10_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  117. ecadlib/package_specs/advanced/BGA-121_9.0x9.0mm_Layout11x11_P0.8mm_Ball0.4mm_Pad0.35mm_NSMD.json +26 -0
  118. ecadlib/package_specs/advanced/BGA-144_7.0x7.0mm_Layout13x13_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +29 -0
  119. ecadlib/package_specs/advanced/BGA-169_11.0x11.0mm_Layout13x13_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +23 -0
  120. ecadlib/package_specs/advanced/BGA-256_11.0x11.0mm_Layout20x20_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +26 -0
  121. ecadlib/package_specs/advanced/BGA-256_17.0x17.0mm_Layout16x16_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  122. ecadlib/package_specs/advanced/BGA-324_15.0x15.0mm_Layout18x18_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  123. ecadlib/package_specs/advanced/BGA-36_3.396x3.466mm_Layout6x6_P0.4mm_Ball0.25mm_Pad0.2mm_NSMD.json +24 -0
  124. ecadlib/package_specs/advanced/BGA-672_27.0x27.0mm_Layout26x26_P1.0mm_Ball0.6mm_Pad0.5mm_NSMD.json +32 -0
  125. ecadlib/package_specs/advanced/BGA-68_5.0x5.0mm_Layout9x9_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +30 -0
  126. ecadlib/package_specs/advanced/BGA-90_8.0x13.0mm_Layout2x3x15_P0.8mm.json +26 -0
  127. ecadlib/package_specs/advanced/DFN-8-1EP_3x3mm_P0.5mm_EP1.65x2.38mm_ThermalVias.json +60 -0
  128. ecadlib/package_specs/advanced/Example_Custom_LPad_Slot.json +24 -0
  129. ecadlib/package_specs/advanced/JST_GH_SM04B-GHS-TB_1x04-1MP_P1.25mm_Horizontal.json +49 -0
  130. ecadlib/package_specs/advanced/JST_PH_B4B-PH-K_1x04_P2.00mm_Vertical.json +33 -0
  131. ecadlib/package_specs/advanced/JST_PH_S4B-PH-SM4-TB_1x04-1MP_P2.00mm_Horizontal.json +55 -0
  132. ecadlib/package_specs/advanced/JST_SH_SM04B-SRSS-TB_1x04-1MP_P1.00mm_Horizontal.json +49 -0
  133. ecadlib/package_specs/advanced/JST_XH_B2B-XH-A_1x02_P2.50mm_Vertical.json +34 -0
  134. ecadlib/package_specs/advanced/JST_XH_B4B-XH-A_1x04_P2.50mm_Vertical.json +34 -0
  135. ecadlib/package_specs/advanced/JST_XH_B6B-XH-A_1x06_P2.50mm_Vertical.json +33 -0
  136. ecadlib/package_specs/advanced/LFBGA-144_10x10mm_Layout12x12_P0.8mm.json +23 -0
  137. ecadlib/package_specs/advanced/Molex_KK-254_AE-6410-04A_1x04_P2.54mm_Vertical.json +34 -0
  138. ecadlib/package_specs/advanced/Molex_PicoBlade_53398-0471_1x04-1MP_P1.25mm_Vertical.json +54 -0
  139. ecadlib/package_specs/advanced/PinHeader_1x05_P1.00mm_Vertical_SMD_Pin1Left.json +14 -0
  140. ecadlib/package_specs/advanced/PinHeader_1x05_P1.27mm_Vertical_SMD_Pin1Left.json +14 -0
  141. ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical.json +12 -0
  142. ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical_SMD_Pin1Left.json +14 -0
  143. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical.json +12 -0
  144. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Left.json +25 -0
  145. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Right.json +14 -0
  146. ecadlib/package_specs/advanced/PinHeader_1x10_P1.00mm_Vertical.json +15 -0
  147. ecadlib/package_specs/advanced/PinHeader_2x05_P1.00mm_Vertical_SMD.json +13 -0
  148. ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical.json +18 -0
  149. ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical_SMD.json +24 -0
  150. ecadlib/package_specs/advanced/PinHeader_2x05_P2.00mm_Vertical_SMD.json +13 -0
  151. ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical.json +12 -0
  152. ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical_SMD.json +24 -0
  153. ecadlib/package_specs/advanced/PinHeader_2x20_P2.54mm_Vertical.json +12 -0
  154. ecadlib/package_specs/advanced/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm_ThermalVias.json +87 -0
  155. ecadlib/package_specs/advanced/UFBGA-132_7x7mm_Layout12x12_P0.5mm.json +43 -0
  156. ecadlib/package_specs/advanced/USB_C_Receptacle_HRO_TYPE-C-31-M-12.json +218 -0
  157. ecadlib/package_specs/standard.json.gz +0 -0
  158. ecadlib/parity.py +1204 -0
  159. ecadlib/parts_export.py +154 -0
  160. ecadlib/partview.py +215 -0
  161. ecadlib/plugins.py +231 -0
  162. ecadlib/providers.py +226 -0
  163. ecadlib/rebuild.py +471 -0
  164. ecadlib/release_check.py +437 -0
  165. ecadlib/reports.py +274 -0
  166. ecadlib/resolve.py +556 -0
  167. ecadlib/review_cli.py +292 -0
  168. ecadlib/rules.py +979 -0
  169. ecadlib/rules_infer.py +1205 -0
  170. ecadlib/sexpr.py +149 -0
  171. ecadlib/sheetsafe.py +65 -0
  172. ecadlib/sim_models.py +1087 -0
  173. ecadlib/sourcing.py +1161 -0
  174. ecadlib/stdparts.py +755 -0
  175. ecadlib/step_gen.py +929 -0
  176. ecadlib/symbol_layout.py +798 -0
  177. ecadlib/team.py +558 -0
  178. ecadlib/team_setup.py +288 -0
  179. ecadlib/term.py +420 -0
  180. ecadlib/term_views.py +64 -0
  181. ecadlib/testpack.py +334 -0
  182. ecadlib/ui/__init__.py +1 -0
  183. ecadlib/ui/__main__.py +3 -0
  184. ecadlib/ui/server.py +1029 -0
  185. ecadlib/ui/static/hairline.js +2 -0
  186. ecadlib/ui/static/index.html +2098 -0
  187. ecadlib/ui/static/setup.html +294 -0
  188. ecadlib/upkeep.py +717 -0
  189. ecadlib/upstream.py +573 -0
  190. ecadlib/userconfig.py +257 -0
  191. ecadlib/visual_check.py +1603 -0
  192. ecadlib/ws_status.py +203 -0
  193. ecadlib-0.3.0.dist-info/METADATA +2245 -0
  194. ecadlib-0.3.0.dist-info/RECORD +199 -0
  195. ecadlib-0.3.0.dist-info/WHEEL +5 -0
  196. ecadlib-0.3.0.dist-info/entry_points.txt +2 -0
  197. ecadlib-0.3.0.dist-info/licenses/LICENSE +21 -0
  198. ecadlib-0.3.0.dist-info/licenses/NOTICE +53 -0
  199. 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()