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
@@ -0,0 +1,798 @@
1
+ """Tidy symbols for big chips: pins grouped by function and laid out like a person would draw them.
2
+
3
+ ecadlib symbol PINS.csv|PINS.json|PART.SchLib|LIB.kicad_sym:NAME|kicad:Lib:Name -o out.SchLib [--kicad out.kicad_sym]
4
+ [--preview out.svg] [--split N] [--stack stack|hide|none] [--rules ecadlib.toml]
5
+
6
+ The house rule: inputs left, outputs right, power on top, ground on the bottom ([symbols] power_side = "top"), or
7
+ the top and bottom edges left empty with the power pins at the top of the left side and the ground pins at its
8
+ bottom (power_side = "left"). Each pin is put in a functional group from its name (and type when the name says nothing):
9
+ power, analog (VDDA, VREF+) -> top (power_side "left": top of the left side)
10
+ ground (GND, VSS, AGND, VREF-), ep (exposed pad) -> bottom (power_side "left": bottom of the left side)
11
+ reset (NRST, BOOT0, EN), clock (OSC_IN, XTAL), debug (SWDIO, TCK, U0TXD), usb, adc, inputs (IN+, TRIG, FB, type
12
+ input) -> left
13
+ power_out (VOUT, VCAP), uart, i2c, spi, can, eth, ports (PA0..PE15, GPIO12, P0.1), outputs (OUT, Q, PG, SW, type
14
+ output), nc -> right
15
+ other (no name rule and no direction: passive, bidirectional) -> left, moved right when that keeps the symbol square
16
+ Alternate-function names ("PA9/USART1_TX", "PH0-OSC_IN") are grouped by their first (primary) name; overbars
17
+ ('~{RESET}', 'R\\E\\S\\E\\T\\', '/RESET', 'RESET#') are ignored for grouping. Port pins are sorted by port and
18
+ number and each port is its own block; blocks are separated by one empty grid row. Ports (and unclassified pins)
19
+ and 'other' pins move between the left and right side to keep the symbol about square, whole ports first; inputs and
20
+ outputs never move.
21
+
22
+ Power pins sharing a name (VDD x5) are stacked (rules [symbols] stack_power): 'stack' puts them on one spot, one
23
+ pin showing its name and number and the rest drawn on top of it with name and number hidden (Altium joins pins at
24
+ one point; the KiCad export writes the rest as hidden passive pins, KLC S4.3); 'hide' makes the rest hidden pins
25
+ tied to the net by name (Altium HiddenNetName, KiCad hidden power_in); 'none' draws every pin.
26
+
27
+ Above [symbols] split_pins pins the part becomes a multi-part symbol: part A holds the power and ground pins, the
28
+ other parts the signal blocks in order, at most unit_pins pins each (a port is never split across parts unless it
29
+ alone is bigger). Everything is on the [symbols] grid (100 mil): body edges, pin ends, pin pitch.
30
+
31
+ altium_lib.ic_symbol uses this for parts with at least [symbols] layout_pins pins (default 1: every IC; above that the
32
+ smaller parts keep the plain 'side per pin' layout). librarian re-lays out EasyEDA IC symbols the same way, and a KiCad
33
+ library symbol whose pin text would overlap in Altium (text_problems). datasheet.py can call arrange_pins() to show the
34
+ grouping in its review."""
35
+ import argparse, csv, json, re, sys
36
+ from pathlib import Path
37
+
38
+ from . import altium_lib as AL
39
+
40
+ GROUPS = ("power", "analog", "power_out", "ground", "ep", "reset", "clock", "debug", "usb", "adc", "other_in",
41
+ "other", "uart", "i2c", "spi", "can", "eth", "ports", "other_out", "nc")
42
+ ORDER = ["power", "analog", "reset", "clock", "debug", "usb", "adc", "other_in", "other", "power_out", "uart", "i2c",
43
+ "spi", "can", "eth", "ports", "other_out", "nc", "ground", "ep"]
44
+ SIDES = {"power": "top", "analog": "top", "power_out": "right", "ground": "bottom", "ep": "bottom",
45
+ "reset": "left", "clock": "left", "debug": "left", "usb": "left", "adc": "left", "other_in": "left",
46
+ "other": "left", "uart": "right", "i2c": "right", "spi": "right", "can": "right", "eth": "right",
47
+ "ports": "right", "other_out": "right", "nc": "right"}
48
+ POWER_GROUPS = {"power", "analog", "power_out", "ground", "ep"}
49
+ FLEX = ("ports", "other") # may move between left and right to balance the symbol
50
+ STACKS = ("stack", "hide", "none")
51
+ POWER_SIDES = ("top", "left")
52
+ DIRECTED = {"input": "other_in", "output": "other_out", "open_collector": "other_out", "open_emitter": "other_out",
53
+ "tristate": "other_out", "hiz": "other_out"} # [symbols] power_side: the house rule's two variants
54
+ SPLIT_PENALTY = 6 # rows a port split across sides must save before it is used
55
+ CHAR = 7 # name width per character, DXP units (as altium_lib.ic_symbol)
56
+
57
+ _R = lambda s: re.compile(r"^(?:" + s + r")$")
58
+ RX = [ # (group, regex on the primary name, upper case, overbar / active-low markers removed); first match wins
59
+ ("nc", _R(r"N\.?C\.?\d*|DNC|DNU|NU|RESERVED\d*|RSVD\d*|NOT ?CONNECTED")),
60
+ ("ep", _R(r"EP|E?PAD|EXPOSED[ _]?PAD|THERMAL[ _]?PAD|TAB|SLUG")),
61
+ ("ground", _R(r"[ADPSE]?GND[A-Z0-9_]*|GND[ADPS]?|VSS[A-Z0-9_]*|V?REF-|VREFN|VREF_?N|VEE|V-|0V|AVSS|DVSS|PVSS")),
62
+ ("analog", _R(r"VDDA[A-Z0-9_]*|AVDD[A-Z0-9_]*|AVCC|VCCA|V?REF\+?|VREFP|VREF_?P|VREF_?OUT|VREFBUF")),
63
+ ("power_out", _R(r"V?OUT|VO|VREG[A-Z0-9_]*|VCAP[A-Z0-9_]*|VDDCORE_?OUT|\d+V\d*_?OUT|V\d+V?\d*_?OUT|LDO_?OUT|VCORE|DECOUPLE")),
64
+ ("usb", _R(r"USB[A-Z0-9_+\-]*|D[PM]\d?|D[+\-]|UD[+\-PM]|VBUS|USB_?ID|OTG[A-Z0-9_]*|CC[12]")),
65
+ ("power", _R(r"VDD[A-Z0-9_]*|VCC[A-Z0-9_]*|VBAT[A-Z0-9_]*|VIN\d*|VS|V\+|VPP|VIO|VM|VSYS|DVDD|IOVDD|DVCC|PVDD|PVIN|"
66
+ r"V\d+V\d*|\d+V\d*|VBACKUP|VDDIO\d*|VBATT|BAT|VLOGIC|VL")),
67
+ ("reset", _R(r"N?RST[A-Z0-9_]*|N?RESET[A-Z0-9_]*|MCLR|N?MCLR|EN|CHIP_?EN|CHIP_?PU|N?CE|BOOT\d*|BOOT_?SEL\d*|PDR_?ON|"
68
+ r"MODE\d*|TEST\d*|TST|N?WAKE[A-Z0-9_]*|PWR_?EN|N?SLEEP|N?SHDN|SHUTDOWN|N?STBY|N?PD|PWRDN|N?PWRDWN|N?SUSPEND|NMI")),
69
+ ("clock", _R(r"OSC[A-Z0-9_]*|XTAL[A-Z0-9_]*|XI|XO|XIN|XOUT|X[12]|CLK_?IN|CLK_?OUT|CLKO?|MCO\d*|EXT_?CLK|XT[IO]|"
70
+ r"RTC_?X[A-Z0-9_]*|32K_?X[PN]|OSC32[A-Z0-9_]*|LSE[A-Z_]*|HSE[A-Z_]*")),
71
+ ("debug", _R(r"SWDIO|SWCLK|SWO|SWD[A-Z0-9_]*|TMS|TCK|TDI|TDO|N?TRST|JT[A-Z0-9_]*|DBG[A-Z0-9_]*|TRACE[A-Z0-9_]*|"
72
+ r"U0TXD|U0RXD|UART0_?TXD?|UART0_?RXD?|TXD0|RXD0|SWIM|PDI|UPDI|ICSP[A-Z]*|PGE?[CD]\d?")),
73
+ ("uart", _R(r"L?P?US?ART[A-Z0-9_]*|TXD?\d*|RXD?\d*|N?RTS\d*|N?CTS\d*|N?DTR|N?DSR|N?DCD|N?RI|TXEN|TXDEN|RS485[A-Z_]*|"
74
+ r"TXLED|RXLED|N?TXLED|N?RXLED")),
75
+ ("i2c", _R(r"I2C[A-Z0-9_]*|SDA\d*|SCL\d*|TWI[A-Z0-9_]*|SMB[A-Z0-9_]*|IIC[A-Z0-9_]*")),
76
+ ("spi", _R(r"Q?SPI[A-Z0-9_]*|MOSI\d*|MISO\d*|SCK\d*|SCLK\d*|N?CS\d*|N?SS\d*|N?SEL|SDI|SDO|SIO\d|SD[0-3]|CMD|"
77
+ r"FLASH[A-Z0-9_]*|N?HOLD|N?WP")),
78
+ ("can", _R(r"F?D?CAN[A-Z0-9_]*|TXCAN|RXCAN")),
79
+ ("eth", _R(r"ETH[A-Z0-9_]*|R?G?MII[A-Z0-9_]*|MDIO|MDC|T[XD][PN+\-]|R[XD][PN+\-]|TXP|TXN|RXP|RXN|PHY[A-Z0-9_]*")),
80
+ ("adc", _R(r"ADC[A-Z0-9_]*|AIN\d*|AN\d+|DAC[A-Z0-9_]*|AOUT\d*|SENSOR_?V[PN]|SENSOR[A-Z0-9_]*|VP|VN|AIN[PN]\d*")),
81
+ # direction from the name, for the small analog / power / logic parts whose pins have no better group
82
+ ("other_out", _R(r"OUT[A-Z0-9_+\-]*|[A-Z0-9]+_?OUT[A-Z0-9]*|N?Q\d*|Q_?[NB]|DIS|DISCH[A-Z]*|N?PG|N?PGOOD|P_?GOOD|PWR_?(GD|GOOD|OK)|"
83
+ r"N?FLT|N?FAULT|N?FLAG|N?ALERT|N?INT\d*|N?IRQ\d*|RDY|N?READY|BUSY|DONE|STAT\d*|LED[A-Z0-9_]*|SW\d*|LX\d*|"
84
+ r"BST|BOOST|PH|DRV[A-Z0-9_]*|GATE[A-Z0-9_]*|[HL]O|[HL]G|ALARM|TRIP")),
85
+ ("other_in", _R(r"IN\d*|IN[+\-PNAB]\d*|[+\-]IN[A-Z0-9]*|IN_?[PN]\d*|[A-Z0-9]+_IN|TRIG[A-Z]*|TRG|TR|THR[A-Z]*|CV|CONT[A-Z]*|"
86
+ r"CTRL[A-Z0-9_]*|CTL|FB\d*|VFB|ADJ|SET|SENSE[A-Z0-9_+\-]*|SNS[A-Z0-9_+\-]*|CS[+\-]|SS|SS_?TR|ILIM|ISET|RT|"
87
+ r"RT_?CLK|COMP|DIR|STEP|PWM[A-Z0-9_]*|[HL]I|D|J|K|S|R|C?LR|PRE|IREF|RSET|RLIM|SEL\d*|A\d?|B\d?")),
88
+ ]
89
+ PORT_RX = [re.compile(r"^P([A-Z])(\d{1,2})$"), re.compile(r"^P(\d{1,2})[._](\d{1,2})$"), re.compile(r"^PIO(\d)_(\d{1,2})$"),
90
+ re.compile(r"^(GPIO|IO|GP)(\d{1,3})$"), re.compile(r"^R(\d)(\d)$")]
91
+
92
+
93
+ # ------------------------------------------------------------------ names
94
+ def plain(name):
95
+ """Pin name without overbar markup: '~{RESET}' / 'R\\E\\S\\E\\T\\' -> 'RESET'."""
96
+ s = re.sub(r"~\{([^}]*)\}", r"\1", str(name or ""))
97
+ return s.replace("\\", "").replace("~", "")
98
+
99
+
100
+ def primary(name):
101
+ """Primary function of an alternate-function name: 'PA9/USART1_TX' -> 'PA9', 'PH0-OSC_IN' -> 'PH0'."""
102
+ s = plain(name).strip()
103
+ if re.fullmatch(r"(?i)n/?c|not connected", s):
104
+ return "NC"
105
+ if s.startswith("/") and len(s) > 1: # '/RESET' active low
106
+ s = s[1:]
107
+ tok = re.split(r"[/,;(\s]", s, maxsplit=1)[0] or s
108
+ m = re.match(r"^(P[A-Z]\d{1,2}|GPIO\d+|IO\d+)[-_](?=[A-Z])", tok, re.I)
109
+ return m.group(1) if m else tok
110
+
111
+
112
+ def _key(name):
113
+ """Primary name for matching: upper case, active-low suffixes removed ('RESET#', 'RST_N', 'RESET_B')."""
114
+ s = primary(name).upper()
115
+ if len(s) > 2 and not re.fullmatch(r".*(OUT|IN)\d*_[AB]", s): # OUT_B / IN_B: channel B of a dual part, not active low
116
+ s = re.sub(r"(#|\*|_N|_B|_L)$", "", s)
117
+ return s
118
+
119
+
120
+ def port_of(name):
121
+ """(port, number) for a port pin name ('PA9' -> ('A', 9), 'GPIO12' -> ('IO', 12), 'P0.3' -> ('0', 3)); else None."""
122
+ s = _key(name)
123
+ for rx in PORT_RX:
124
+ m = rx.match(s)
125
+ if m:
126
+ port, num = m.groups()
127
+ return ("IO" if port in ("GPIO", "IO", "GP") else port), int(num)
128
+ return None
129
+
130
+
131
+ def classify(pin):
132
+ """-> functional group (see GROUPS). pin: {'name', 'type', 'num', optional 'group'} (an explicit 'group' wins)."""
133
+ if pin.get("group") in GROUPS:
134
+ return pin["group"]
135
+ k, t = _key(pin.get("name", "")), str(pin.get("type") or "").lower()
136
+ num = str(pin.get("num", "")).upper()
137
+ if t == "nc":
138
+ return "nc"
139
+ if port_of(pin.get("name", "")):
140
+ return "ports"
141
+ for g, rx in RX:
142
+ if rx.match(k):
143
+ if g == "usb" and k == "VBUS" and t in ("power", "power_in"):
144
+ return "power"
145
+ if g in ("other_in", "other_out") and t in DIRECTED: # a name only hints the direction: the type says it
146
+ return DIRECTED[t]
147
+ return g
148
+ if num in ("EP", "PAD", "EPAD", "TAB"):
149
+ return "ep"
150
+ if t == "ground":
151
+ return "ground"
152
+ if t in ("power", "power_in"):
153
+ return "power"
154
+ if t == "power_out":
155
+ return "power_out"
156
+ return DIRECTED.get(t, "other")
157
+
158
+
159
+ def _natural(s):
160
+ return [int(x) if x.isdigit() else x for x in re.split(r"(\d+)", str(s))]
161
+
162
+
163
+ def stack_key(name):
164
+ """Pins whose names give the same key may be one net: 'VDD_1', 'VDD_2', 'VDD' -> 'VDD'. Only an index after a
165
+ separator is dropped: 'VDD1' / 'VDD2' (an isolator's two sides) and 'VDD18' / 'VDD33' are different supplies."""
166
+ return re.sub(r"[_\-]\d+$", "", _key(name)) or _key(name)
167
+
168
+
169
+ def stack_keys(names):
170
+ """name -> stacking key for the power pins of one group. 'VDD_1'..'VDD_5' (instance numbers 1..n or 0..n-1) share
171
+ the key 'VDD'; 'VDD_18' + 'VDD_33' are not instance numbers and keep their own names (never stacked together)."""
172
+ idx = {}
173
+ for n in names:
174
+ m = re.fullmatch(r"(.+?)[_\-](\d+)", _key(n))
175
+ if m:
176
+ idx.setdefault(m.group(1), set()).add(int(m.group(2)))
177
+ ok = {st for st, ix in idx.items() if ix in (set(range(1, len(ix) + 1)), set(range(len(ix))))}
178
+ out = {}
179
+ for n in names:
180
+ m = re.fullmatch(r"(.+?)[_\-](\d+)", _key(n))
181
+ out[n] = m.group(1) if m and m.group(1) in ok else _key(n)
182
+ return out
183
+
184
+
185
+ # ------------------------------------------------------------------ settings
186
+ def settings(rules=None):
187
+ """[symbols] settings: rules may be a loaded rules dict, a dict of [symbols] overrides, or None (defaults)."""
188
+ from . import rules as RU
189
+ s = dict(RU.DEFAULTS["symbols"])
190
+ if isinstance(rules, dict):
191
+ over = rules.get("symbols") if isinstance(rules.get("symbols"), dict) else {k: v for k, v in rules.items() if k in s}
192
+ s.update(over or {})
193
+ return s
194
+
195
+
196
+ def wants_layout(pins, rules=None):
197
+ """True when a part with these pins (or this many) gets the grouped layout."""
198
+ n = pins if isinstance(pins, int) else len(pins)
199
+ t = settings(rules)["layout_pins"]
200
+ return bool(t) and n >= t
201
+
202
+
203
+ # ------------------------------------------------------------------ arrangement
204
+ def _by_channel(items):
205
+ """A dual / quad part's pins one channel at a time: 1IN-, 1IN+, 2IN-, 2IN+ (not 1IN-, 2IN-, 1IN+, 2IN+). Only
206
+ when two or more names differ just by a channel number that is not at the end of the name (D0..D7 / A0..A7 are
207
+ buses and keep their order) and every name has the same channels. items: [(_, index, pin)] sorted by stem."""
208
+ split = [re.fullmatch(r"(\D*)(\d+)(\D+)", _key(t[2].get("name", ""))) for t in items]
209
+ if len(items) < 4 or not all(split):
210
+ return items
211
+ stems = {}
212
+ for m in split:
213
+ stems.setdefault(m.group(1) + "#" + m.group(3), set()).add(int(m.group(2)))
214
+ chans = list(stems.values())
215
+ if len(stems) < 2 or len(chans[0]) < 2 or any(c != chans[0] for c in chans):
216
+ return items
217
+ order = {st: i for i, st in enumerate(stems)}
218
+ key = {id(t): (int(m.group(2)), order[m.group(1) + "#" + m.group(3)]) for t, m in zip(items, split)}
219
+ return sorted(items, key=lambda t: key[id(t)])
220
+
221
+
222
+ def _blocks(pins, s):
223
+ """pins -> ordered blocks {group, label, side, slots: [[lead pin, stacked...], ...]}."""
224
+ order = {g: i for i, g in enumerate(list(s["order"]) + [g for g in ORDER if g not in s["order"]])}
225
+ sides = dict(SIDES, **(s.get("sides") or {}))
226
+ by = {}
227
+ for i, p in enumerate(pins):
228
+ g = classify(p)
229
+ port = port_of(p.get("name", "")) if g == "ports" else None
230
+ sub = port[0] if port else ""
231
+ by.setdefault((g, sub), []).append((port[1] if port else i, i, p))
232
+ blocks = []
233
+ for (g, sub), items in sorted(by.items(), key=lambda kv: (order[kv[0][0]], _natural(kv[0][1]))):
234
+ if g == "ports":
235
+ items.sort(key=lambda t: (t[0], _natural(t[2].get("num", ""))))
236
+ else: # numbered names in number order (CBUS0..4, SD0..3, VSS_1..5),
237
+ stem = (lambda n: stack_key(n)) if g in POWER_GROUPS else (lambda n: re.sub(r"\d+", "#", _key(n)))
238
+ stems = {} # otherwise the order given (TXD, RXD, RTS, CTS ...)
239
+ for t in items:
240
+ stems.setdefault(stem(t[2].get("name", "")), t[1])
241
+ items.sort(key=lambda t: (stems[stem(t[2].get("name", ""))], _natural(_key(t[2].get("name", ""))), t[1]))
242
+ if g not in POWER_GROUPS:
243
+ items = _by_channel(items)
244
+ slots, at, keys = [], {}, []
245
+ sk = stack_keys([t[2].get("name", "") for t in items])
246
+ for _, _, p in items:
247
+ k = sk[p.get("name", "")]
248
+ if g in POWER_GROUPS and s["stack_power"] != "none" and k in at:
249
+ slots[at[k]].append(p)
250
+ else:
251
+ at[k] = len(slots)
252
+ slots.append([p])
253
+ keys.append(k)
254
+ blocks.append({"group": g, "label": f"P{sub}" if sub and sub.isalpha() and len(sub) == 1 else sub or g,
255
+ "side": sides[g], "slots": slots, "nets": {id(sl): k for sl, k in zip(slots, keys)}})
256
+ return blocks
257
+
258
+
259
+ def _npins(blocks):
260
+ return sum(len(sl) for b in blocks for sl in b["slots"])
261
+
262
+
263
+ def _height(blocks):
264
+ """Rows a side takes: slots plus one gap between blocks (pieces of one block that follow each other: no gap)."""
265
+ rows = sum(len(b["slots"]) for b in blocks)
266
+ return rows + sum(1 for a, b in zip(blocks, blocks[1:]) if not (b.get("piece") and b["piece"][0] == id(a.get("src", a))))
267
+
268
+
269
+ def _pieces(b, step=8):
270
+ """A flexible block cut where it may be split between the sides (every `step` slots, blocks of 16+ slots)."""
271
+ n = len(b["slots"])
272
+ if n < 16:
273
+ return [b]
274
+ src = b.get("src", b)
275
+ return [dict(b, slots=b["slots"][i:i + step], piece=(id(src), i), src=src) for i in range(0, n, step)]
276
+
277
+
278
+ def _balance(blocks, power_side="top"):
279
+ """Assign flexible blocks to left/right: left = fixed left + flex[:k], right = flex[k:] + fixed right. Ties keep
280
+ 'other' pins (no direction known) on the left with the inputs. power_side "left": the top blocks go first and the
281
+ bottom blocks last on the left side, and the top and bottom edges stay empty."""
282
+ top = [b for b in blocks if b["side"] == "top"]
283
+ bot = [b for b in blocks if b["side"] == "bottom"]
284
+ fixed_l = [b for b in blocks if b["side"] == "left" and b["group"] not in FLEX]
285
+ fixed_r = [b for b in blocks if b["side"] == "right" and b["group"] not in FLEX]
286
+ flex = [b for b in blocks if b["group"] in FLEX and b["side"] in ("left", "right")]
287
+ head, tail = (top, bot) if power_side == "left" else ([], [])
288
+ pieces = [pc for b in flex for pc in _pieces(b)]
289
+ best = None
290
+ for k in range(len(pieces) + 1):
291
+ left, right = head + fixed_l + pieces[:k] + tail, pieces[k:] + fixed_r
292
+ split = 0 < k < len(pieces) and pieces[k].get("piece") and pieces[k]["piece"][1] > 0
293
+ hl, hr = _height(left), _height(right)
294
+ moved = sum(1 for pc in pieces[k:] if pc["group"] == "other")
295
+ cost = (max(hl, hr) + (SPLIT_PENALTY if split else 0), abs(hl - hr), moved, k)
296
+ if best is None or cost < best[0]:
297
+ best = (cost, left, right)
298
+ _, left, right = best
299
+ if power_side == "left":
300
+ return {"left": left, "right": right, "top": [], "bottom": []}
301
+ return {"left": left, "right": right, "top": top, "bottom": bot}
302
+
303
+
304
+ def _chunk(block, size):
305
+ return [dict(block, slots=block["slots"][i:i + size]) for i in range(0, len(block["slots"]), size)] if size else [block]
306
+
307
+
308
+ def arrange(pins, rules=None, split=None):
309
+ """pins -> [unit {'unit': n, 'sides': {side: [block, ...]}}]. split: True/False forces multi-part on/off."""
310
+ s = settings(rules)
311
+ blocks = _blocks(pins, s)
312
+ big = s["split_pins"] and len(pins) > s["split_pins"]
313
+ ps = s.get("power_side", "top")
314
+ if not (big if split is None else split):
315
+ return [{"unit": 1, "sides": _balance(blocks, ps)}]
316
+ pw = [b for b in blocks if b["side"] in ("top", "bottom") or b["group"] in POWER_GROUPS or b["group"] == "nc"] # part A
317
+ sig = [b for b in blocks if b not in pw]
318
+ cap = max(8, s["unit_pins"])
319
+ units, cur = [], []
320
+ for b in sig:
321
+ for c in (_chunk(b, cap) if _npins([b]) > cap else [b]):
322
+ if cur and _npins(cur) + _npins([c]) > cap:
323
+ units.append(cur); cur = []
324
+ cur.append(c)
325
+ if cur:
326
+ units.append(cur)
327
+ out = [{"unit": 1, "sides": _balance(pw, ps)}] if pw else []
328
+ return out + [{"unit": len(out) + i + 1, "sides": _balance(u, ps)} for i, u in enumerate(units)]
329
+
330
+
331
+ def arrange_pins(pins, rules=None, split=None):
332
+ """For datasheet.py and reviews: the pins in layout order, each a copy with 'group', 'side', 'unit' (1 = part A)
333
+ and 'stacked_on' (the pin number it is stacked on, for stacked power pins)."""
334
+ out = []
335
+ for u in arrange(pins, rules, split):
336
+ for side in ("left", "right", "top", "bottom"):
337
+ for b in u["sides"][side]:
338
+ for sl in b["slots"]:
339
+ for j, p in enumerate(sl):
340
+ out.append(dict(p, group=b["group"], side=side, unit=u["unit"], **({"stacked_on": sl[0].get("num")} if j else {})))
341
+ return out
342
+
343
+
344
+ # ------------------------------------------------------------------ geometry
345
+ def _up(v, g):
346
+ return g * -(-int(v) // g)
347
+
348
+
349
+ def _rows(blocks):
350
+ """[slot or None (gap)] down one side."""
351
+ out = []
352
+ for i, b in enumerate(blocks):
353
+ if i and not (b.get("piece") and b["piece"][0] == id(blocks[i - 1].get("src", blocks[i - 1]))):
354
+ out.append(None)
355
+ out += b["slots"]
356
+ return out
357
+
358
+
359
+ def _nlen(p):
360
+ return len(plain(p.get("name", "")))
361
+
362
+
363
+ def build(name, pins, description="", designator="U?", footprint=None, comment="*", rules=None, split=None):
364
+ """Grouped multi-block (and optionally multi-part) IC symbol, as an altium_lib symbol dict."""
365
+ bad = [p.get("num") for p in pins if not str(p.get("num", "")).strip()]
366
+ if bad:
367
+ raise ValueError(f"{name}: {len(bad)} pins have no number")
368
+ s = settings(rules)
369
+ g, L = s["grid"] // 10, s["pin_length"] // 10
370
+ units = arrange(pins, rules, split)
371
+ prims, parts = [], []
372
+ tallest = (0, 0)
373
+ for u in units:
374
+ sd = u["sides"]
375
+ nets = {k: v for bl in sd.values() for b in bl for k, v in b.get("nets", {}).items()}
376
+ left, right, top, bot = _rows(sd["left"]), _rows(sd["right"]), _rows(sd["top"]), _rows(sd["bottom"])
377
+ names = lambda rows: max([_nlen(sl[0]) for sl in rows if sl] or [0])
378
+ lch, rch = names(left), names(right)
379
+ t_need = _up(names(top) * CHAR + 10, g) if top else 0 # vertical names of top / bottom pins
380
+ b_need = _up(names(bot) * CHAR + 10, g) if bot else 0
381
+ nrows = max(len(left), len(right), 1)
382
+ cols = max(len(top), len(bot))
383
+ def shape(w): # -> (w + h, w, h, top room, bottom room, x0s)
384
+ W = w // g
385
+ fits = lambda rows, x0: (x0 - g // 2 > lch * CHAR + g // 2 # clear of the side names
386
+ and x0 + g * (len(rows) - 1) + g // 2 < w - rch * CHAR - g // 2)
387
+ x0s = {}
388
+ for rows in (top, bot): # centred, else just right of the left names
389
+ c = [g * max(1, (W - len(rows) + 1) // 2), _up(lch * CHAR + g + 1, g)]
390
+ x0s[id(rows)] = next((x for x in c if fits(rows, x)), c[0])
391
+ clear = lambda rows: not rows or fits(rows, x0s[id(rows)])
392
+ tr, br = (0 if clear(top) else t_need), (0 if clear(bot) else b_need) # side pins start below them only if they meet
393
+ h = max(tr + g * (nrows + 1) + br, t_need + b_need + 2 * g)
394
+ return w + h, w, h, tr, br, x0s
395
+ w0 = max(4 * g, _up(lch * CHAR + rch * CHAR + 2 * g, g), g * (cols + 1))
396
+ wide = _up(lch * CHAR + rch * CHAR + 2 * g, g) + g * (cols + 2) # wide enough for the top/bottom pins between names
397
+ _, w, h, t_room, b_room, x0s = min(shape(w0), shape(max(w0, wide)), key=lambda t: t[:3])
398
+ prims.append(("rect", 0, 0, w, -h)); parts.append(u["unit"])
399
+ tallest = max(tallest, (h, w))
400
+
401
+ def put(sl, side, x, y):
402
+ for j, p in enumerate(sl):
403
+ q = dict(p)
404
+ if j:
405
+ if s["stack_power"] == "hide":
406
+ q.update(hidden=True, net=q.get("net") or nets.get(id(sl)) or stack_key(p.get("name", "")))
407
+ else:
408
+ q.update(show_name=False, show_num=False)
409
+ prims.append(("pin", q, side, x, y, L)); parts.append(u["unit"])
410
+ for i, sl in enumerate(left):
411
+ if sl:
412
+ put(sl, "left", 0, -t_room - g * (i + 1))
413
+ for i, sl in enumerate(right):
414
+ if sl:
415
+ put(sl, "right", w, -t_room - g * (i + 1))
416
+ for rows, side, y in ((top, "top", 0), (bot, "bottom", -h)):
417
+ x0 = x0s[id(rows)]
418
+ for j, sl in enumerate(rows):
419
+ if sl:
420
+ put(sl, side, x0 + g * j, y)
421
+ h, _ = tallest
422
+ top_pins = any(u["sides"]["top"] for u in units)
423
+ bot_pins = any(u["sides"]["bottom"] for u in units)
424
+ sym = {"name": name, "description": description, "designator": designator, "comment": comment, "footprint": footprint,
425
+ "prims": prims, "label_at": ((0, (L + 2) if top_pins else 2), (0, -h - (L + 8 if bot_pins else 8)))}
426
+ if len(units) > 1:
427
+ sym.update(parts=len(units), prim_parts=parts)
428
+ return sym
429
+
430
+
431
+ def relayout(sym, rules=None, split=None):
432
+ """An altium_lib symbol dict (e.g. converted from EasyEDA) -> the grouped layout, keeping its name, labels,
433
+ footprint and parameters. Pins keep number, name, type, shape. Only single-part symbols are re-laid out."""
434
+ if sym.get("parts", 1) > 1 or any(sym.get("prim_modes") or []):
435
+ return sym
436
+ pins, seen = [], set()
437
+ for pr in sym["prims"]:
438
+ if pr[0] == "pin" and pr[1]["num"] not in seen:
439
+ seen.add(pr[1]["num"])
440
+ pins.append({k: v for k, v in pr[1].items() if k in ("num", "name", "type", "shape", "description")})
441
+ new = build(sym["name"], pins, sym.get("description", ""), sym.get("designator", "U?"), sym.get("footprint"),
442
+ sym.get("comment", "*"), rules, split)
443
+ if sym.get("params"):
444
+ new["params"] = sym["params"]
445
+ return new
446
+
447
+
448
+ # ------------------------------------------------------------------ checks
449
+ def check(sym, grid=100):
450
+ """Problems in a laid-out symbol: pins off the grid, two different pins at one spot (other than stacked power
451
+ pins with the same name), a pin whose end or line lies on another pin's line, pin lines crossing, pin text that
452
+ overlaps (text_problems). -> [text]."""
453
+ g = grid // 10
454
+ probs, at, segs = [], {}, []
455
+ parts = sym.get("prim_parts") or [1] * len(sym["prims"])
456
+ for pr, u in zip(sym["prims"], parts):
457
+ if pr[0] != "pin":
458
+ continue
459
+ _, p, side, x, y, L, *_ = pr
460
+ dx, dy = {"left": (-1, 0), "right": (1, 0), "top": (0, 1), "bottom": (0, -1)}[side]
461
+ ex, ey = x + dx * L, y + dy * L
462
+ if any(v % g for v in (x, y, ex, ey)):
463
+ probs.append(f"pin {p['num']} off the {grid} mil grid at ({x}, {y})")
464
+ key = (u, x, y)
465
+ if key in at and stack_key(at[key]["name"]) != stack_key(p["name"]):
466
+ probs.append(f"pins {at[key]['num']} and {p['num']} overlap at ({x}, {y}) in part {u}")
467
+ at.setdefault(key, p)
468
+ segs.append((u, p, (x, y), (ex, ey)))
469
+ # a pin whose end or body lies on another pin's line, or two pin lines crossing (stacked pins share one line: fine)
470
+ on = lambda q, a, b: min(a[0], b[0]) <= q[0] <= max(a[0], b[0]) and min(a[1], b[1]) <= q[1] <= max(a[1], b[1]) and \
471
+ (b[0] - a[0]) * (q[1] - a[1]) == (b[1] - a[1]) * (q[0] - a[0])
472
+ for i, (u, p, a, b) in enumerate(segs):
473
+ for v, q, c, d in segs[i + 1:]:
474
+ if u != v or (a, b) == (c, d):
475
+ continue
476
+ if on(c, a, b) or on(d, a, b) or on(a, c, d) or on(b, c, d) or _cross(a, b, c, d):
477
+ probs.append(f"pin {q['num']} lies on or crosses pin {p['num']}'s line in part {u}")
478
+ return probs + text_problems(sym)
479
+
480
+
481
+ # pin text as Altium draws it (default 10 pt pin font), DXP units (10 mil): a little wide, so a symbol that passes looks
482
+ # clear in Altium and KiCad. The layout itself sizes bodies with CHAR (wider still).
483
+ TXT_W, TXT_H, TXT_GAP = 6, 8, 3
484
+ CONNECTOR_PREFIX = ("J", "P", "CN", "CON", "X", "XS", "XP", "JP", "SW", "S", "K", "T") # pin order is the drawing
485
+
486
+
487
+ def _boxes(sym):
488
+ """[(part, what, (x0, y0, x1, y1))] for every shown pin name and number, and {part: body rect}."""
489
+ parts = sym.get("prim_parts") or [1] * len(sym["prims"])
490
+ body, out = {}, []
491
+ for pr, u in zip(sym["prims"], parts):
492
+ if pr[0] == "rect":
493
+ r = (min(pr[1], pr[3]), min(pr[2], pr[4]), max(pr[1], pr[3]), max(pr[2], pr[4]))
494
+ if u not in body or (r[2] - r[0]) * (r[3] - r[1]) > (body[u][2] - body[u][0]) * (body[u][3] - body[u][1]):
495
+ body[u] = r
496
+ pos = {}
497
+ for pr, u in zip(sym["prims"], parts):
498
+ if pr[0] == "pin":
499
+ pos.setdefault((u, pr[2], pr[3] if pr[2] in ("left", "right") else pr[4]), set()).add(pr[4] if pr[2] in ("left", "right") else pr[3])
500
+ gaps = [b - a for v in pos.values() for a, b in zip(sorted(v), sorted(v)[1:])]
501
+ f = min(1.0, min(gaps or [10]) / 10) # a 50 mil pitch symbol is drawn with a font half as big
502
+ tw, th, tg = TXT_W * f, TXT_H * f, TXT_GAP * f
503
+ for pr, u in zip(sym["prims"], parts):
504
+ if pr[0] != "pin":
505
+ continue
506
+ _, p, side, x, y, L, *show = pr
507
+ if p.get("hidden"):
508
+ continue
509
+ sn = p.get("show_name", show[0] if show else True) and plain(p.get("name", "")) not in ("", "~")
510
+ sd = p.get("show_num", show[0] if show else True) and str(p.get("num", ""))
511
+ n, m, h = len(plain(p.get("name", ""))) * tw, len(str(p.get("num", ""))) * tw, th / 2
512
+ if side in ("left", "right"):
513
+ s = 1 if side == "left" else -1 # +1: the body is to the right of the pin's end
514
+ if sn:
515
+ a = x + s * tg
516
+ out.append((u, f"pin {p['num']} name", (min(a, a + s * n), y - h, max(a, a + s * n), y + h)))
517
+ if sd:
518
+ mx = x - s * L / 2
519
+ out.append((u, f"pin {p['num']} number", (mx - m / 2, y + f, mx + m / 2, y + f + th)))
520
+ else:
521
+ s = -1 if side == "top" else 1 # names run down from a top pin, up from a bottom one
522
+ if sn:
523
+ a = y + s * tg
524
+ out.append((u, f"pin {p['num']} name", (x - h, min(a, a + s * n), x + h, max(a, a + s * n))))
525
+ if sd:
526
+ my = y - s * L / 2
527
+ out.append((u, f"pin {p['num']} number", (x - f - th, my - m / 2, x - f, my + m / 2)))
528
+ return out, body
529
+
530
+
531
+ def text_problems(sym):
532
+ """Pin text that would overlap in Altium: two pin names or numbers on top of each other, or a pin name running
533
+ out of the body (the estimate: TXT_W per character, TXT_H high). -> [text]."""
534
+ out, body = _boxes(sym)
535
+ probs = []
536
+ hit = lambda a, b: a[0] < b[2] - 0.5 and b[0] < a[2] - 0.5 and a[1] < b[3] - 0.5 and b[1] < a[3] - 0.5
537
+ for i, (u, w, a) in enumerate(out):
538
+ r = body.get(u)
539
+ if r and w.endswith("name") and (a[0] < r[0] - 0.5 or a[2] > r[2] + 0.5 or a[1] < r[1] - 0.5 or a[3] > r[3] + 0.5):
540
+ probs.append(f"{w} runs out of the body" + (f" in part {u}" if len(body) > 1 else ""))
541
+ for v, w2, b in out[i + 1:]:
542
+ if u == v and hit(a, b):
543
+ probs.append(f"{w} overlaps {w2}" + (f" in part {u}" if len(body) > 1 else ""))
544
+ return probs
545
+
546
+
547
+ def is_box_ic(sym):
548
+ """True for a plain IC symbol: designator U / IC (or 8+ pins and not a connector), one part, a rectangle body with every pin on its edge, and nothing
549
+ else drawn but small marks (a pin 1 dot, text). Those can be re-laid out by the house rule without losing a drawing;
550
+ connectors, transistors, op-amp triangles and logic gates cannot."""
551
+ if sym.get("parts", 1) > 1 or any(sym.get("prim_modes") or []):
552
+ return False
553
+ pre = re.sub(r"[^A-Z]", "", str(sym.get("designator", "")).upper())
554
+ rects = [pr for pr in sym["prims"] if pr[0] == "rect"]
555
+ pins = [pr for pr in sym["prims"] if pr[0] == "pin"]
556
+ if len(rects) != 1 or len(pins) < 3 or pre in CONNECTOR_PREFIX or (pre not in ("U", "IC") and len(pins) < 8):
557
+ return False
558
+ x0, x1 = sorted((rects[0][1], rects[0][3]))
559
+ y0, y1 = sorted((rects[0][2], rects[0][4]))
560
+ if any(pr[0] in ("line", "polyline", "polygon", "arc", "bezier") or pr[0] == "circle" and pr[3] > 10
561
+ for pr in sym["prims"]):
562
+ return False
563
+ edge = {"left": lambda x, y: abs(x - x0) < 1 and y0 <= y <= y1, "right": lambda x, y: abs(x - x1) < 1 and y0 <= y <= y1,
564
+ "top": lambda x, y: abs(y - y1) < 1 and x0 <= x <= x1, "bottom": lambda x, y: abs(y - y0) < 1 and x0 <= x <= x1}
565
+ return all(edge[pr[2]](pr[3], pr[4]) for pr in pins)
566
+
567
+
568
+ def _cross(a, b, c, d):
569
+ """Axis-aligned segments a-b and c-d (one horizontal, one vertical) cross in their interiors."""
570
+ if (a[0] == b[0]) == (c[0] == d[0]):
571
+ return False
572
+ (h1, h2), (v1, v2) = ((a, b), (c, d)) if a[1] == b[1] else ((c, d), (a, b))
573
+ return min(h1[0], h2[0]) < v1[0] < max(h1[0], h2[0]) and min(v1[1], v2[1]) < h1[1] < max(v1[1], v2[1])
574
+
575
+
576
+ # ------------------------------------------------------------------ SVG preview
577
+ def svg(sym, scale=2.0):
578
+ """SVG drawing of an altium_lib symbol dict (every part side by side): body, pins, names, numbers."""
579
+ parts = sym.get("prim_parts") or [1] * len(sym["prims"])
580
+ units = sorted(set(parts))
581
+ S, out, xoff = scale, [], 0.0
582
+ fs = 7 * S
583
+ boxes = []
584
+ for u in units:
585
+ prs = [pr for pr, pu in zip(sym["prims"], parts) if pu == u]
586
+ xs, ys = [], []
587
+ for pr in prs:
588
+ if pr[0] == "rect":
589
+ xs += [pr[1], pr[3]]; ys += [pr[2], pr[4]]
590
+ elif pr[0] == "pin":
591
+ _, p, side, x, y, L, *_ = pr
592
+ dx, dy = {"left": (-1, 0), "right": (1, 0), "top": (0, 1), "bottom": (0, -1)}[side]
593
+ xs += [x, x + dx * L]; ys += [y, y + dy * L]
594
+ x0, x1, y0, y1 = min(xs or [0]) - 10, max(xs or [0]) + 10, min(ys or [0]) - 10, max(ys or [0]) + 20
595
+ boxes.append((u, prs, x0, x1, y0, y1, xoff))
596
+ xoff += (x1 - x0) + 30
597
+ W, H = xoff * S, max(b[5] - b[4] for b in boxes) * S + 20
598
+ esc = lambda t: str(t).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
599
+ out.append(f'<svg xmlns="http://www.w3.org/2000/svg" width="{W:.0f}" height="{H:.0f}" viewBox="0 0 {W:.0f} {H:.0f}" '
600
+ f'font-family="Arial, sans-serif" font-size="{fs:.0f}">')
601
+ out.append(f'<rect width="{W:.0f}" height="{H:.0f}" fill="#ffffff"/>')
602
+ for u, prs, x0, x1, y0, y1, xo in boxes:
603
+ X = lambda x: (x - x0 + xo) * S
604
+ Y = lambda y: (y1 - y) * S + 10
605
+ if len(units) > 1:
606
+ out.append(f'<text x="{X(x0 + 2):.1f}" y="{Y(y1 - 8):.1f}" fill="#555">{esc(sym["name"])} part {chr(64 + u)}</text>')
607
+ for pr in prs:
608
+ if pr[0] == "rect":
609
+ out.append(f'<rect x="{X(min(pr[1], pr[3])):.1f}" y="{Y(max(pr[2], pr[4])):.1f}" width="{abs(pr[3] - pr[1]) * S:.1f}" '
610
+ f'height="{abs(pr[4] - pr[2]) * S:.1f}" fill="#ffffb0" stroke="#800000" stroke-width="1.5"/>')
611
+ elif pr[0] == "line":
612
+ out.append(f'<line x1="{X(pr[1]):.1f}" y1="{Y(pr[2]):.1f}" x2="{X(pr[3]):.1f}" y2="{Y(pr[4]):.1f}" stroke="#000080"/>')
613
+ elif pr[0] in ("polyline", "polygon"):
614
+ pts = " ".join(f"{X(a):.1f},{Y(b):.1f}" for a, b in pr[1])
615
+ out.append(f'<{pr[0]} points="{pts}" fill="none" stroke="#000080"/>')
616
+ elif pr[0] == "circle":
617
+ out.append(f'<circle cx="{X(pr[1]):.1f}" cy="{Y(pr[2]):.1f}" r="{pr[3] * S:.1f}" fill="none" stroke="#000080"/>')
618
+ elif pr[0] == "text":
619
+ out.append(f'<text x="{X(pr[1]):.1f}" y="{Y(pr[2]):.1f}">{esc(pr[3])}</text>')
620
+ for pr in prs:
621
+ if pr[0] != "pin":
622
+ continue
623
+ _, p, side, x, y, L, *show = pr
624
+ if p.get("hidden"):
625
+ continue
626
+ dx, dy = {"left": (-1, 0), "right": (1, 0), "top": (0, 1), "bottom": (0, -1)}[side]
627
+ ex, ey = x + dx * L, y + dy * L
628
+ out.append(f'<line x1="{X(x):.1f}" y1="{Y(y):.1f}" x2="{X(ex):.1f}" y2="{Y(ey):.1f}" stroke="#000080" stroke-width="1.2"/>')
629
+ out.append(f'<circle cx="{X(ex):.1f}" cy="{Y(ey):.1f}" r="1.2" fill="#000080"/>')
630
+ sn = p.get("show_name", show[0] if show else True) and p.get("name") not in ("", "~")
631
+ sd = p.get("show_num", show[0] if show else True)
632
+ nm, num = esc(plain(p.get("name", ""))), esc(p.get("num", ""))
633
+ over = ' text-decoration="overline"' if re.search(r"~\{|\\", str(p.get("name", ""))) else ""
634
+ if dy == 0:
635
+ tx = X(x) + (6 if dx < 0 else -6)
636
+ anchor = "start" if dx < 0 else "end"
637
+ if sn:
638
+ out.append(f'<text x="{tx:.1f}" y="{Y(y) + fs / 3:.1f}" text-anchor="{anchor}"{over}>{nm}</text>')
639
+ if sd:
640
+ mx = (X(x) + X(ex)) / 2
641
+ out.append(f'<text x="{mx:.1f}" y="{Y(y) - 2:.1f}" text-anchor="middle" fill="#800000" font-size="{fs * 0.85:.0f}">{num}</text>')
642
+ else:
643
+ tx, ty = X(x) + fs / 3, Y(y) + (6 if dy > 0 else -6)
644
+ anchor = "end" if dy > 0 else "start"
645
+ if sn:
646
+ out.append(f'<text x="{tx:.1f}" y="{ty:.1f}" text-anchor="{anchor}" transform="rotate(-90 {tx:.1f} {ty:.1f})"'
647
+ f'{over}>{nm}</text>')
648
+ if sd:
649
+ my = (Y(y) + Y(ey)) / 2
650
+ out.append(f'<text x="{X(x) - 2:.1f}" y="{my:.1f}" text-anchor="middle" fill="#800000" font-size="{fs * 0.85:.0f}" '
651
+ f'transform="rotate(-90 {X(x) - 2:.1f} {my:.1f})">{num}</text>')
652
+ out.append("</svg>")
653
+ return "\n".join(out) + "\n"
654
+
655
+
656
+ # ------------------------------------------------------------------ inputs
657
+ ETYPE_NAME = {0: "input", 1: "io", 2: "output", 3: "open_collector", 4: "passive", 5: "tristate", 6: "open_emitter", 7: "power"}
658
+ TYPE_ALIASES = {"i": "input", "in": "input", "o": "output", "out": "output", "bidirectional": "io", "i/o": "io", "inout": "io",
659
+ "pwr": "power", "power_input": "power_in", "power_output": "power_out", "gnd": "ground", "tri_state": "tristate",
660
+ "unspecified": "passive", "free": "passive", "no_connect": "nc", "": "passive", "p": "power", "analog": "passive"}
661
+
662
+
663
+ def _ptype(t):
664
+ t = str(t or "").strip().lower().replace(" ", "_")
665
+ t = TYPE_ALIASES.get(t, t)
666
+ return t if t in AL.ETYPE else "passive"
667
+
668
+
669
+ def pins_from_csv(path):
670
+ """CSV/TSV with a header: number (num/pin/#), name, optional type, group. -> pins."""
671
+ text = Path(path).read_text(encoding="utf-8-sig")
672
+ dialect = "excel-tab" if "\t" in text.splitlines()[0] else "excel"
673
+ rows = list(csv.DictReader(text.splitlines(), dialect=dialect))
674
+ pick = lambda r, *ks: next((str(r[k]).strip() for k in r if k and k.strip().lower() in ks and r[k] is not None), "")
675
+ out = []
676
+ for r in rows:
677
+ num, name = pick(r, "num", "number", "pin", "#", "pin number", "pin_number"), pick(r, "name", "pin name", "signal", "pin_name")
678
+ if not num:
679
+ continue
680
+ p = {"num": num, "name": name or num, "type": _ptype(pick(r, "type", "electrical", "electrical type", "etype", "dir"))}
681
+ grp = pick(r, "group")
682
+ if grp:
683
+ p["group"] = grp
684
+ out.append(p)
685
+ return out
686
+
687
+
688
+ def pins_from_schlib(path, index=0):
689
+ _, syms = AL.read_schlib(path)
690
+ sy = syms[index]
691
+ slot_names = {v: k for k, v in AL.PIN_SYMBOL.items()}
692
+ pins, seen = [], set()
693
+ for r in sy["records"]:
694
+ if r.get("RECORD") == "2" and r["num"] not in seen and not r["mode"]:
695
+ seen.add(r["num"])
696
+ d = {sl: slot_names.get(v, v) for sl, v in zip(AL.PIN_SLOTS, r["symbols"]) if v}
697
+ shape = next((k for k, v in AL.PIN_SHAPE.items() if v == d), None)
698
+ p = {"num": r["num"], "name": _kicad_name(r["name"]), "type": ETYPE_NAME.get(r["etype"], "passive")}
699
+ if shape and shape != "line":
700
+ p["shape"] = shape
701
+ pins.append(p)
702
+ return sy["name"], pins
703
+
704
+
705
+ def _kicad_name(s):
706
+ """Altium 'R\\E\\S\\E\\T\\' -> '~{RESET}' so altium_lib.overline writes it back the same."""
707
+ from .kicad_export import _kicad_pin_name
708
+ return _kicad_pin_name(s)
709
+
710
+
711
+ def load_pins(src, http=None):
712
+ """CSV / JSON (pin list, datasheet extract or symbol JSON) / .SchLib / LIB.kicad_sym:NAME / kicad:Lib:Name ->
713
+ (name, pins, extra symbol fields)."""
714
+ s = str(src)
715
+ low = s.lower()
716
+ if low.endswith((".csv", ".tsv", ".txt")):
717
+ return Path(s).stem, pins_from_csv(s), {}
718
+ if low.endswith(".json"):
719
+ d = json.loads(Path(s).read_text(encoding="utf-8"))
720
+ if isinstance(d, list):
721
+ return Path(s).stem, d, {}
722
+ d = d.get("symbol", d) if isinstance(d.get("symbol"), dict) else d
723
+ extra = {k: d[k] for k in ("description", "designator", "footprint") if d.get(k)}
724
+ return d.get("name") or d.get("mpn") or Path(s).stem, d["pins"], extra
725
+ if low.endswith(".schlib"):
726
+ name, pins = pins_from_schlib(s)
727
+ return name, pins, {}
728
+ from . import kicad_import as K
729
+ ref = s[6:] if low.startswith("kicad:") else s
730
+ chain, _ = K.load_symbol(ref, http if low.startswith("kicad:") else None)
731
+ sym = K.convert_symbol(chain, None, None, [], None, [])
732
+ pins, seen = [], set()
733
+ for pr in sym["prims"]:
734
+ if pr[0] == "pin" and pr[1]["num"] not in seen:
735
+ seen.add(pr[1]["num"])
736
+ pins.append({k: v for k, v in pr[1].items() if k in ("num", "name", "type", "shape")})
737
+ return str(sym["name"]).strip('"'), pins, {k: sym[k] for k in ("description", "designator") if sym.get(k)}
738
+
739
+
740
+ def main(argv=None):
741
+ ap = argparse.ArgumentParser(prog="ecadlib symbol", description="group a pin list by function and write a tidy symbol "
742
+ "(Altium .SchLib, optional KiCad .kicad_sym and SVG preview)")
743
+ ap.add_argument("source", help="pins: .csv/.tsv (num,name[,type][,group]), .json (pin list / datasheet extract / symbol JSON), "
744
+ ".SchLib (re-layout its first symbol), LIB.kicad_sym:NAME or kicad:Lib:Name")
745
+ ap.add_argument("-o", "--out", help="output .SchLib (default: <name>.SchLib)")
746
+ ap.add_argument("--name", help="symbol name (default: from the source)")
747
+ ap.add_argument("--kicad", help="also write this .kicad_sym")
748
+ ap.add_argument("--preview", help="write an SVG preview here")
749
+ ap.add_argument("--split", type=int, help="multi-part above this many pins (0 = never; default: rules [symbols] split_pins)")
750
+ ap.add_argument("--stack", choices=STACKS, help="same-name power pins (default: rules [symbols] stack_power)")
751
+ ap.add_argument("--footprint", help="footprint name to link")
752
+ ap.add_argument("--rules", help="library rules file (default: ecadlib.toml found from here)")
753
+ ap.add_argument("--list", action="store_true", help="print the grouping")
754
+ a = ap.parse_args(argv)
755
+ from . import rules as RU
756
+ try:
757
+ rules = RU.load(a.rules, start=Path(a.out).parent if a.out else ".")
758
+ except RU.RulesError as e:
759
+ print(f"error: {e}", file=sys.stderr)
760
+ return 2
761
+ sec = dict(rules["symbols"])
762
+ if a.split is not None:
763
+ sec["split_pins"] = a.split
764
+ if a.stack:
765
+ sec["stack_power"] = a.stack
766
+ name, pins, extra = load_pins(a.source)
767
+ name = a.name or name
768
+ sym = build(name, pins, extra.get("description", ""), extra.get("designator", "U?"), a.footprint or extra.get("footprint"),
769
+ rules={"symbols": sec})
770
+ out = Path(a.out or f"{AL.file_stem(name)}.SchLib")
771
+ out.parent.mkdir(parents=True, exist_ok=True)
772
+ AL.write_schlib(out, [sym])
773
+ probs = check(sym, sec["grid"])
774
+ nparts = sym.get("parts", 1)
775
+ groups = {}
776
+ for p in arrange_pins(pins, {"symbols": sec}):
777
+ groups.setdefault((p["unit"], p["side"], p["group"]), []).append(p)
778
+ print(f"{name}: {len(pins)} pins in {len({k[2] for k in groups})} groups, {nparts} part{'s' if nparts > 1 else ''} -> {out}")
779
+ if a.list:
780
+ for (u, side, g), ps in groups.items():
781
+ print(f" part {chr(64 + u)} {side:<6} {g:<9} " + ", ".join(f"{p['num']}:{plain(p['name'])}" + ("*" if p.get("stacked_on") else "") for p in ps))
782
+ if a.kicad:
783
+ from . import kicad_export as X, sexpr
784
+ notes = []
785
+ ks = X.symbol_from_schlib(out, X.kname(name), notes)
786
+ tree = ["kicad_symbol_lib", ["version", X.SYM_VERSION], ["generator", sexpr.Q("ecadlib")], ["generator_version", sexpr.Q("8.0")], ks]
787
+ Path(a.kicad).write_text(sexpr.dumps(tree), encoding="utf-8", newline="\n")
788
+ print(f" KiCad symbol -> {a.kicad}")
789
+ if a.preview:
790
+ Path(a.preview).write_text(svg(sym), encoding="utf-8")
791
+ print(f" preview -> {a.preview}")
792
+ for pr in probs:
793
+ print(f" problem: {pr}")
794
+ return 1 if probs else 0
795
+
796
+
797
+ if __name__ == "__main__":
798
+ sys.exit(main())