ecadlib 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ecadlib/__init__.py +3 -0
- ecadlib/__main__.py +5 -0
- ecadlib/a365_sync.py +637 -0
- ecadlib/agent.py +1407 -0
- ecadlib/agent_queue.py +780 -0
- ecadlib/agent_watch.py +732 -0
- ecadlib/altium365.py +385 -0
- ecadlib/altium_dblib_sync.py +530 -0
- ecadlib/altium_export.py +209 -0
- ecadlib/altium_forms.py +768 -0
- ecadlib/altium_lib.py +1089 -0
- ecadlib/altium_menu.py +1751 -0
- ecadlib/altium_pkg.py +396 -0
- ecadlib/altium_project.py +1095 -0
- ecadlib/app.py +1458 -0
- ecadlib/approvals.py +395 -0
- ecadlib/assets/demo_bom.csv +8 -0
- ecadlib/assets/kicad_action_plugin.py +607 -0
- ecadlib/assets/kicad_reread.py +60 -0
- ecadlib/assets/pcblib_template.json.gz +0 -0
- ecadlib/bom_inputs.py +339 -0
- ecadlib/bom_triage.py +519 -0
- ecadlib/cad_model.py +528 -0
- ecadlib/cfb.py +171 -0
- ecadlib/cli.py +185 -0
- ecadlib/clihelp.py +149 -0
- ecadlib/cliutil.py +119 -0
- ecadlib/completion.py +173 -0
- ecadlib/costs.py +276 -0
- ecadlib/crosscheck.py +412 -0
- ecadlib/datasheet.py +2168 -0
- ecadlib/dedupe.py +572 -0
- ecadlib/detect.py +170 -0
- ecadlib/doctor.py +450 -0
- ecadlib/ds_route.py +541 -0
- ecadlib/eagle_export.py +1409 -0
- ecadlib/easy_setup.py +338 -0
- ecadlib/easyeda_convert.py +386 -0
- ecadlib/easyeda_pro_export.py +972 -0
- ecadlib/eval/__init__.py +0 -0
- ecadlib/eval/baselines/kicad-v1.json +404 -0
- ecadlib/eval/build_set.py +299 -0
- ecadlib/eval/cli.py +119 -0
- ecadlib/eval/gate.py +358 -0
- ecadlib/eval/golden/demo.txt +379 -0
- ecadlib/eval/golden/specs.txt +3120 -0
- ecadlib/eval/golden/testpack.txt +463 -0
- ecadlib/eval/golden.py +446 -0
- ecadlib/eval/replay/kicad-v1.json.gz +0 -0
- ecadlib/eval/runner.py +456 -0
- ecadlib/eval/score.py +179 -0
- ecadlib/eval/sets/kicad-v1.json +630 -0
- ecadlib/firstrun.py +400 -0
- ecadlib/footprint_adv.py +1006 -0
- ecadlib/footprint_bench.py +218 -0
- ecadlib/footprint_check.py +562 -0
- ecadlib/footprint_gen.py +582 -0
- ecadlib/footprint_rules.py +1092 -0
- ecadlib/forge.py +267 -0
- ecadlib/fp_share.py +95 -0
- ecadlib/fp_tidy.py +389 -0
- ecadlib/fsutil.py +62 -0
- ecadlib/house_style.py +1051 -0
- ecadlib/httpcache.py +124 -0
- ecadlib/hub.py +1490 -0
- ecadlib/hub_copy.py +161 -0
- ecadlib/hub_tools.py +79 -0
- ecadlib/kicad_dblib.py +257 -0
- ecadlib/kicad_export.py +837 -0
- ecadlib/kicad_import.py +913 -0
- ecadlib/kicad_index.py +160 -0
- ecadlib/kicad_menu.py +193 -0
- ecadlib/kicad_pcm.py +475 -0
- ecadlib/launcher.py +156 -0
- ecadlib/librarian.py +963 -0
- ecadlib/lifecycle.py +691 -0
- ecadlib/llm/__init__.py +276 -0
- ecadlib/llm/cli.py +70 -0
- ecadlib/llm/openai_compat.py +214 -0
- ecadlib/llm/profiles.py +245 -0
- ecadlib/llm/rails.py +570 -0
- ecadlib/llm/run.py +126 -0
- ecadlib/llm/sampling.py +49 -0
- ecadlib/mcp_server.py +723 -0
- ecadlib/messages.py +419 -0
- ecadlib/migrate.py +407 -0
- ecadlib/notify.py +329 -0
- ecadlib/package_schema.json +1238 -0
- ecadlib/package_specs/CP_EIA-3216-18_Kemet-A.json +27 -0
- ecadlib/package_specs/C_0402_1005Metric.json +22 -0
- ecadlib/package_specs/C_0805_2012Metric.json +22 -0
- ecadlib/package_specs/DFN-8-1EP_3x3mm_P0.5mm_EP1.66x2.38mm.json +40 -0
- ecadlib/package_specs/D_SMA.json +27 -0
- ecadlib/package_specs/D_SMB.json +27 -0
- ecadlib/package_specs/D_SOD-123.json +43 -0
- ecadlib/package_specs/D_SOD-323.json +43 -0
- ecadlib/package_specs/LED_0603_1608Metric.json +23 -0
- ecadlib/package_specs/LED_0805_2012Metric.json +23 -0
- ecadlib/package_specs/LQFP-48_7x7mm_P0.5mm.json +31 -0
- ecadlib/package_specs/L_0805_2012Metric.json +22 -0
- ecadlib/package_specs/MSOP-8_3x3mm_P0.65mm.json +31 -0
- ecadlib/package_specs/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm.json +39 -0
- ecadlib/package_specs/QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm.json +37 -0
- ecadlib/package_specs/R_0402_1005Metric.json +22 -0
- ecadlib/package_specs/R_0603_1608Metric.json +22 -0
- ecadlib/package_specs/R_0805_2012Metric.json +22 -0
- ecadlib/package_specs/R_1206_3216Metric.json +22 -0
- ecadlib/package_specs/SOIC-16_3.9x9.9mm_P1.27mm.json +32 -0
- ecadlib/package_specs/SOIC-8_3.9x4.9mm_P1.27mm.json +32 -0
- ecadlib/package_specs/SOT-223-3_TabPin2.json +56 -0
- ecadlib/package_specs/SOT-23-5.json +57 -0
- ecadlib/package_specs/SOT-23-6.json +62 -0
- ecadlib/package_specs/SOT-23.json +48 -0
- ecadlib/package_specs/TSSOP-16_4.4x5mm_P0.65mm.json +31 -0
- ecadlib/package_specs/VQFN-16-1EP_3x3mm_P0.5mm_EP1.68x1.68mm.json +39 -0
- ecadlib/package_specs/advanced/BGA-100_11.0x11.0mm_Layout10x10_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-121_9.0x9.0mm_Layout11x11_P0.8mm_Ball0.4mm_Pad0.35mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-144_7.0x7.0mm_Layout13x13_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +29 -0
- ecadlib/package_specs/advanced/BGA-169_11.0x11.0mm_Layout13x13_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +23 -0
- ecadlib/package_specs/advanced/BGA-256_11.0x11.0mm_Layout20x20_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-256_17.0x17.0mm_Layout16x16_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-324_15.0x15.0mm_Layout18x18_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-36_3.396x3.466mm_Layout6x6_P0.4mm_Ball0.25mm_Pad0.2mm_NSMD.json +24 -0
- ecadlib/package_specs/advanced/BGA-672_27.0x27.0mm_Layout26x26_P1.0mm_Ball0.6mm_Pad0.5mm_NSMD.json +32 -0
- ecadlib/package_specs/advanced/BGA-68_5.0x5.0mm_Layout9x9_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +30 -0
- ecadlib/package_specs/advanced/BGA-90_8.0x13.0mm_Layout2x3x15_P0.8mm.json +26 -0
- ecadlib/package_specs/advanced/DFN-8-1EP_3x3mm_P0.5mm_EP1.65x2.38mm_ThermalVias.json +60 -0
- ecadlib/package_specs/advanced/Example_Custom_LPad_Slot.json +24 -0
- ecadlib/package_specs/advanced/JST_GH_SM04B-GHS-TB_1x04-1MP_P1.25mm_Horizontal.json +49 -0
- ecadlib/package_specs/advanced/JST_PH_B4B-PH-K_1x04_P2.00mm_Vertical.json +33 -0
- ecadlib/package_specs/advanced/JST_PH_S4B-PH-SM4-TB_1x04-1MP_P2.00mm_Horizontal.json +55 -0
- ecadlib/package_specs/advanced/JST_SH_SM04B-SRSS-TB_1x04-1MP_P1.00mm_Horizontal.json +49 -0
- ecadlib/package_specs/advanced/JST_XH_B2B-XH-A_1x02_P2.50mm_Vertical.json +34 -0
- ecadlib/package_specs/advanced/JST_XH_B4B-XH-A_1x04_P2.50mm_Vertical.json +34 -0
- ecadlib/package_specs/advanced/JST_XH_B6B-XH-A_1x06_P2.50mm_Vertical.json +33 -0
- ecadlib/package_specs/advanced/LFBGA-144_10x10mm_Layout12x12_P0.8mm.json +23 -0
- ecadlib/package_specs/advanced/Molex_KK-254_AE-6410-04A_1x04_P2.54mm_Vertical.json +34 -0
- ecadlib/package_specs/advanced/Molex_PicoBlade_53398-0471_1x04-1MP_P1.25mm_Vertical.json +54 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P1.00mm_Vertical_SMD_Pin1Left.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P1.27mm_Vertical_SMD_Pin1Left.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical_SMD_Pin1Left.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Left.json +25 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Right.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x10_P1.00mm_Vertical.json +15 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P1.00mm_Vertical_SMD.json +13 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical.json +18 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical_SMD.json +24 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P2.00mm_Vertical_SMD.json +13 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical_SMD.json +24 -0
- ecadlib/package_specs/advanced/PinHeader_2x20_P2.54mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm_ThermalVias.json +87 -0
- ecadlib/package_specs/advanced/UFBGA-132_7x7mm_Layout12x12_P0.5mm.json +43 -0
- ecadlib/package_specs/advanced/USB_C_Receptacle_HRO_TYPE-C-31-M-12.json +218 -0
- ecadlib/package_specs/standard.json.gz +0 -0
- ecadlib/parity.py +1204 -0
- ecadlib/parts_export.py +154 -0
- ecadlib/partview.py +215 -0
- ecadlib/plugins.py +231 -0
- ecadlib/providers.py +226 -0
- ecadlib/rebuild.py +471 -0
- ecadlib/release_check.py +437 -0
- ecadlib/reports.py +274 -0
- ecadlib/resolve.py +556 -0
- ecadlib/review_cli.py +292 -0
- ecadlib/rules.py +979 -0
- ecadlib/rules_infer.py +1205 -0
- ecadlib/sexpr.py +149 -0
- ecadlib/sheetsafe.py +65 -0
- ecadlib/sim_models.py +1087 -0
- ecadlib/sourcing.py +1161 -0
- ecadlib/stdparts.py +755 -0
- ecadlib/step_gen.py +929 -0
- ecadlib/symbol_layout.py +798 -0
- ecadlib/team.py +558 -0
- ecadlib/team_setup.py +288 -0
- ecadlib/term.py +420 -0
- ecadlib/term_views.py +64 -0
- ecadlib/testpack.py +334 -0
- ecadlib/ui/__init__.py +1 -0
- ecadlib/ui/__main__.py +3 -0
- ecadlib/ui/server.py +1029 -0
- ecadlib/ui/static/hairline.js +2 -0
- ecadlib/ui/static/index.html +2098 -0
- ecadlib/ui/static/setup.html +294 -0
- ecadlib/upkeep.py +717 -0
- ecadlib/upstream.py +573 -0
- ecadlib/userconfig.py +257 -0
- ecadlib/visual_check.py +1603 -0
- ecadlib/ws_status.py +203 -0
- ecadlib-0.3.0.dist-info/METADATA +2245 -0
- ecadlib-0.3.0.dist-info/RECORD +199 -0
- ecadlib-0.3.0.dist-info/WHEEL +5 -0
- ecadlib-0.3.0.dist-info/entry_points.txt +2 -0
- ecadlib-0.3.0.dist-info/licenses/LICENSE +21 -0
- ecadlib-0.3.0.dist-info/licenses/NOTICE +53 -0
- ecadlib-0.3.0.dist-info/top_level.txt +1 -0
ecadlib/footprint_adv.py
ADDED
|
@@ -0,0 +1,1006 @@
|
|
|
1
|
+
"""Harder footprints for footprint_gen: ball grids, pin headers, connectors and odd shapes.
|
|
2
|
+
|
|
3
|
+
package_type (package_schema.json), all built by build() below and reached through footprint_gen.build():
|
|
4
|
+
|
|
5
|
+
bga BGA / LGA / CSP / WLCSP: rows x cols balls at pitch (pitch_y), lettered A, B, ... from the top
|
|
6
|
+
skipping I O Q S X Z (JEDEC JEP95), then AA, AB, ...; numbered 1.. from the left. Missing balls from
|
|
7
|
+
`depopulated` (names or ranges "E5:H8") or a `ball_map` (one string per row, "-" or "." = no ball).
|
|
8
|
+
Land: the datasheet's land_diameter, else IPC-7351B from the nominal ball size (collapsing balls
|
|
9
|
+
shrink 25 % from 0.55 mm up, 20 % from 0.25 mm, 15 % below; non-collapsing grow 5 %; LGA lands
|
|
10
|
+
= terminal). NSMD (copper = land, mask opening 0.05 bigger all round) or SMD (copper 0.05 bigger,
|
|
11
|
+
mask opening = land). Courtyard = body + 2.0 / 1.0 / 0.5 mm (density A / B / C, KLC uses 1.0).
|
|
12
|
+
pin_header 1 or 2 columns (cols) of rows pins at pitch. Through-hole: pin 1 at the origin, square pad 1,
|
|
13
|
+
round others (KiCad's drill/pad table per pitch unless drill / land_diameter are given). SMD:
|
|
14
|
+
pads from `land` (or KiCad's table), single-row SMD headers alternate left/right from pin 1.
|
|
15
|
+
connector any connector from the datasheet's recommended PCB layout, as a pad_table (below): signal pads,
|
|
16
|
+
mounting pads (MP), shield tabs (SH), NPTH locating holes, slots, keep-outs. THT pads without a
|
|
17
|
+
size get KiCad's connector rule (pad = drill + 1.0, no wider than pitch - 0.8 across the row).
|
|
18
|
+
custom the escape hatch for odd shapes: a pad_table with polygon pads, slots, paste split into windows
|
|
19
|
+
and thermal vias for big pads.
|
|
20
|
+
|
|
21
|
+
pad_table: the dimension drawing as a table, in the drawing's own frame so an LLM can copy it without arithmetic:
|
|
22
|
+
unit (mm | mil | in), y_axis (up | down), view (top | bottom: bottom views are mirrored), origin (what the
|
|
23
|
+
drawing's 0,0 is: center | pin1 | datum), center_at (the package centre in drawing coordinates when the origin is
|
|
24
|
+
not the centre; default the middle of the pads), footprint_origin (center | pin1; default pin1 when there are
|
|
25
|
+
through-hole pins, as KiCad does), default_pad, pads[], arrays[] (a row of equal pads: count, pitch dx/dy),
|
|
26
|
+
body (rectangle) and keepouts[]. Everything is converted to mm, KiCad's y-down top view and the chosen origin.
|
|
27
|
+
|
|
28
|
+
check_pad_table() checks a table before it becomes copper: units (mils typed as mm), pad count against pin_count,
|
|
29
|
+
duplicate / missing numbers, overlapping pads, pitch regularity along each row, mirror symmetry (an odd pad out in
|
|
30
|
+
an otherwise symmetric pattern is usually a misread number), pin 1 at the end of its row and at 0,0 when the
|
|
31
|
+
drawing says the origin is pin 1, drill vs pad (annular ring). Errors stop build(); warnings land in the report.
|
|
32
|
+
|
|
33
|
+
Courtyard for connector / custom / pin_header: the union of every pad and the body (and the lead span of SMD
|
|
34
|
+
headers), each grown by the courtyard excess (0.5 mm for connectors, KLC F5.3) and rounded out to 0.01 mm, traced
|
|
35
|
+
as one rectilinear outline (falls back to the bounding box when the pieces don't touch).
|
|
36
|
+
"""
|
|
37
|
+
import math, re
|
|
38
|
+
|
|
39
|
+
UNIT = {"mm": 1.0, "mil": 0.0254, "in": 25.4}
|
|
40
|
+
JEDEC_SKIP = set("IOQSXZ")
|
|
41
|
+
LETTERS = [c for c in "ABCDEFGHIJKLMNOPQRSTUVWXYZ" if c not in JEDEC_SKIP] # 20 row letters
|
|
42
|
+
ALL_LETTERS = list("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
|
43
|
+
# Pads that are not pins. KiCad's library names them MP (mounting) and SH (shield), holes "" (kicad-footprints f8440fa:
|
|
44
|
+
# 4400+ MP / SH pads; no MH / MTG / MOUNT / MECH / NPTH, kept because datasheet tables use them). Never a bare letter:
|
|
45
|
+
# H and S (and G, R, T) name BGA ball rows, audio-jack contacts (S = sleeve), rotary-encoder switch pins (S1, S2) and
|
|
46
|
+
# edge-connector fingers (H), all of them pins.
|
|
47
|
+
MOUNT = re.compile(r"^((MP|MH|SH|MTG|MOUNT|MECH|NPTH)\d*)?$", re.I)
|
|
48
|
+
PIN_ROLES = ("pin", "thermal") # numbered pads that are pins
|
|
49
|
+
BGA_EXCESS = {"A": 2.0, "B": 1.0, "C": 0.5}
|
|
50
|
+
CONN_EXCESS = {"A": 0.5, "B": 0.5, "C": 0.5} # KLC F5.3: connectors 0.5 mm
|
|
51
|
+
CUSTOM_EXCESS = {"A": 0.5, "B": 0.25, "C": 0.1}
|
|
52
|
+
NSMD_MASK = 0.05 # NSMD mask opening this much bigger than the land, all round
|
|
53
|
+
SMD_GROW = 0.05 # SMD-defined: copper this much bigger, mask = land
|
|
54
|
+
TH_RING, TH_RING_MIN, TH_PAD_GAP = 0.5, 0.15, 0.8 # KiCad connector generator: annular ring, minimum, pad-to-pad
|
|
55
|
+
# KiCad pin header tables: pitch -> (drill, pad diameter) for through-hole, (pad length, width, centre) for SMD
|
|
56
|
+
HEADER_TH = {2.54: (1.0, 1.7), 2.0: (0.8, 1.35), 1.27: (0.65, 1.0), 1.0: (0.5, 0.85)}
|
|
57
|
+
HEADER_SMD = {2.54: (3.15, 1.0, 2.525), 2.0: (2.58, 1.0, 2.085), 1.27: (2.4, 0.74, 1.95), 1.0: (2.0, 0.5, 1.65)}
|
|
58
|
+
HEADER_SMD1 = {2.54: (2.51, 1.0, 1.655), 2.0: (2.35, 0.85, 1.175), 1.27: (3.0, 0.65, 1.5), 1.0: (1.75, 0.6, 0.875)} # 1 row
|
|
59
|
+
GRID = 0.01
|
|
60
|
+
# IPC-7351B land approximation for collapsing BGA balls (nominal ball -> nominal land, mm): 25 % smaller from 0.55 mm,
|
|
61
|
+
# 20 % from 0.25 mm, 15 % below. Source: IPC-7351B section 14 (BGA), table "Collapsing ball land approximation", as
|
|
62
|
+
# reproduced in maker app notes; not re-checked online (ipc.org and the usual mirrors are blocked here). KiCad's
|
|
63
|
+
# library agrees where it names both sizes (Ball0.25 -> Pad0.2, Ball0.3 -> 0.25, Ball0.5 -> 0.4); it has no 0.17 mm ball.
|
|
64
|
+
IPC_BGA_COLLAPSING = ((0.75, 0.55), (0.65, 0.50), (0.60, 0.45), (0.55, 0.40), (0.50, 0.40), (0.45, 0.35), (0.40, 0.30),
|
|
65
|
+
(0.35, 0.30), (0.30, 0.25), (0.25, 0.20), (0.20, 0.17), (0.17, 0.15), (0.15, 0.13))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _F():
|
|
69
|
+
from . import footprint_gen
|
|
70
|
+
return footprint_gen
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _dim(v):
|
|
74
|
+
return _F().dim(v)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _r4(x):
|
|
78
|
+
return round(x + 0.0, 4)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------- BGA naming
|
|
82
|
+
def row_name(i, jedec=True):
|
|
83
|
+
"""0 -> A, 19 -> Y, 20 -> AA (JEDEC JEP95: I O Q S X Z are never used as row letters)."""
|
|
84
|
+
L = LETTERS if jedec else ALL_LETTERS
|
|
85
|
+
if i < len(L):
|
|
86
|
+
return L[i]
|
|
87
|
+
i -= len(L)
|
|
88
|
+
return L[i // len(L)] + L[i % len(L)]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def ball_index(name, jedec=True, rows=200):
|
|
92
|
+
"""'AB12' -> (row, col) zero-based, or None."""
|
|
93
|
+
m = re.fullmatch(r"([A-Z]{1,2})(\d+)", str(name).strip().upper())
|
|
94
|
+
if not m:
|
|
95
|
+
return None
|
|
96
|
+
names = {row_name(i, jedec): i for i in range(rows)}
|
|
97
|
+
return (names[m.group(1)], int(m.group(2)) - 1) if m.group(1) in names else None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def bga_balls(spec):
|
|
101
|
+
"""[(name, row, col)] of the populated balls, row-major from A1."""
|
|
102
|
+
rows, cols = spec["rows"], spec["cols"]
|
|
103
|
+
if not all(isinstance(v, int) and not isinstance(v, bool) and 1 <= v <= 200 for v in (rows, cols)):
|
|
104
|
+
raise ValueError(f"rows / cols must be whole numbers from 1 to 200 (got {rows!r} x {cols!r})")
|
|
105
|
+
jedec = spec.get("row_letters", "jedec") == "jedec"
|
|
106
|
+
if spec.get("ball_map"):
|
|
107
|
+
bm = spec["ball_map"]
|
|
108
|
+
if len(bm) != rows or any(len(r.replace(" ", "")) != cols for r in bm):
|
|
109
|
+
raise ValueError(f"ball_map must have {rows} rows of {cols} characters (one per column, '-' or '.' = no ball); "
|
|
110
|
+
f"got {len(bm)} rows of {sorted({len(r.replace(' ', '')) for r in bm})}")
|
|
111
|
+
have = {(i, j) for i, r in enumerate(bm) for j, ch in enumerate(r.replace(" ", "")) if ch not in "-._0"}
|
|
112
|
+
else:
|
|
113
|
+
have = {(i, j) for i in range(rows) for j in range(cols)}
|
|
114
|
+
for item in spec.get("depopulated") or []:
|
|
115
|
+
a, _, b = str(item).partition(":")
|
|
116
|
+
ia, ib = ball_index(a, jedec, rows), ball_index(b or a, jedec, rows)
|
|
117
|
+
if ia is None or ib is None or max(ia[0], ib[0]) >= rows or max(ia[1], ib[1]) >= cols:
|
|
118
|
+
raise ValueError(f"depopulated {item!r}: not a ball of a {rows} x {cols} grid "
|
|
119
|
+
f"(rows {row_name(0, jedec)}..{row_name(rows - 1, jedec)}, columns 1..{cols})")
|
|
120
|
+
for i in range(min(ia[0], ib[0]), max(ia[0], ib[0]) + 1):
|
|
121
|
+
for j in range(min(ia[1], ib[1]), max(ia[1], ib[1]) + 1):
|
|
122
|
+
have.discard((i, j))
|
|
123
|
+
return [(f"{row_name(i, jedec)}{j + 1}", i, j) for i, j in sorted(have)]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def ipc_bga_land(ball, ball_type="collapsing"):
|
|
127
|
+
"""IPC-7351B land diameter for a nominal ball (or LGA terminal) diameter. Returns (land, rule text)."""
|
|
128
|
+
if ball_type == "lga":
|
|
129
|
+
return round(ball, 3), "LGA land = terminal size"
|
|
130
|
+
if ball_type == "non_collapsing":
|
|
131
|
+
d = ball * 1.05
|
|
132
|
+
return round(math.floor(d / 0.01 + 0.5) * 0.01, 2), "non-collapsing ball: land 5 % larger"
|
|
133
|
+
red = 0.25 if ball >= 0.55 - 1e-9 else 0.20 if ball >= 0.25 - 1e-9 else 0.15
|
|
134
|
+
hit = next((land for b, land in IPC_BGA_COLLAPSING if abs(b - ball) < 1e-6), None)
|
|
135
|
+
if hit is not None: # a ball size the IPC table lists: its land
|
|
136
|
+
return hit, f"collapsing ball {ball:g} mm: land {round(red * 100)} % smaller (IPC-7351B table)"
|
|
137
|
+
g = 0.05 if ball >= 0.25 - 1e-9 else 0.01 # between table sizes: the table's rounding (0.05 mm; 0.01 mm
|
|
138
|
+
d = (math.floor(ball * (1 - red) / g + 0.5) if g > 0.01 else math.ceil(round(ball * (1 - red) / g, 6))) * g # up below 0.25)
|
|
139
|
+
return round(d, 3), f"collapsing ball {ball:g} mm: land {round(red * 100)} % smaller"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------- geometry helpers
|
|
143
|
+
def _rot(x, y, deg):
|
|
144
|
+
a = math.radians(deg)
|
|
145
|
+
return x * math.cos(a) + y * math.sin(a), -x * math.sin(a) + y * math.cos(a) # KiCad: CCW on screen, y down
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def pad_bbox(p):
|
|
149
|
+
"""(x1, y1, x2, y2) of a pad's copper (rotation and polygons included)."""
|
|
150
|
+
if p.get("poly"):
|
|
151
|
+
xs, ys = [q[0] for q in p["poly"]], [q[1] for q in p["poly"]]
|
|
152
|
+
return min(xs), min(ys), max(xs), max(ys)
|
|
153
|
+
w, h, rot = p["w"], p["h"], p.get("rot", 0) % 180
|
|
154
|
+
if abs(rot - 90) < 1e-6:
|
|
155
|
+
w, h = h, w
|
|
156
|
+
elif rot:
|
|
157
|
+
c, s = abs(math.cos(math.radians(rot))), abs(math.sin(math.radians(rot)))
|
|
158
|
+
w, h = w * c + h * s, w * s + h * c
|
|
159
|
+
return p["x"] - w / 2, p["y"] - h / 2, p["x"] + w / 2, p["y"] + h / 2
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _pt_in_poly(x, y, pts):
|
|
163
|
+
inside = False
|
|
164
|
+
for (x1, y1), (x2, y2) in zip(pts, pts[1:] + pts[:1]):
|
|
165
|
+
if (y1 > y) != (y2 > y) and x < x1 + (y - y1) * (x2 - x1) / (y2 - y1):
|
|
166
|
+
inside = not inside
|
|
167
|
+
return inside
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _seg_dist(px, py, a, b):
|
|
171
|
+
(x1, y1), (x2, y2) = a, b
|
|
172
|
+
dx, dy = x2 - x1, y2 - y1
|
|
173
|
+
t = 0 if dx == dy == 0 else max(0, min(1, ((px - x1) * dx + (py - y1) * dy) / (dx * dx + dy * dy)))
|
|
174
|
+
return math.hypot(px - x1 - t * dx, py - y1 - t * dy)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _pad_dist(px, py, p):
|
|
178
|
+
"""Distance from a point to a pad's copper outline (0 inside)."""
|
|
179
|
+
if p.get("poly"):
|
|
180
|
+
if _pt_in_poly(px, py, p["poly"]):
|
|
181
|
+
return 0.0
|
|
182
|
+
return min(_seg_dist(px, py, a, b) for a, b in zip(p["poly"], p["poly"][1:] + p["poly"][:1]))
|
|
183
|
+
x1, y1, x2, y2 = pad_bbox(p)
|
|
184
|
+
w, h = x2 - x1, y2 - y1
|
|
185
|
+
r = min(w, h) / 2 if p["shape"] in ("circle", "oval") else _corner(p) if p["shape"] == "roundrect" else 0.0
|
|
186
|
+
dx = max(abs(px - p["x"]) - (w / 2 - r), 0)
|
|
187
|
+
dy = max(abs(py - p["y"]) - (h / 2 - r), 0)
|
|
188
|
+
return max(math.hypot(dx, dy) - r, 0.0)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _corner(p):
|
|
192
|
+
return min(0.25, 0.25 * min(p["w"], p["h"]))
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _interior_point(pts):
|
|
196
|
+
"""A point well inside a polygon (centroid when it is inside, else the best of a grid)."""
|
|
197
|
+
cx, cy = sum(x for x, _ in pts) / len(pts), sum(y for _, y in pts) / len(pts)
|
|
198
|
+
edge = lambda x, y: min(_seg_dist(x, y, a, b) for a, b in zip(pts, pts[1:] + pts[:1]))
|
|
199
|
+
best = (edge(cx, cy), cx, cy) if _pt_in_poly(cx, cy, pts) else (-1, cx, cy)
|
|
200
|
+
xs, ys = [x for x, _ in pts], [y for _, y in pts]
|
|
201
|
+
for i in range(1, 24):
|
|
202
|
+
for j in range(1, 24):
|
|
203
|
+
x = min(xs) + (max(xs) - min(xs)) * i / 24
|
|
204
|
+
y = min(ys) + (max(ys) - min(ys)) * j / 24
|
|
205
|
+
if _pt_in_poly(x, y, pts):
|
|
206
|
+
d = edge(x, y)
|
|
207
|
+
if d > best[0] * 1.5:
|
|
208
|
+
best = (d, x, y)
|
|
209
|
+
if best[0] <= 0:
|
|
210
|
+
raise ValueError("polygon pad has no inside (check its points)")
|
|
211
|
+
return best
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _poly_area(pts):
|
|
215
|
+
return abs(sum(a[0] * b[1] - b[0] * a[1] for a, b in zip(pts, pts[1:] + pts[:1]))) / 2
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _rup(x):
|
|
219
|
+
return round(math.ceil(round(x / GRID, 6)) * GRID, 2)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _rdn(x):
|
|
223
|
+
return round(math.floor(round(x / GRID, 6)) * GRID, 2)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def union_outline(rects):
|
|
227
|
+
"""Outer outline of the union of axis-aligned rectangles (holes filled) as a list of corner points, or None when
|
|
228
|
+
the rectangles form more than one island."""
|
|
229
|
+
xs = sorted({v for r in rects for v in (r[0], r[2])})
|
|
230
|
+
ys = sorted({v for r in rects for v in (r[1], r[3])})
|
|
231
|
+
nx, ny = len(xs) - 1, len(ys) - 1
|
|
232
|
+
cov = [[False] * ny for _ in range(nx)]
|
|
233
|
+
for x1, y1, x2, y2 in rects:
|
|
234
|
+
i1, i2, j1, j2 = xs.index(x1), xs.index(x2), ys.index(y1), ys.index(y2)
|
|
235
|
+
for i in range(i1, i2):
|
|
236
|
+
for j in range(j1, j2):
|
|
237
|
+
cov[i][j] = True
|
|
238
|
+
# flood the outside (one cell of margin) so enclosed holes count as filled
|
|
239
|
+
out = set()
|
|
240
|
+
stack = [(-1, -1)]
|
|
241
|
+
while stack:
|
|
242
|
+
i, j = stack.pop()
|
|
243
|
+
if (i, j) in out or not (-1 <= i <= nx and -1 <= j <= ny):
|
|
244
|
+
continue
|
|
245
|
+
if 0 <= i < nx and 0 <= j < ny and cov[i][j]:
|
|
246
|
+
continue
|
|
247
|
+
out.add((i, j))
|
|
248
|
+
stack += [(i + 1, j), (i - 1, j), (i, j + 1), (i, j - 1)]
|
|
249
|
+
filled = lambda i, j: 0 <= i < nx and 0 <= j < ny and (i, j) not in out
|
|
250
|
+
cells = [(i, j) for i in range(nx) for j in range(ny) if filled(i, j)]
|
|
251
|
+
seen, stack = set(), [cells[0]]
|
|
252
|
+
while stack: # one island?
|
|
253
|
+
c = stack.pop()
|
|
254
|
+
if c in seen or not filled(*c):
|
|
255
|
+
continue
|
|
256
|
+
seen.add(c)
|
|
257
|
+
stack += [(c[0] + 1, c[1]), (c[0] - 1, c[1]), (c[0], c[1] + 1), (c[0], c[1] - 1)]
|
|
258
|
+
if len(seen) != len(cells):
|
|
259
|
+
return None
|
|
260
|
+
edges = {} # directed boundary edges, filled cell on the right (y down)
|
|
261
|
+
for i, j in cells:
|
|
262
|
+
if not filled(i, j - 1):
|
|
263
|
+
edges.setdefault((xs[i], ys[j]), []).append((xs[i + 1], ys[j]))
|
|
264
|
+
if not filled(i + 1, j):
|
|
265
|
+
edges.setdefault((xs[i + 1], ys[j]), []).append((xs[i + 1], ys[j + 1]))
|
|
266
|
+
if not filled(i, j + 1):
|
|
267
|
+
edges.setdefault((xs[i + 1], ys[j + 1]), []).append((xs[i], ys[j + 1]))
|
|
268
|
+
if not filled(i - 1, j):
|
|
269
|
+
edges.setdefault((xs[i], ys[j + 1]), []).append((xs[i], ys[j]))
|
|
270
|
+
start = min(edges)
|
|
271
|
+
loop, cur, prev_dir = [start], start, None
|
|
272
|
+
total = sum(len(v) for v in edges.values())
|
|
273
|
+
for _ in range(total):
|
|
274
|
+
cands = edges[cur]
|
|
275
|
+
if len(cands) > 1 and prev_dir: # pinch point: keep turning right (stay on this island)
|
|
276
|
+
cands.sort(key=lambda q: _turn(prev_dir, (q[0] - cur[0], q[1] - cur[1])))
|
|
277
|
+
nxt = cands.pop(0)
|
|
278
|
+
prev_dir = (nxt[0] - cur[0], nxt[1] - cur[1])
|
|
279
|
+
cur = nxt
|
|
280
|
+
if cur == start:
|
|
281
|
+
break
|
|
282
|
+
loop.append(cur)
|
|
283
|
+
pts = [] # drop collinear corners
|
|
284
|
+
for k, p in enumerate(loop):
|
|
285
|
+
a, b = loop[k - 1], loop[(k + 1) % len(loop)]
|
|
286
|
+
if (p[0] - a[0]) * (b[1] - p[1]) - (p[1] - a[1]) * (b[0] - p[0]) != 0:
|
|
287
|
+
pts.append(p)
|
|
288
|
+
return pts
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _turn(d0, d1):
|
|
292
|
+
"""Sort key at a pinch point: right turn (towards the filled side, y down) first, then straight, then left."""
|
|
293
|
+
sg = lambda v: ((v[0] > 0) - (v[0] < 0), (v[1] > 0) - (v[1] < 0))
|
|
294
|
+
a, b = sg(d0), sg(d1)
|
|
295
|
+
cross = a[0] * b[1] - a[1] * b[0]
|
|
296
|
+
return 0 if cross > 0 else 1 if cross == 0 else 2
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# ---------------------------------------------------------------- pad table -> pads
|
|
300
|
+
def _num(v):
|
|
301
|
+
return "" if v is None else str(v)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _expand_table(t):
|
|
305
|
+
"""pad_table rows + arrays -> list of raw pad dicts (drawing units, drawing frame)."""
|
|
306
|
+
base = dict(t.get("default_pad") or {})
|
|
307
|
+
rows = []
|
|
308
|
+
for i, p in enumerate(t.get("pads") or []):
|
|
309
|
+
rows.append(dict(base, **{k: v for k, v in p.items() if v is not None}, _src=f"pads[{i}]"))
|
|
310
|
+
for i, a in enumerate(t.get("arrays") or []):
|
|
311
|
+
step = a.get("number_step", 1)
|
|
312
|
+
first = a.get("first_number", 1)
|
|
313
|
+
for k in range(a["count"]):
|
|
314
|
+
p = dict(base, **{kk: v for kk, v in a.items() if v is not None and kk not in
|
|
315
|
+
("count", "dx", "dy", "first_number", "number_step", "prefix", "x", "y")})
|
|
316
|
+
p["number"] = f"{a.get('prefix', '')}{first + k * step}"
|
|
317
|
+
p["x"], p["y"] = a["x"] + k * a.get("dx", 0), a["y"] + k * a.get("dy", 0)
|
|
318
|
+
p["_src"] = f"arrays[{i}] #{k + 1}"
|
|
319
|
+
rows.append(p)
|
|
320
|
+
return rows
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _role(p, mount=MOUNT):
|
|
324
|
+
if p.get("role"):
|
|
325
|
+
return p["role"]
|
|
326
|
+
if p.get("type") == "npth":
|
|
327
|
+
return "npth"
|
|
328
|
+
n = _num(p.get("number"))
|
|
329
|
+
if not n:
|
|
330
|
+
return "npth" if p.get("type") == "npth" else "mount"
|
|
331
|
+
if mount.fullmatch(n):
|
|
332
|
+
return "shield" if n.upper().startswith("SH") else "mount"
|
|
333
|
+
return "pin"
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def table_layout(spec):
|
|
337
|
+
"""pad_table -> (pads in mm / KiCad frame, body rect or None, keepouts, info). Pads keep their source row (_src)."""
|
|
338
|
+
t = spec["pad_table"]
|
|
339
|
+
f = UNIT[t.get("unit", "mm")]
|
|
340
|
+
raw = _expand_table(t)
|
|
341
|
+
if not raw:
|
|
342
|
+
raise ValueError("pad_table has no pads")
|
|
343
|
+
for p in raw: # polygon pads: position and extent from their points
|
|
344
|
+
if p.get("polygon"):
|
|
345
|
+
xs, ys = [v["x"] for v in p["polygon"]], [v["y"] for v in p["polygon"]]
|
|
346
|
+
p.setdefault("x", (min(xs) + max(xs)) / 2)
|
|
347
|
+
p.setdefault("y", (min(ys) + max(ys)) / 2)
|
|
348
|
+
p["_ext"] = (min(xs), min(ys), max(xs), max(ys))
|
|
349
|
+
elif "x" not in p or "y" not in p:
|
|
350
|
+
raise ValueError(f"{p['_src']} (pad {p.get('number')}): no x/y")
|
|
351
|
+
sx =-1 if t.get("view", "top") == "bottom" else 1
|
|
352
|
+
sy = -1 if t.get("y_axis", "up") == "up" else 1
|
|
353
|
+
if t.get("center_at"):
|
|
354
|
+
cx, cy = t["center_at"]["x"], t["center_at"]["y"]
|
|
355
|
+
elif t.get("origin", "center") == "center":
|
|
356
|
+
cx = cy = 0.0
|
|
357
|
+
else: # the middle of the pads' extent
|
|
358
|
+
ext = [p.get("_ext") or (p["x"] - (p.get("w") or p.get("d") or 0) / 2, p["y"] - (p.get("h") or p.get("d") or 0) / 2,
|
|
359
|
+
p["x"] + (p.get("w") or p.get("d") or 0) / 2, p["y"] + (p.get("h") or p.get("d") or 0) / 2)
|
|
360
|
+
for p in raw]
|
|
361
|
+
cx = (min(e[0] for e in ext) + max(e[2] for e in ext)) / 2
|
|
362
|
+
cy = (min(e[1] for e in ext) + max(e[3] for e in ext)) / 2
|
|
363
|
+
mount = MOUNT # H1, S2 .. are pins (balls, contacts), MP / SH are not
|
|
364
|
+
X = lambda x: _r4(sx * (x - cx) * f)
|
|
365
|
+
Y = lambda y: _r4(sy * (y - cy) * f)
|
|
366
|
+
pads = []
|
|
367
|
+
for p in raw:
|
|
368
|
+
kind = {"smd": "smd", "tht": "thru_hole", "npth": "np_thru_hole"}[p.get("type") or ("tht" if p.get("drill") else "smd")]
|
|
369
|
+
role = _role(dict(p, type={"thru_hole": "tht", "np_thru_hole": "npth"}.get(kind, kind)), mount)
|
|
370
|
+
q = {"num": "" if kind == "np_thru_hole" else _num(p.get("number")), "kind": kind, "role": role,
|
|
371
|
+
"x": X(p["x"]), "y": Y(p["y"]), "rot": (p.get("rotation", 0) or 0) * sx, "_src": p["_src"]}
|
|
372
|
+
if p.get("polygon"):
|
|
373
|
+
pts = [(X(v["x"]), Y(v["y"])) for v in p["polygon"]]
|
|
374
|
+
if len(pts) < 3:
|
|
375
|
+
raise ValueError(f"{p['_src']}: a polygon pad needs at least 3 points")
|
|
376
|
+
xs, ys = [a for a, _ in pts], [b for _, b in pts]
|
|
377
|
+
q.update(shape="custom", poly=pts, w=_r4(max(xs) - min(xs)), h=_r4(max(ys) - min(ys)), rot=0)
|
|
378
|
+
else:
|
|
379
|
+
d = p.get("d")
|
|
380
|
+
w, h = (d, d) if d else (p.get("w"), p.get("h"))
|
|
381
|
+
drill = p.get("drill")
|
|
382
|
+
if (w is None or h is None) and kind == "np_thru_hole" and drill:
|
|
383
|
+
w = h = drill
|
|
384
|
+
if (w is None or h is None) and kind == "thru_hole" and drill:
|
|
385
|
+
q["auto_size"] = True
|
|
386
|
+
w = h = drill # sized later by tht_pad_size (needs the row direction)
|
|
387
|
+
if w is None or h is None:
|
|
388
|
+
raise ValueError(f"{p['_src']} (pad {p.get('number')}): no size (give w and h, or d)")
|
|
389
|
+
q.update(w=_r4(w * f), h=_r4(h * f), shape=p.get("shape") or ("circle" if d else "roundrect" if kind == "smd"
|
|
390
|
+
else "circle" if abs(w - h) < 1e-9 else "oval"))
|
|
391
|
+
q["_auto_shape"] = not p.get("shape")
|
|
392
|
+
if q["shape"] == "polygon":
|
|
393
|
+
raise ValueError(f"{p['_src']}: shape polygon needs polygon points")
|
|
394
|
+
if q["shape"] == "circle" and abs(q["w"] - q["h"]) > 1e-9:
|
|
395
|
+
q["shape"] = "oval"
|
|
396
|
+
if kind != "smd":
|
|
397
|
+
dr = p.get("drill")
|
|
398
|
+
if not dr:
|
|
399
|
+
raise ValueError(f"{p['_src']} (pad {p.get('number')}): a {p.get('type')} pad needs a drill")
|
|
400
|
+
q["drill"] = (_r4(dr * f), _r4((p.get("drill_h") or dr) * f))
|
|
401
|
+
for k in ("mask_margin", "paste_margin"):
|
|
402
|
+
if p.get(k) is not None:
|
|
403
|
+
q[k] = _r4(p[k] * f)
|
|
404
|
+
q["paste"] = p.get("paste", "none" if kind != "smd" else "full")
|
|
405
|
+
for k in ("paste_coverage", "paste_grid", "thermal_vias"):
|
|
406
|
+
if p.get(k) is not None:
|
|
407
|
+
q[k] = p[k]
|
|
408
|
+
if q.get("thermal_vias"): # vias in mm by default (as before); unit mil / in / table
|
|
409
|
+
tv = dict(q["thermal_vias"])
|
|
410
|
+
u = tv.pop("unit", "mm")
|
|
411
|
+
g = f if u == "table" else UNIT[u]
|
|
412
|
+
q["thermal_vias"] = {k: (_r4(v * g) if k in ("drill", "diameter", "pitch", "pitch_y") else v) for k, v in tv.items()}
|
|
413
|
+
pads.append(q)
|
|
414
|
+
body = None
|
|
415
|
+
if t.get("body"):
|
|
416
|
+
b = t["body"]
|
|
417
|
+
xa, xb, ya, yb = X(b["x1"]), X(b["x2"]), Y(b["y1"]), Y(b["y2"])
|
|
418
|
+
body = (min(xa, xb), min(ya, yb), max(xa, xb), max(ya, yb))
|
|
419
|
+
elif spec.get("body_length") and spec.get("body_width"):
|
|
420
|
+
bx, by = _dim(spec["body_length"])[1] / 2, _dim(spec["body_width"])[1] / 2
|
|
421
|
+
body = (-bx, -by, bx, by)
|
|
422
|
+
keepouts = []
|
|
423
|
+
for k in t.get("keepouts") or []:
|
|
424
|
+
if k.get("polygon"):
|
|
425
|
+
pts = [(X(v["x"]), Y(v["y"])) for v in k["polygon"]]
|
|
426
|
+
else:
|
|
427
|
+
xa, xb, ya, yb = X(k["x1"]), X(k["x2"]), Y(k["y1"]), Y(k["y2"])
|
|
428
|
+
pts = [(min(xa, xb), min(ya, yb)), (max(xa, xb), min(ya, yb)), (max(xa, xb), max(ya, yb)), (min(xa, xb), max(ya, yb))]
|
|
429
|
+
keepouts.append({"pts": pts, "layers": k.get("layers") or ["F.Cu"], "rules": k.get("rules") or ["tracks", "vias", "pads", "copperpour"]})
|
|
430
|
+
return pads, body, keepouts, {"unit": t.get("unit", "mm"), "factor": f, "center": (cx, cy)}
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def tht_pad_size(pads, pitch=None):
|
|
434
|
+
"""KiCad's connector rule for through-hole pads given only a drill: drill + 2 x 0.5 mm, but no wider than
|
|
435
|
+
pitch - 0.8 mm across the row (then oval), never less than a 0.15 mm ring."""
|
|
436
|
+
pins = [p for p in pads if p["kind"] == "thru_hole"]
|
|
437
|
+
for p in pins:
|
|
438
|
+
if not p.pop("auto_size", False):
|
|
439
|
+
continue
|
|
440
|
+
d = max(p["drill"])
|
|
441
|
+
full = _r4(d + 2 * TH_RING)
|
|
442
|
+
near = sorted(((abs(q["x"] - p["x"]), abs(q["y"] - p["y"])) for q in pins if q is not p),
|
|
443
|
+
key=lambda v: math.hypot(*v))
|
|
444
|
+
w = h = full
|
|
445
|
+
if near:
|
|
446
|
+
dx, dy = near[0]
|
|
447
|
+
gap = pitch or math.hypot(dx, dy)
|
|
448
|
+
lim = _r4(max(gap - TH_PAD_GAP, d + 2 * TH_RING_MIN))
|
|
449
|
+
if dx >= dy and lim < full:
|
|
450
|
+
w = lim
|
|
451
|
+
elif dy > dx and lim < full:
|
|
452
|
+
h = lim
|
|
453
|
+
if round((p.get("rot") or 0) / 90) % 2: # w / h are in the pad's own frame: KiCad turns the pad by rot
|
|
454
|
+
w, h = h, w
|
|
455
|
+
p["w"], p["h"] = w, h
|
|
456
|
+
p["shape"] = "circle" if abs(w - h) < 1e-9 else "oval"
|
|
457
|
+
for p in pins: # KiCad / KLC: pin 1 of a through-hole connector is (rounded) square
|
|
458
|
+
if p["num"] == "1" and p["role"] == "pin" and p.pop("_auto_shape", False):
|
|
459
|
+
p["shape"] = "roundrect"
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
# ---------------------------------------------------------------- consistency checks on a table
|
|
463
|
+
def check_pad_table(spec, pads=None):
|
|
464
|
+
"""Consistency checks on a pad table (mm, KiCad frame). Returns [{level, rule, message}]."""
|
|
465
|
+
out = []
|
|
466
|
+
add = lambda lvl, rule, msg: out.append({"level": lvl, "rule": rule, "message": msg})
|
|
467
|
+
t = spec.get("pad_table") or {}
|
|
468
|
+
if pads is None:
|
|
469
|
+
pads = table_layout(spec)[0]
|
|
470
|
+
tht_pad_size(pads, spec.get("pitch"))
|
|
471
|
+
unit = t.get("unit", "mm")
|
|
472
|
+
raw = _expand_table(t) if t else []
|
|
473
|
+
vals = [abs(v) for p in raw for k, v in p.items() if k in ("x", "y", "w", "h", "d", "drill") and isinstance(v, (int, float))]
|
|
474
|
+
if raw and unit == "mm" and vals and max(vals) > 60:
|
|
475
|
+
add("error", "units", f"pad_table values up to {max(vals):g} with unit mm: that looks like mils (set unit to mil)")
|
|
476
|
+
sizes = [abs(v) for p in raw for k, v in p.items() if k in ("w", "h", "d", "drill") and isinstance(v, (int, float))]
|
|
477
|
+
if raw and unit == "mil" and sizes and max(sizes) < 4:
|
|
478
|
+
add("warning", "units", f"pad_table unit is mil but no pad or hole is bigger than {max(sizes):g} mil "
|
|
479
|
+
f"({max(sizes) * 0.0254:.3f} mm): is the table in mm?")
|
|
480
|
+
pins = [p for p in pads if p["role"] in PIN_ROLES]
|
|
481
|
+
nums = [p["num"] for p in pins]
|
|
482
|
+
distinct = sorted(set(nums), key=lambda s: (len(s), s))
|
|
483
|
+
if spec.get("pin_count") and len(distinct) != spec["pin_count"]:
|
|
484
|
+
add("error", "count", f"pin_count is {spec['pin_count']} but the table has {len(distinct)} numbered pins "
|
|
485
|
+
f"(mounting pads, shield tabs and holes not counted)")
|
|
486
|
+
ints = sorted(int(n) for n in distinct if n.isdigit())
|
|
487
|
+
if ints and len(ints) == len(distinct) and ints[0] >= 1:
|
|
488
|
+
missing = sorted(set(range(1, ints[-1] + 1)) - set(ints))
|
|
489
|
+
if missing:
|
|
490
|
+
add("warning", "numbers", f"pin numbers skip {', '.join(map(str, missing[:10]))}{' ...' if len(missing) > 10 else ''}")
|
|
491
|
+
same = [set(map(str, g)) for g in spec.get("same_net") or []]
|
|
492
|
+
for n in sorted({n for n in nums if nums.count(n) > 1}):
|
|
493
|
+
locs = [p for p in pins if p["num"] == n]
|
|
494
|
+
if all(abs(q["x"] - locs[0]["x"]) < 1e-6 and abs(q["y"] - locs[0]["y"]) < 1e-6 for q in locs[1:]):
|
|
495
|
+
add("error", "numbers", f"pad {n} is listed twice at the same place")
|
|
496
|
+
if not ({"1", "A1"} & set(nums)):
|
|
497
|
+
add("error", "pin1", "no pad numbered 1 (or A1)")
|
|
498
|
+
# overlaps between different pads
|
|
499
|
+
cu = [p for p in pads if p["kind"] != "np_thru_hole"]
|
|
500
|
+
for i, a in enumerate(cu):
|
|
501
|
+
for b in cu[i + 1:]:
|
|
502
|
+
if a["num"] and a["num"] == b["num"]:
|
|
503
|
+
continue
|
|
504
|
+
if any({a["num"], b["num"]} <= g for g in same):
|
|
505
|
+
continue
|
|
506
|
+
ax1, ay1, ax2, ay2 = pad_bbox(a)
|
|
507
|
+
bx1, by1, bx2, by2 = pad_bbox(b)
|
|
508
|
+
if min(ax2, bx2) - max(ax1, bx1) > 0.005 and min(ay2, by2) - max(ay1, by1) > 0.005:
|
|
509
|
+
if min(_pad_dist(b["x"], b["y"], a), _pad_dist(a["x"], a["y"], b)) == 0 or not (a.get("poly") or b.get("poly")):
|
|
510
|
+
add("error", "overlap", f"pads {a['num'] or '(hole)'} ({a['_src']}) and {b['num'] or '(hole)'} ({b['_src']}) overlap: "
|
|
511
|
+
"check their x/y (list same-net pads in same_net)")
|
|
512
|
+
# drill vs pad
|
|
513
|
+
for p in pads:
|
|
514
|
+
if p["kind"] == "thru_hole":
|
|
515
|
+
ring = min(p["w"] - p["drill"][0], p["h"] - p["drill"][1]) / 2
|
|
516
|
+
if ring <= 0:
|
|
517
|
+
add("error", "drill", f"pad {p['num']} ({p['_src']}): drill {p['drill'][0]:g} is not smaller than the pad")
|
|
518
|
+
elif ring < TH_RING_MIN - 1e-6:
|
|
519
|
+
add("warning", "drill", f"pad {p['num']} ({p['_src']}): annular ring {ring:.3f} mm < {TH_RING_MIN} mm")
|
|
520
|
+
# pitch regularity along rows and columns of equal pads
|
|
521
|
+
row_pitches = []
|
|
522
|
+
for axis in (0, 1):
|
|
523
|
+
key = "y" if axis == 0 else "x"
|
|
524
|
+
along = "x" if axis == 0 else "y"
|
|
525
|
+
groups = {}
|
|
526
|
+
for p in pins:
|
|
527
|
+
groups.setdefault((round(p[key], 2), round(p["w"], 3), round(p["h"], 3)), []).append(p)
|
|
528
|
+
for g in groups.values():
|
|
529
|
+
g = sorted({round(p[along], 4): p for p in g}.values(), key=lambda p: p[along])
|
|
530
|
+
if len(g) < 3:
|
|
531
|
+
continue
|
|
532
|
+
steps = [round(b[along] - a[along], 4) for a, b in zip(g, g[1:])]
|
|
533
|
+
counts = {}
|
|
534
|
+
for st in steps:
|
|
535
|
+
counts[round(st, 2)] = counts.get(round(st, 2), 0) + 1
|
|
536
|
+
sp = spec.get("pitch")
|
|
537
|
+
if sp and any(abs(st - sp) <= 0.011 for st in steps):
|
|
538
|
+
base = sp # the spec's pitch, when this row uses it
|
|
539
|
+
else:
|
|
540
|
+
base, cnt = max(counts.items(), key=lambda kv: (kv[1], -kv[0]))
|
|
541
|
+
if cnt < 2:
|
|
542
|
+
continue # no clear pitch in this row
|
|
543
|
+
off = lambda st: abs(st - round(st / base) * base)
|
|
544
|
+
bad = [(a, b, st) for (a, b), st in zip(zip(g, g[1:]), steps)
|
|
545
|
+
if (st < 1.5 * base and abs(st - base) > 0.011) or (st >= 1.5 * base and 0.011 < off(st) < 0.1 * base)]
|
|
546
|
+
for a, b, st in bad[:3]:
|
|
547
|
+
add("warning", "pitch", f"pads {a['num']} and {b['num']} are {st:g} mm apart but the row pitch is {base:g} mm "
|
|
548
|
+
f"({b['_src']}): misread coordinate?")
|
|
549
|
+
row_pitches.append((base, g[0]["num"], g[-1]["num"]))
|
|
550
|
+
if spec.get("pitch") and row_pitches and not any(abs(b - spec["pitch"]) <= 0.011 for b, _, _ in row_pitches):
|
|
551
|
+
b, a, z = max(row_pitches, key=lambda r: r[0])
|
|
552
|
+
add("warning", "pitch", f"no row of pads has the spec's {spec['pitch']:g} mm pitch (pads {a}..{z}: {b:g} mm)")
|
|
553
|
+
# symmetry about the package centre
|
|
554
|
+
cu_all = [p for p in pads if p["role"] != "npth"]
|
|
555
|
+
if len(cu_all) >= 4 and t:
|
|
556
|
+
best = None # mirrored about the package centre (0, 0 before any origin shift)
|
|
557
|
+
for name, fx, fy in (("left/right", -1, 1), ("top/bottom", 1, -1), ("180 deg", -1, -1)):
|
|
558
|
+
if all(abs(fx * p["x"] - p["x"]) < 0.02 and abs(fy * p["y"] - p["y"]) < 0.02 for p in cu_all):
|
|
559
|
+
continue # every pad on the mirror line: says nothing
|
|
560
|
+
odd = []
|
|
561
|
+
for p in cu_all:
|
|
562
|
+
if not any(abs(q["x"] - fx * p["x"]) < 0.02 and abs(q["y"] - fy * p["y"]) < 0.02 and
|
|
563
|
+
abs(pad_bbox(q)[2] - pad_bbox(q)[0] - (pad_bbox(p)[2] - pad_bbox(p)[0])) < 0.02 and
|
|
564
|
+
abs(pad_bbox(q)[3] - pad_bbox(q)[1] - (pad_bbox(p)[3] - pad_bbox(p)[1])) < 0.02 for q in cu_all):
|
|
565
|
+
odd.append(p)
|
|
566
|
+
if best is None or len(odd) < len(best[1]):
|
|
567
|
+
best = (name, odd)
|
|
568
|
+
name, odd = best
|
|
569
|
+
if odd and len(odd) <= max(2, len(cu_all) // 10):
|
|
570
|
+
add("warning", "symmetry", f"the pads are {name} symmetric except " +
|
|
571
|
+
", ".join(f"pad {p['num'] or '(hole)'} at ({p['x']:g}, {p['y']:g}) ({p['_src']})" for p in odd[:4]) +
|
|
572
|
+
": probably a misread number")
|
|
573
|
+
elif odd:
|
|
574
|
+
add("info", "symmetry", "the pad pattern is not symmetric about the package centre (normal for many connectors; "
|
|
575
|
+
"if this part is symmetric, check center_at / origin)")
|
|
576
|
+
# pin 1 location
|
|
577
|
+
p1 = next((p for p in pins if p["num"] in ("1", "A1")), None)
|
|
578
|
+
if p1 is not None:
|
|
579
|
+
row = [p for p in pins if abs(p["y"] - p1["y"]) < 0.01]
|
|
580
|
+
col = [p for p in pins if abs(p["x"] - p1["x"]) < 0.01]
|
|
581
|
+
ends = lambda g, k: abs(p1[k] - min(q[k] for q in g)) < 0.01 or abs(p1[k] - max(q[k] for q in g)) < 0.01
|
|
582
|
+
if (len(row) >= 3 and not ends(row, "x")) and (len(col) >= 3 and not ends(col, "y")):
|
|
583
|
+
add("warning", "pin1", f"pad {p1['num']} is in the middle of its row: pin 1 is normally at an end (check numbering)")
|
|
584
|
+
if t.get("origin") == "pin1":
|
|
585
|
+
src = next(r for r in raw if _num(r.get("number")) == p1["num"])
|
|
586
|
+
if abs(src["x"]) > 1e-6 or abs(src["y"]) > 1e-6:
|
|
587
|
+
add("error", "pin1", f"the drawing's origin is pin 1 but pad {p1['num']} is at ({src['x']:g}, {src['y']:g}) "
|
|
588
|
+
f"{unit}: origin or pad coordinates misread")
|
|
589
|
+
return out
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
# ---------------------------------------------------------------- S-expression output
|
|
593
|
+
def _n(x):
|
|
594
|
+
return _F().n(x)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _q(s):
|
|
598
|
+
return _F().q(s)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def pad_sexpr(p):
|
|
602
|
+
kind, shape = p["kind"], p["shape"]
|
|
603
|
+
at = f"(at {_n(p['x'])} {_n(p['y'])}{' ' + _n(p['rot']) if p.get('rot') else ''})"
|
|
604
|
+
if kind == "smd":
|
|
605
|
+
lay = ["F.Cu"] + (["F.Paste"] if p.get("paste") == "full" else []) + ["F.Mask"]
|
|
606
|
+
elif p.get("via"):
|
|
607
|
+
lay = ["*.Cu"]
|
|
608
|
+
else:
|
|
609
|
+
lay = ["*.Cu", "*.Mask"]
|
|
610
|
+
if p.get("layers"):
|
|
611
|
+
lay = p["layers"]
|
|
612
|
+
s = f' (pad {_q(p["num"])} {kind} {shape} {at}'
|
|
613
|
+
if shape == "custom":
|
|
614
|
+
ax, ay, size = p["anchor"]
|
|
615
|
+
s = f' (pad {_q(p["num"])} {kind} custom (at {_n(ax)} {_n(ay)}) (size {_n(size)} {_n(size)})'
|
|
616
|
+
else:
|
|
617
|
+
s += f' (size {_n(p["w"])} {_n(p["h"])})'
|
|
618
|
+
if p.get("drill"):
|
|
619
|
+
dw, dh = p["drill"]
|
|
620
|
+
s += f" (drill {_n(dw)})" if abs(dw - dh) < 1e-9 else f" (drill oval {_n(dw)} {_n(dh)})"
|
|
621
|
+
s += " (layers " + " ".join(_q(l) for l in lay) + ")"
|
|
622
|
+
if shape == "roundrect":
|
|
623
|
+
s += f" (roundrect_rratio {_n(min(0.25, 0.25 / min(p['w'], p['h'])))})"
|
|
624
|
+
if p.get("mask_margin") is not None:
|
|
625
|
+
s += f" (solder_mask_margin {_n(p['mask_margin'])})"
|
|
626
|
+
if p.get("paste_margin") is not None:
|
|
627
|
+
s += f" (solder_paste_margin {_n(p['paste_margin'])})"
|
|
628
|
+
if shape == "custom":
|
|
629
|
+
ax, ay, _ = p["anchor"]
|
|
630
|
+
pts = " ".join(f"(xy {_n(x - ax)} {_n(y - ay)})" for x, y in p["poly"])
|
|
631
|
+
s += f"\n (options (clearance outline) (anchor circle))\n (primitives (gr_poly (pts {pts}) (width 0) (fill yes)))"
|
|
632
|
+
return s + ")"
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _paste_windows(p, cov=0.65, grid=None):
|
|
636
|
+
"""Split a big pad's paste into windows of about 1.2 mm (KLC F6.3 style), coverage ~cov of the copper."""
|
|
637
|
+
x1, y1, x2, y2 = pad_bbox(p)
|
|
638
|
+
w, h = x2 - x1, y2 - y1
|
|
639
|
+
gx = (grid[0] if isinstance(grid, list) else grid) if grid else max(1, min(6, math.ceil(w / 1.2 - 1e-9)))
|
|
640
|
+
gy = (grid[1] if isinstance(grid, list) else grid) if grid else max(1, min(6, math.ceil(h / 1.2 - 1e-9)))
|
|
641
|
+
area = _poly_area(p["poly"]) if p.get("poly") else w * h
|
|
642
|
+
sw, sh = w * math.sqrt(cov) / gx, h * math.sqrt(cov) / gy
|
|
643
|
+
wins = []
|
|
644
|
+
for i in range(gx):
|
|
645
|
+
for j in range(gy):
|
|
646
|
+
cx, cy = x1 + (i + 0.5) * w / gx, y1 + (j + 0.5) * h / gy
|
|
647
|
+
if p.get("poly"): # keep windows wholly inside the polygon
|
|
648
|
+
corners = [(cx + a * sw / 2, cy + b * sh / 2) for a in (-1, 1) for b in (-1, 1)]
|
|
649
|
+
if not all(_pt_in_poly(x, y, p["poly"]) and _pad_dist(x, y, p) == 0 for x, y in corners):
|
|
650
|
+
continue
|
|
651
|
+
wins.append((_r4(cx), _r4(cy), round(sw, 3), round(sh, 3)))
|
|
652
|
+
if p.get("poly") and wins: # scale up to reach the coverage over the polygon
|
|
653
|
+
k = min(math.sqrt(cov * area / sum(a[2] * a[3] for a in wins)), w / gx / sw * 0.95, h / gy / sh * 0.95)
|
|
654
|
+
trial = [(x, y, round(a * k, 3), round(b * k, 3)) for x, y, a, b in wins]
|
|
655
|
+
if all(all(_pt_in_poly(x + s * a / 2, y + t * b / 2, p["poly"]) for s in (-1, 1) for t in (-1, 1)) for x, y, a, b in trial):
|
|
656
|
+
wins = trial
|
|
657
|
+
return wins, (gx, gy)
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _thermal_vias(p, tv, notes=None):
|
|
661
|
+
"""A grid of vias (same number as the pad) centred on the pad (polygon pads: on their anchor); vias that would
|
|
662
|
+
stick out of the copper are left out and listed in notes."""
|
|
663
|
+
skipped = []
|
|
664
|
+
x1, y1, x2, y2 = pad_bbox(p)
|
|
665
|
+
nx, ny = tv.get("nx", 2), tv.get("ny", 2)
|
|
666
|
+
pitch = tv.get("pitch")
|
|
667
|
+
dx = pitch or (x2 - x1) / nx
|
|
668
|
+
dy = tv.get("pitch_y") or pitch or (y2 - y1) / ny
|
|
669
|
+
out = []
|
|
670
|
+
for i in range(nx):
|
|
671
|
+
for j in range(ny):
|
|
672
|
+
x, y = p["x"] + (i - (nx - 1) / 2) * dx, p["y"] + (j - (ny - 1) / 2) * dy
|
|
673
|
+
d = tv.get("diameter", tv["drill"] + 0.3)
|
|
674
|
+
ring = [(x + d / 2 * math.cos(k * math.pi / 4), y + d / 2 * math.sin(k * math.pi / 4)) for k in range(8)]
|
|
675
|
+
if not all(_pad_dist(a, b, p) <= 1e-6 for a, b in ring + [(x, y)]): # (a via may touch the edge)
|
|
676
|
+
skipped.append((round(x, 3), round(y, 3)))
|
|
677
|
+
continue
|
|
678
|
+
out.append({"num": p["num"], "kind": "thru_hole", "shape": "circle", "x": _r4(x), "y": _r4(y), "w": d, "h": d,
|
|
679
|
+
"drill": (tv["drill"], tv["drill"]), "via": True, "role": "thermal_via", "_src": p["_src"]})
|
|
680
|
+
if skipped and notes is not None:
|
|
681
|
+
notes.append(f"pad {p['num']}: {len(skipped)} of {nx * ny} thermal vias left out (outside the copper at {skipped[:4]})")
|
|
682
|
+
return out
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _clip_any(a, b, pads, c, min_len=0.2):
|
|
686
|
+
"""Pieces of the segment a-b that stay at least c from every pad (sampled every 0.01 mm)."""
|
|
687
|
+
L = math.hypot(b[0] - a[0], b[1] - a[1])
|
|
688
|
+
n = max(2, int(L / 0.01))
|
|
689
|
+
lo_x, hi_x, lo_y, hi_y = min(a[0], b[0]) - c, max(a[0], b[0]) + c, min(a[1], b[1]) - c, max(a[1], b[1]) + c
|
|
690
|
+
pads = [p for p in pads if not (pad_bbox(p)[0] > hi_x or pad_bbox(p)[2] < lo_x or pad_bbox(p)[1] > hi_y or pad_bbox(p)[3] < lo_y)]
|
|
691
|
+
ok = []
|
|
692
|
+
for k in range(n + 1):
|
|
693
|
+
t = k / n
|
|
694
|
+
x, y = a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t
|
|
695
|
+
ok.append(all(_pad_dist(x, y, p) >= c for p in pads))
|
|
696
|
+
pieces, start = [], None
|
|
697
|
+
for k, v in enumerate(ok + [False]):
|
|
698
|
+
if v and start is None:
|
|
699
|
+
start = k
|
|
700
|
+
if not v and start is not None:
|
|
701
|
+
t0, t1 = start / n, (k - 1) / n
|
|
702
|
+
if (t1 - t0) * L >= min_len:
|
|
703
|
+
pieces.append(((a[0] + (b[0] - a[0]) * t0, a[1] + (b[1] - a[1]) * t0),
|
|
704
|
+
(a[0] + (b[0] - a[0]) * t1, a[1] + (b[1] - a[1]) * t1)))
|
|
705
|
+
start = None
|
|
706
|
+
return [((_r4(p[0]), _r4(p[1])), (_r4(q[0]), _r4(q[1]))) for p, q in pieces]
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _pin1_mark(p1, pads, box, c):
|
|
710
|
+
"""A filled silk triangle next to pin 1, outside the silk outline when possible, pointing at the pad."""
|
|
711
|
+
from .footprint_check import _point_in_poly
|
|
712
|
+
x1, y1, x2, y2 = pad_bbox(p1)
|
|
713
|
+
s, depth = 0.24, 0.33
|
|
714
|
+
cands = []
|
|
715
|
+
for d in ((-1, 0), (0, -1), (0, 1), (1, 0)):
|
|
716
|
+
for push in (True, False):
|
|
717
|
+
if d[0]:
|
|
718
|
+
edge = x1 if d[0] < 0 else x2
|
|
719
|
+
ax = edge + d[0] * (c + 0.02)
|
|
720
|
+
if push and box:
|
|
721
|
+
ax = min(ax, box[0] - 0.15) if d[0] < 0 else max(ax, box[2] + 0.15)
|
|
722
|
+
tri = [(ax, p1["y"]), (ax + d[0] * depth, p1["y"] - s), (ax + d[0] * depth, p1["y"] + s)]
|
|
723
|
+
else:
|
|
724
|
+
edge = y1 if d[1] < 0 else y2
|
|
725
|
+
ay = edge + d[1] * (c + 0.02)
|
|
726
|
+
if push and box:
|
|
727
|
+
ay = min(ay, box[1] - 0.15) if d[1] < 0 else max(ay, box[3] + 0.15)
|
|
728
|
+
tri = [(p1["x"], ay), (p1["x"] - s, ay + d[1] * depth), (p1["x"] + s, ay + d[1] * depth)]
|
|
729
|
+
cands.append((0 if push else 1, math.hypot(tri[0][0] - p1["x"], tri[0][1] - p1["y"]), len(cands), tri))
|
|
730
|
+
for *_, tri in sorted(cands):
|
|
731
|
+
segs = list(zip(tri, tri[1:] + tri[:1]))
|
|
732
|
+
clear = all(min(_pad_dist(x, y, p) for p in pads) >= c
|
|
733
|
+
for a, b in segs for x, y in ((a[0] + (b[0] - a[0]) * k / 20, a[1] + (b[1] - a[1]) * k / 20) for k in range(21)))
|
|
734
|
+
if clear and not any(_point_in_poly(p["x"], p["y"], tri) for p in pads):
|
|
735
|
+
return [(_r4(x), _r4(y)) for x, y in tri]
|
|
736
|
+
return None
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _emit(spec, pads, body, rep, excess, extra_rects=(), keepouts=(), corner_mark=None, courtyard_union=True):
|
|
740
|
+
"""Common writer: fab (body + pin-1 chamfer), silk (body outline cut back from the pads + pin-1 mark),
|
|
741
|
+
courtyard (union outline or rectangle), pads, keep-out zones. Returns kicad_mod text; fills rep."""
|
|
742
|
+
F = _F()
|
|
743
|
+
copper = pads
|
|
744
|
+
p1 = next((p for p in pads if p["num"] in ("1", "A1") and p["role"] in PIN_ROLES), None) or \
|
|
745
|
+
next((p for p in pads if p["role"] in PIN_ROLES), pads[0])
|
|
746
|
+
# ---- courtyard
|
|
747
|
+
rects = [pad_bbox(p) for p in pads] + ([body] if body else []) + list(extra_rects)
|
|
748
|
+
grown = [(_rdn(r[0] - excess), _rdn(r[1] - excess), _rup(r[2] + excess), _rup(r[3] + excess)) for r in rects]
|
|
749
|
+
bb = (min(r[0] for r in grown), min(r[1] for r in grown), max(r[2] for r in grown), max(r[3] for r in grown))
|
|
750
|
+
outline = union_outline(grown) if courtyard_union and len(grown) > 1 else None
|
|
751
|
+
if outline is None or len(outline) <= 4:
|
|
752
|
+
outline = None
|
|
753
|
+
rep["courtyard"] = (round(bb[2] - bb[0], 2), round(bb[3] - bb[1], 2))
|
|
754
|
+
rep["courtyard_bbox"] = tuple(round(v, 2) for v in bb)
|
|
755
|
+
rep["courtyard_shape"] = f"union outline, {len(outline)} corners" if outline else "rectangle"
|
|
756
|
+
attr = "through_hole" if any(p["kind"] == "thru_hole" and p["role"] in PIN_ROLES and not p.get("via") for p in pads) \
|
|
757
|
+
else "smd" # KiCad: THT shield / mounting pads alone don't make it THT
|
|
758
|
+
density = rep["density"]
|
|
759
|
+
tags = " ".join(filter(None, [spec["package_type"], rep.get("ipc_family", ""), f"IPC-7351B density-{density}"]))
|
|
760
|
+
top, bot = bb[1], bb[3]
|
|
761
|
+
out = [f'(footprint {_q(spec["name"])} (version 20221018) (generator "ecadlib_footprint_gen")',
|
|
762
|
+
' (layer "F.Cu")',
|
|
763
|
+
f' (descr {_q(rep["descr"])})',
|
|
764
|
+
f' (tags {_q(tags)})',
|
|
765
|
+
f' (attr {attr})',
|
|
766
|
+
F.text("reference", "REF**", _r4((bb[0] + bb[2]) / 2), _r4(top - 0.7), "F.SilkS"),
|
|
767
|
+
F.text("value", spec["name"], _r4((bb[0] + bb[2]) / 2), _r4(bot + 0.7), "F.Fab")]
|
|
768
|
+
if body:
|
|
769
|
+
bx, by = (body[2] - body[0]) / 2, (body[3] - body[1]) / 2
|
|
770
|
+
out.append(F.text("user", "${REFERENCE}", _r4((body[0] + body[2]) / 2), _r4((body[1] + body[3]) / 2), "F.Fab",
|
|
771
|
+
size=math.floor(100 * min(1.0, max(0.25, 0.5 * max(bx, by)))) / 100))
|
|
772
|
+
# fab: body with the corner nearest pin 1 chamfered
|
|
773
|
+
ch = min(1.0, 0.25 * min(body[2] - body[0], body[3] - body[1]))
|
|
774
|
+
corners = [(body[0], body[1]), (body[2], body[1]), (body[2], body[3]), (body[0], body[3])]
|
|
775
|
+
k = min(range(4), key=lambda i: math.hypot(corners[i][0] - p1["x"], corners[i][1] - p1["y"]))
|
|
776
|
+
pts = []
|
|
777
|
+
for i in range(4):
|
|
778
|
+
if i != k:
|
|
779
|
+
pts.append(corners[i]); continue
|
|
780
|
+
prev, nxt = corners[i - 1], corners[(i + 1) % 4]
|
|
781
|
+
u = lambda a, b: ((b[0] - a[0]) / (math.hypot(b[0] - a[0], b[1] - a[1]) or 1), (b[1] - a[1]) / (math.hypot(b[0] - a[0], b[1] - a[1]) or 1))
|
|
782
|
+
d1, d2 = u(corners[i], prev), u(corners[i], nxt)
|
|
783
|
+
pts += [(corners[i][0] + d1[0] * ch, corners[i][1] + d1[1] * ch), (corners[i][0] + d2[0] * ch, corners[i][1] + d2[1] * ch)]
|
|
784
|
+
out.append(F.poly([(_r4(x), _r4(y)) for x, y in pts], "F.Fab", F.FAB_W))
|
|
785
|
+
# ---- silk: body outline pushed out 0.11 mm, cut back from every pad (and hole) by 0.2 mm + half the line
|
|
786
|
+
c = F.SILK_PAD_CLEAR + F.SILK_W / 2
|
|
787
|
+
silk_box = None
|
|
788
|
+
if body:
|
|
789
|
+
o = F.SILK_FAB_OFFSET
|
|
790
|
+
silk_box = (body[0] - o, body[1] - o, body[2] + o, body[3] + o)
|
|
791
|
+
sb = silk_box
|
|
792
|
+
box = [((sb[0], sb[1]), (sb[2], sb[1])), ((sb[2], sb[1]), (sb[2], sb[3])), ((sb[2], sb[3]), (sb[0], sb[3])), ((sb[0], sb[3]), (sb[0], sb[1]))]
|
|
793
|
+
holes = [dict(p, shape="circle", w=p["drill"][0], h=p["drill"][1]) for p in pads if p["kind"] == "np_thru_hole"]
|
|
794
|
+
for a, b in box:
|
|
795
|
+
for s, e in _clip_any(a, b, copper + holes, c):
|
|
796
|
+
out.append(F.line(s[0], s[1], e[0], e[1], "F.SilkS", F.SILK_W))
|
|
797
|
+
if corner_mark is not None: # BGA: KiCad-style triangle outside the A1 corner of the silk
|
|
798
|
+
out.append(F.poly(corner_mark, "F.SilkS", F.SILK_W, fill="solid"))
|
|
799
|
+
else:
|
|
800
|
+
tri = _pin1_mark(p1, copper, silk_box, c)
|
|
801
|
+
if tri:
|
|
802
|
+
out.append(F.poly(tri, "F.SilkS", F.SILK_W, fill="solid"))
|
|
803
|
+
else:
|
|
804
|
+
rep.setdefault("notes", []).append("no room for a pin-1 mark on the silkscreen")
|
|
805
|
+
# ---- courtyard
|
|
806
|
+
if outline:
|
|
807
|
+
out.append(F.poly(outline, "F.CrtYd", F.CRT_W))
|
|
808
|
+
else:
|
|
809
|
+
out.append(F.rect(bb[0], bb[1], bb[2], bb[3], "F.CrtYd", F.CRT_W))
|
|
810
|
+
# ---- pads, paste windows
|
|
811
|
+
for p in pads:
|
|
812
|
+
out.append(pad_sexpr(p))
|
|
813
|
+
for (x, y, w, h) in p.get("windows", []):
|
|
814
|
+
out.append(F.pad("", x, y, w, h, layers=("F.Paste",)))
|
|
815
|
+
for k in keepouts:
|
|
816
|
+
rules = set(k["rules"])
|
|
817
|
+
ko = " ".join(f"({r} {'not_allowed' if r in rules else 'allowed'})" for r in ("tracks", "vias", "pads", "copperpour", "footprints"))
|
|
818
|
+
pts = " ".join(f"(xy {_n(x)} {_n(y)})" for x, y in k["pts"])
|
|
819
|
+
lay = " ".join(_q(l) for l in k["layers"])
|
|
820
|
+
out.append(f' (zone (net 0) (net_name "") (layers {lay}) (hatch edge 0.5) (connect_pads (clearance 0)) (min_thickness 0.25)'
|
|
821
|
+
f' (keepout {ko}) (fill (thermal_gap 0.5) (thermal_bridge_width 0.5)) (polygon (pts {pts})))')
|
|
822
|
+
if spec.get("model_3d"):
|
|
823
|
+
out.append(f' (model {_q(spec["model_3d"])} (offset (xyz 0 0 0)) (scale (xyz 1 1 1)) (rotate (xyz 0 0 0)))')
|
|
824
|
+
out.append(")")
|
|
825
|
+
return "\n".join(out) + "\n"
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _finish_pads(pads, notes=None):
|
|
829
|
+
"""Custom-pad anchors, paste windows and thermal vias."""
|
|
830
|
+
out = []
|
|
831
|
+
for p in pads:
|
|
832
|
+
if p.get("poly"):
|
|
833
|
+
d, ax, ay = _interior_point(p["poly"])
|
|
834
|
+
p["anchor"] = (_r4(ax), _r4(ay), round(max(0.05, min(1.6 * d, 1.0)), 3))
|
|
835
|
+
p["x"], p["y"] = _r4(ax), _r4(ay)
|
|
836
|
+
if p["kind"] == "smd" and p.get("paste") == "grid":
|
|
837
|
+
wins, g = _paste_windows(p, p.get("paste_coverage", 0.65), p.get("paste_grid"))
|
|
838
|
+
p["windows"] = wins
|
|
839
|
+
out.append(p)
|
|
840
|
+
if p.get("thermal_vias"):
|
|
841
|
+
out += _thermal_vias(p, p["thermal_vias"], notes)
|
|
842
|
+
return out
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
# ---------------------------------------------------------------- builders
|
|
846
|
+
def _bga(spec, density):
|
|
847
|
+
F = _F()
|
|
848
|
+
rows, cols, e = spec["rows"], spec["cols"], spec["pitch"]
|
|
849
|
+
ey = spec.get("pitch_y") or e
|
|
850
|
+
balls = bga_balls(spec)
|
|
851
|
+
if spec.get("pin_count") and spec["pin_count"] != len(balls):
|
|
852
|
+
raise ValueError(f"pin_count is {spec['pin_count']} but the {rows} x {cols} grid minus depopulated balls leaves "
|
|
853
|
+
f"{len(balls)} balls (check depopulated / ball_map)")
|
|
854
|
+
bt = spec.get("ball_type", "collapsing")
|
|
855
|
+
if spec.get("land_diameter"):
|
|
856
|
+
land, rule = _dim(spec["land_diameter"])[1], "datasheet land"
|
|
857
|
+
else:
|
|
858
|
+
if not spec.get("ball_diameter"):
|
|
859
|
+
raise ValueError("bga needs ball_diameter (or the datasheet's land_diameter)")
|
|
860
|
+
land, rule = ipc_bga_land(_dim(spec["ball_diameter"])[1], bt)
|
|
861
|
+
style = spec.get("land_style", "NSMD")
|
|
862
|
+
cu, mask = (land, NSMD_MASK) if style == "NSMD" else (round(land + 2 * SMD_GROW, 3), -SMD_GROW)
|
|
863
|
+
if cu >= min(e, ey) - 0.1: # the copper, which SMD-defined lands make bigger than the land
|
|
864
|
+
raise ValueError(f"land {land:g} mm{f' (copper {cu:g} mm, SMD-defined)' if cu != land else ''} leaves less than "
|
|
865
|
+
f"0.1 mm between pads at {min(e, ey):g} mm pitch: check ball_diameter / pitch (mils?)")
|
|
866
|
+
pads = []
|
|
867
|
+
for name, i, j in balls:
|
|
868
|
+
p = {"num": name, "kind": "smd", "shape": "circle", "x": _r4((j - (cols - 1) / 2) * e), "y": _r4((i - (rows - 1) / 2) * ey),
|
|
869
|
+
"w": cu, "h": cu, "role": "pin", "paste": "full", "mask_margin": mask, "_src": name}
|
|
870
|
+
if style == "SMD":
|
|
871
|
+
p["paste_margin"] = -SMD_GROW
|
|
872
|
+
pads.append(p)
|
|
873
|
+
bx, by = _dim(spec["body_length"])[1] / 2, _dim(spec["body_width"])[1] / 2
|
|
874
|
+
gx, gy = (cols - 1) * e / 2, (rows - 1) * ey / 2
|
|
875
|
+
if gx + land / 2 > bx or gy + land / 2 > by:
|
|
876
|
+
raise ValueError(f"the {rows} x {cols} grid at {e:g} mm pitch ({2 * gx:.2f} x {2 * gy:.2f} mm) doesn't fit the "
|
|
877
|
+
f"{2 * bx:g} x {2 * by:g} mm body: rows/cols swapped or pitch misread?")
|
|
878
|
+
body = (-bx, -by, bx, by)
|
|
879
|
+
o = F.SILK_FAB_OFFSET
|
|
880
|
+
sx, sy = bx + o, by + o
|
|
881
|
+
mark = [(-sx, -sy), (-sx - 0.5, -sy), (-sx, -sy - 0.5)]
|
|
882
|
+
nominal = f"{rows}x{cols} grid, {len(balls)} balls, pitch {e:g}{'/' + format(ey, 'g') if ey != e else ''} mm"
|
|
883
|
+
rep = {"name": spec["name"], "type": "bga", "density": density, "ipc_family": "bga", "land": land, "land_rule": rule,
|
|
884
|
+
"land_style": style, "copper": cu, "balls": len(balls), "grid": (rows, cols),
|
|
885
|
+
"Z": round(2 * gx + cu, 4), "G": round(min(e, ey) - cu, 4), "X": cu}
|
|
886
|
+
rep["descr"] = (f"{spec.get('description', spec['name'])} | {nominal}, {style} land {land:g} mm ({rule})"
|
|
887
|
+
+ (f" | src {spec['source']['datasheet']}" if spec.get("source", {}).get("datasheet") else "")
|
|
888
|
+
+ " | generated by ecadlib (prototype - review before use)")
|
|
889
|
+
txt = _emit(spec, pads, body, rep, spec.get("courtyard_excess", BGA_EXCESS[density]), corner_mark=mark, courtyard_union=False)
|
|
890
|
+
return txt, rep, pads
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def _header(spec, density):
|
|
894
|
+
cols, rows, e = spec.get("cols", 1), spec["rows"], spec["pitch"]
|
|
895
|
+
if cols not in (1, 2):
|
|
896
|
+
raise ValueError("pin_header: cols is 1 or 2 (use connector with a pad_table for anything else)")
|
|
897
|
+
n = rows * cols
|
|
898
|
+
if spec.get("pin_count") and spec["pin_count"] != n:
|
|
899
|
+
raise ValueError(f"pin_count is {spec['pin_count']} but {cols} x {rows} = {n}")
|
|
900
|
+
key = min(HEADER_TH, key=lambda k: abs(k - e))
|
|
901
|
+
pads, extra = [], []
|
|
902
|
+
if spec.get("mount", "tht") == "tht":
|
|
903
|
+
drill = _dim(spec["drill"])[1] if spec.get("drill") else HEADER_TH[key][0] if abs(key - e) < 0.01 else None
|
|
904
|
+
dia = _dim(spec["land_diameter"])[1] if spec.get("land_diameter") else HEADER_TH[key][1] if abs(key - e) < 0.01 else None
|
|
905
|
+
if drill is None:
|
|
906
|
+
raise ValueError(f"no default drill for a {e:g} mm pitch header: give drill (and land_diameter)")
|
|
907
|
+
if dia is None:
|
|
908
|
+
dia = round(min(drill + 2 * TH_RING, e - 0.5), 2)
|
|
909
|
+
for k in range(n):
|
|
910
|
+
r, c = divmod(k, cols)
|
|
911
|
+
pads.append({"num": str(k + 1), "kind": "thru_hole", "shape": "rect" if k == 0 else "circle", "x": _r4(c * e),
|
|
912
|
+
"y": _r4(r * e), "w": dia, "h": dia, "drill": (drill, drill), "role": "pin", "_src": f"pin {k + 1}"})
|
|
913
|
+
cx, cy = (cols - 1) * e / 2, (rows - 1) * e / 2
|
|
914
|
+
family = "pin_header_tht"
|
|
915
|
+
else:
|
|
916
|
+
land = spec.get("land")
|
|
917
|
+
if land:
|
|
918
|
+
ll, lw, lc = land["pad_length"], land["pad_width"], land["pad_center"]
|
|
919
|
+
elif abs(key - e) < 0.01 and key in (HEADER_SMD1 if cols == 1 else HEADER_SMD):
|
|
920
|
+
ll, lw, lc = (HEADER_SMD1 if cols == 1 else HEADER_SMD)[key] # single-row headers have their own land
|
|
921
|
+
else:
|
|
922
|
+
raise ValueError(f"no default SMD land for a {e:g} mm header: give land (pad_length, pad_width, pad_center)")
|
|
923
|
+
for k in range(n):
|
|
924
|
+
if cols == 2:
|
|
925
|
+
r, c = divmod(k, 2)
|
|
926
|
+
side = -1 if c == 0 else 1
|
|
927
|
+
else: # single row SMD: alternate sides, pin 1 on pin1_side
|
|
928
|
+
r = k
|
|
929
|
+
side = (-1 if k % 2 == 0 else 1) * (1 if spec.get("pin1_side", "left") == "left" else -1)
|
|
930
|
+
pads.append({"num": str(k + 1), "kind": "smd", "shape": "rect", "x": _r4(side * lc),
|
|
931
|
+
"y": _r4((r - (rows - 1) / 2) * e), "w": ll, "h": lw, "role": "pin", "paste": "full", "_src": f"pin {k + 1}"})
|
|
932
|
+
cx, cy = 0.0, 0.0
|
|
933
|
+
family = "pin_header_smd"
|
|
934
|
+
if spec.get("lead_span"):
|
|
935
|
+
ls = _dim(spec["lead_span"])[1] / 2
|
|
936
|
+
extra.append((-ls, -(rows - 1) * e / 2 - lw / 2, ls, (rows - 1) * e / 2 + lw / 2))
|
|
937
|
+
bl = _dim(spec["body_length"])[1] if spec.get("body_length") else cols * e if spec.get("mount", "tht") == "tht" else cols * e
|
|
938
|
+
bw = _dim(spec["body_width"])[1] if spec.get("body_width") else rows * e
|
|
939
|
+
body = (_r4(cx - bl / 2), _r4(cy - bw / 2), _r4(cx + bl / 2), _r4(cy + bw / 2))
|
|
940
|
+
rep = {"name": spec["name"], "type": "pin_header", "density": density, "ipc_family": family}
|
|
941
|
+
rep["descr"] = (f"{spec.get('description', spec['name'])} | {cols}x{rows} pins, pitch {e:g} mm"
|
|
942
|
+
+ (f", drill {pads[0]['drill'][0]:g} mm, pad {pads[0]['w']:g} mm" if pads[0].get("drill") else
|
|
943
|
+
f", pads {pads[0]['w']:g}x{pads[0]['h']:g} mm") + " | generated by ecadlib (prototype - review before use)")
|
|
944
|
+
txt = _emit(spec, pads, body, rep, spec.get("courtyard_excess", CONN_EXCESS[density]), extra_rects=extra)
|
|
945
|
+
return txt, rep, pads
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
def table_geometry(spec):
|
|
949
|
+
"""pad_table -> (pads, body, keepouts, info, problems, notes) in the footprint's frame (origin applied, polygon
|
|
950
|
+
anchors, paste windows and vias added). Raises ValueError when check_pad_table finds an error."""
|
|
951
|
+
pads, body, keepouts, info = table_layout(spec)
|
|
952
|
+
tht_pad_size(pads, spec.get("pitch"))
|
|
953
|
+
problems = check_pad_table(spec, pads)
|
|
954
|
+
errs = [p for p in problems if p["level"] == "error"]
|
|
955
|
+
if errs:
|
|
956
|
+
raise ValueError("the pad table doesn't add up: " + "; ".join(p["message"] for p in errs))
|
|
957
|
+
t = spec["pad_table"]
|
|
958
|
+
has_th = any(p["kind"] == "thru_hole" and p["role"] in PIN_ROLES for p in pads)
|
|
959
|
+
if t.get("footprint_origin", "pin1" if has_th else "center") == "pin1":
|
|
960
|
+
p1 = next((p for p in pads if p["num"] in ("1", "A1") and p["role"] in PIN_ROLES), None)
|
|
961
|
+
if p1 is None:
|
|
962
|
+
raise ValueError("footprint_origin pin1 but there is no pad 1")
|
|
963
|
+
ox, oy = p1["x"], p1["y"]
|
|
964
|
+
for p in pads:
|
|
965
|
+
p["x"], p["y"] = _r4(p["x"] - ox), _r4(p["y"] - oy)
|
|
966
|
+
if p.get("poly"):
|
|
967
|
+
p["poly"] = [(_r4(x - ox), _r4(y - oy)) for x, y in p["poly"]]
|
|
968
|
+
if body:
|
|
969
|
+
body = (_r4(body[0] - ox), _r4(body[1] - oy), _r4(body[2] - ox), _r4(body[3] - oy))
|
|
970
|
+
for k in keepouts:
|
|
971
|
+
k["pts"] = [(_r4(x - ox), _r4(y - oy)) for x, y in k["pts"]]
|
|
972
|
+
notes = []
|
|
973
|
+
pads = _finish_pads(pads, notes)
|
|
974
|
+
return pads, body, keepouts, info, problems, notes
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def _table(spec, density):
|
|
978
|
+
pads, body, keepouts, info, problems, notes = table_geometry(spec)
|
|
979
|
+
t_ = spec["package_type"]
|
|
980
|
+
exc = spec.get("courtyard_excess", (CONN_EXCESS if t_ == "connector" else CUSTOM_EXCESS)[density])
|
|
981
|
+
pins = len({p["num"] for p in pads if p["role"] in PIN_ROLES})
|
|
982
|
+
rep = {"name": spec["name"], "type": t_, "density": density, "ipc_family": t_, "land_source": "datasheet",
|
|
983
|
+
"table_checks": [p for p in problems if p["level"] != "error"],
|
|
984
|
+
"units": f"{info['unit']} (x{info['factor']:g} to mm)", "notes": notes}
|
|
985
|
+
rep["descr"] = (f"{spec.get('description', spec['name'])} | {pins} pins"
|
|
986
|
+
+ (f", {sum(1 for p in pads if p['role'] in ('mount', 'shield'))} mounting/shield pads" if any(p['role'] in ('mount', 'shield') for p in pads) else "")
|
|
987
|
+
+ (f" | src {spec['source']['datasheet']}" if spec.get("source", {}).get("datasheet") else "")
|
|
988
|
+
+ " | land pattern from the datasheet drawing | generated by ecadlib (prototype - review before use)")
|
|
989
|
+
txt = _emit(spec, pads, body, rep, exc, keepouts=keepouts)
|
|
990
|
+
return txt, rep, pads
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
BUILDERS = {"bga": _bga, "pin_header": _header, "connector": _table, "custom": _table}
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
def build(spec, density=None):
|
|
997
|
+
"""(kicad_mod text, report) for the advanced package types."""
|
|
998
|
+
density = density or spec.get("density", "B")
|
|
999
|
+
txt, rep, pads = BUILDERS[spec["package_type"]](spec, density)
|
|
1000
|
+
rep["pads"] = [{"num": p["num"], "x": p["x"], "y": p["y"], "w": p["w"], "h": p["h"], "kind": p["kind"], "shape": p["shape"],
|
|
1001
|
+
**({"drill": p["drill"]} if p.get("drill") else {}), "role": p["role"]}
|
|
1002
|
+
for p in pads if not p.get("via")]
|
|
1003
|
+
from . import footprint_check
|
|
1004
|
+
same = [list(map(str, g)) for g in spec.get("same_net") or []]
|
|
1005
|
+
rep["checks"] = [{"level": l, "rule": r_, "message": m} for l, r_, m in footprint_check.check(txt, same_net=same)]
|
|
1006
|
+
return txt, rep
|