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_gen.py
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
"""Parametric IPC-7351B-style footprint generator -> KiCad .kicad_mod (S-expression).
|
|
2
|
+
|
|
3
|
+
Supported package_type: chip (2-terminal R/C/L), molded, gullwing (dual-row SOIC/SOP/TSSOP/SOT), qfp, qfn (+EP);
|
|
4
|
+
and, in footprint_adv.py: bga (BGA/LGA/CSP), pin_header, connector and custom (explicit pad table from the
|
|
5
|
+
datasheet drawing: polygon pads, slots, mounting pads, keep-outs).
|
|
6
|
+
Input: JSON matching package_schema.json (the contract an LLM fills from a datasheet drawing).
|
|
7
|
+
|
|
8
|
+
IPC-7351B land-pattern equations (all mm; implemented in ipc_land()):
|
|
9
|
+
|
|
10
|
+
Z = Lmin + 2*Jt + sqrt(CL^2 + F^2 + P^2) outer pad-to-pad extent (toe)
|
|
11
|
+
G = Smax - 2*Jh - sqrt(CS^2 + F^2 + P^2) inner gap between opposing pads (heel)
|
|
12
|
+
X = Wmin + 2*Js + sqrt(CW^2 + F^2 + P^2) pad width (side)
|
|
13
|
+
|
|
14
|
+
L = terminal-to-terminal span (lead span E for gull-wing, body length for chip/QFN)
|
|
15
|
+
S = inner span between terminals: Smin = Lmin - 2*Tmax, Smax = Lmax - 2*Tmin
|
|
16
|
+
(IPC uses an RMS tolerance for S: CS = sqrt(CL^2 + 2*CT^2) and pulls Smin/Smax
|
|
17
|
+
inward by (Stol - CS)/2, so stacked worst cases do not over-shrink the heel)
|
|
18
|
+
W = terminal width; C* = max-min tolerance of each dimension
|
|
19
|
+
Jt/Jh/Js = toe/heel/side solder fillet goals for the density level (FILLETS table)
|
|
20
|
+
F = PCB fabrication tolerance, P = part placement tolerance
|
|
21
|
+
Pad length = (Z - G)/2, pad centre = (Z + G)/4 from origin.
|
|
22
|
+
Courtyard = bounding box of (pads U body) + courtyard excess (density dependent).
|
|
23
|
+
|
|
24
|
+
Output targets KiCad file format version 20221018 (KiCad 7) which KiCad 8/9 load
|
|
25
|
+
natively and upgrade on save. (KiCad's own library is now at 20260206 / KiCad 10.)
|
|
26
|
+
"""
|
|
27
|
+
import argparse, json, math, re, sys
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
# IPC-7351B fillet goals per density A/B/C (most/nominal/least):
|
|
31
|
+
# (Jz, Jg, Js, courtyard excess, round-off)
|
|
32
|
+
# Jz is the goal at the outer end of the land (sets Z), Jg at the inner end (sets G), Js at the side (sets X).
|
|
33
|
+
# For most families Jz is the toe and Jg the heel; for inward-L molded parts it is the other way round.
|
|
34
|
+
# Values are the ones KiCad builds its library with (kicad-footprint-generator src/kilibs/config/ipc_configs/
|
|
35
|
+
# ipc_7351b.yaml and ipc7351B_2terminal.yaml); docs/footprint-rules.md lists the sources per family.
|
|
36
|
+
FILLETS = {
|
|
37
|
+
# Table 3-5: rectangular end-cap chips >= 0603 (1608)
|
|
38
|
+
"chip": {"A": (0.55, 0.00, 0.05, 0.50, 0.05), "B": (0.35, 0.00, 0.00, 0.25, 0.05), "C": (0.15, 0.00, -0.05, 0.10, 0.05)},
|
|
39
|
+
# Table 3-6: chips < 0603 (0402, 0201) - smaller toe, 0.15 courtyard, 0.02 round-off
|
|
40
|
+
"chip_small": {"A": (0.30, 0.00, 0.05, 0.20, 0.02), "B": (0.20, 0.00, 0.00, 0.15, 0.02), "C": (0.10, 0.00, -0.05, 0.10, 0.02)},
|
|
41
|
+
# Table 3-2: gull-wing, pitch >= 0.625 mm (SOIC, SOT-23, SOT-223, TSSOP-0.65)
|
|
42
|
+
"gullwing": {"A": (0.55, 0.45, 0.05, 0.50, 0.05), "B": (0.35, 0.35, 0.03, 0.25, 0.05), "C": (0.15, 0.25, 0.01, 0.10, 0.05)},
|
|
43
|
+
# Table 3-3: gull-wing, pitch < 0.625 mm (side fillet goes negative)
|
|
44
|
+
"gullwing_fine": {"A": (0.55, 0.45, 0.01, 0.50, 0.05), "B": (0.35, 0.35, -0.02, 0.25, 0.05), "C": (0.15, 0.25, -0.04, 0.10, 0.05)},
|
|
45
|
+
# Table 3-15: QFN / DFN / SON with a toe fillet (no heel, negative side)
|
|
46
|
+
"qfn": {"A": (0.40, 0.00, -0.04, 0.50, 0.05), "B": (0.30, 0.00, -0.04, 0.25, 0.05), "C": (0.20, 0.00, -0.04, 0.10, 0.05)},
|
|
47
|
+
# Table 3-13: inward-L / molded body (tantalum, SMA/SMB/SMC, SOD-123F): big fillet at the outer
|
|
48
|
+
# (heel) end, small at the inner (toe) end under the body
|
|
49
|
+
"molded": {"A": (0.80, 0.25, 0.01, 0.50, 0.05), "B": (0.50, 0.15, -0.05, 0.25, 0.05), "C": (0.20, 0.07, -0.10, 0.10, 0.05)},
|
|
50
|
+
# IPC-7351 (2005) Table 3-7: MELF / MiniMELF cylindrical end caps (no 7351B table in KiCad)
|
|
51
|
+
"melf": {"A": (0.60, 0.20, 0.10, 0.50, 0.05), "B": (0.40, 0.10, 0.05, 0.25, 0.05), "C": (0.20, 0.02, 0.01, 0.10, 0.05)},
|
|
52
|
+
# IPC-7352 flat lead (outward flat ribbon leads)
|
|
53
|
+
"flat": {"A": (0.30, 0.00, 0.05, 0.20, 0.05), "B": (0.20, 0.00, 0.00, 0.15, 0.05), "C": (0.10, 0.00, 0.00, 0.10, 0.05)},
|
|
54
|
+
}
|
|
55
|
+
FAB_TOL, PLACE_TOL = 0.10, 0.05 # F and P as KiCad's generator uses them
|
|
56
|
+
MIN_PAD_GAP = 0.2 # minimum copper gap between adjacent pads (solder-mask web)
|
|
57
|
+
MIN_EP_CLEAR = 0.2 # exposed pad to signal pad (KLC F6.3); also KiCad's minimum heel gap / 2
|
|
58
|
+
SILK_W, FAB_W, CRT_W, SILK_PAD_CLEAR = 0.12, 0.10, 0.05, 0.2
|
|
59
|
+
SILK_CLEAR_SMALL, SILK_MIN_LEN = 0.1, 0.2 # KiCad falls back to 0.1 clearance when 0.2 leaves no line >= 0.2 long
|
|
60
|
+
SILK_FAB_OFFSET = 0.11 # silk outline sits this far outside the fab body (KiCad generator default)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------- dimensions & IPC math
|
|
64
|
+
def dim(d):
|
|
65
|
+
"""Normalise a schema dimension to (min, nom, max)."""
|
|
66
|
+
if d is None:
|
|
67
|
+
return None
|
|
68
|
+
if isinstance(d, (int, float)):
|
|
69
|
+
return (d, d, d)
|
|
70
|
+
if "min" in d and "max" in d:
|
|
71
|
+
return (d["min"], d.get("nom", (d["min"] + d["max"]) / 2), d["max"])
|
|
72
|
+
t = d.get("tol", 0.0) # a one-sided min or max still wins over nom +- tol
|
|
73
|
+
return (d.get("min", d["nom"] - t), d["nom"], d.get("max", d["nom"] + t))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def rup(x, g=0.01):
|
|
77
|
+
return math.ceil(round(x / g, 6)) * g
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def rdn(x, g=0.01):
|
|
81
|
+
return math.floor(round(x / g, 6)) * g
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def rnd(x, g):
|
|
85
|
+
"""Round to the nearest multiple of g, the way KiCad's generator does (Python round)."""
|
|
86
|
+
return round(round(x / g) * g, 4)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def ipc_land(L, T, W, fillet, F=FAB_TOL, P=PLACE_TOL, S=None, min_gap=None):
|
|
90
|
+
"""L,T,W(,S) = (min,nom,max) tuples. Returns dict(Z,G,X,pad_len,pad_ctr) - see module docstring.
|
|
91
|
+
S, when the drawing gives the inner span directly, is used as is (no RMS adjustment).
|
|
92
|
+
min_gap: the heel gap G is never allowed below this (EP clearance; KiCad's gull-wing 0.4 mm floor)."""
|
|
93
|
+
jz, jg, js, _, base = fillet
|
|
94
|
+
CL, CW = L[2] - L[0], W[2] - W[0]
|
|
95
|
+
if S is not None:
|
|
96
|
+
smin, smax = S[0], S[2]
|
|
97
|
+
cs = smax - smin
|
|
98
|
+
else:
|
|
99
|
+
CT = T[2] - T[0]
|
|
100
|
+
smin, smax = L[0] - 2 * T[2], L[2] - 2 * T[0]
|
|
101
|
+
cs = math.sqrt(CL ** 2 + 2 * CT ** 2) # RMS tolerance of S
|
|
102
|
+
adj = ((smax - smin) - cs) / 2
|
|
103
|
+
smin, smax = smin + adj, smax - adj
|
|
104
|
+
Z = rnd(L[0] + 2 * jz + math.sqrt(CL ** 2 + F ** 2 + P ** 2), base)
|
|
105
|
+
G = rnd(smax - 2 * jg - math.sqrt(cs ** 2 + F ** 2 + P ** 2), base)
|
|
106
|
+
X = rnd(W[0] + 2 * js + math.sqrt(CW ** 2 + F ** 2 + P ** 2), base)
|
|
107
|
+
G = max(G, 0.0, min_gap or 0.0)
|
|
108
|
+
return {"Z": Z, "G": G, "X": X, "pad_len": round((Z - G) / 2, 4), "pad_ctr": round((Z + G) / 4, 4),
|
|
109
|
+
"Smin": smin, "Smax": smax}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------- S-expression writer
|
|
113
|
+
def n(x):
|
|
114
|
+
s = f"{x:.4f}".rstrip("0").rstrip(".")
|
|
115
|
+
return "0" if s in ("-0", "") else s
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def q(s):
|
|
119
|
+
s = re.sub(r"\s+", " ", str(s)).strip() # one line: KiCad's lexer doesn't take raw newlines in strings
|
|
120
|
+
return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def line(x1, y1, x2, y2, layer, w):
|
|
124
|
+
return f' (fp_line (start {n(x1)} {n(y1)}) (end {n(x2)} {n(y2)}) (stroke (width {n(w)}) (type solid)) (layer {q(layer)}))'
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def rect(x1, y1, x2, y2, layer, w):
|
|
128
|
+
return f' (fp_rect (start {n(x1)} {n(y1)}) (end {n(x2)} {n(y2)}) (stroke (width {n(w)}) (type solid)) (fill none) (layer {q(layer)}))'
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def poly(pts, layer, w, fill="none"):
|
|
132
|
+
xy = " ".join(f"(xy {n(x)} {n(y)})" for x, y in pts)
|
|
133
|
+
return f' (fp_poly (pts {xy}) (stroke (width {n(w)}) (type solid)) (fill {fill}) (layer {q(layer)}))'
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def text(kind, val, x, y, layer, size=1.0, hide=False):
|
|
137
|
+
th = round(size * 0.15, 3)
|
|
138
|
+
return (f' (fp_text {kind} {q(val)} (at {n(x)} {n(y)}) (layer {q(layer)}){" hide" if hide else ""}\n'
|
|
139
|
+
f' (effects (font (size {n(size)} {n(size)}) (thickness {n(th)}))))')
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def pad(num, x, y, w, h, shape="roundrect", layers=("F.Cu", "F.Paste", "F.Mask"), extra=""):
|
|
143
|
+
lay = " ".join(q(l) for l in layers)
|
|
144
|
+
rr = f" (roundrect_rratio {n(min(0.25, 0.25 / min(w, h)))})" if shape == "roundrect" else ""
|
|
145
|
+
return f' (pad {q(num)} smd {shape} (at {n(x)} {n(y)}) (size {n(w)} {n(h)}) (layers {lay}){rr}{extra})'
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ---------------------------------------------------------------- package builders
|
|
149
|
+
def family(spec):
|
|
150
|
+
"""IPC-7351B lead family (FILLETS key) for a spec: `lead_type` overrides the default per package_type."""
|
|
151
|
+
t = spec["package_type"]
|
|
152
|
+
if spec.get("ipc_family") in FILLETS: # forced (ecadlib.stdparts: KiCad's force_small_pitch_ipc_definition)
|
|
153
|
+
return spec["ipc_family"]
|
|
154
|
+
lt = spec.get("lead_type")
|
|
155
|
+
if t == "chip":
|
|
156
|
+
if lt in ("molded", "melf", "flat"):
|
|
157
|
+
return lt
|
|
158
|
+
return "chip_small" if dim(spec["body_length"])[1] < 1.55 else "chip"
|
|
159
|
+
if t == "molded":
|
|
160
|
+
return lt if lt in ("melf", "flat") else "molded"
|
|
161
|
+
if lt and (t in ("qfn", "qfp") or (t == "gullwing" and lt != "flat")):
|
|
162
|
+
raise ValueError(f"lead_type {lt!r} doesn't apply to a {t} package"
|
|
163
|
+
+ (" (only \"flat\" does)" if t == "gullwing" else ""))
|
|
164
|
+
if t == "gullwing":
|
|
165
|
+
if lt == "flat":
|
|
166
|
+
return "flat"
|
|
167
|
+
pitch = spec.get("pitch") or _min_pitch(spec)
|
|
168
|
+
return "gullwing" if pitch is None or pitch >= 0.625 else "gullwing_fine"
|
|
169
|
+
if t == "qfn":
|
|
170
|
+
return "qfn"
|
|
171
|
+
if t == "qfp":
|
|
172
|
+
return "gullwing" if spec["pitch"] >= 0.625 else "gullwing_fine"
|
|
173
|
+
raise ValueError(f"unsupported package_type {t}")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _min_pitch(spec):
|
|
177
|
+
pos = spec.get("pin_positions") or []
|
|
178
|
+
best = None
|
|
179
|
+
for side in ("L", "R"):
|
|
180
|
+
ys = sorted(p["offset"] for p in pos if p["side"] == side)
|
|
181
|
+
for a, b in zip(ys, ys[1:]):
|
|
182
|
+
best = b - a if best is None else min(best, b - a)
|
|
183
|
+
return best
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _two_terminal(spec, fil):
|
|
187
|
+
L = dim(spec["body_length"])
|
|
188
|
+
W = dim(spec.get("terminal_width") or spec["body_width"])
|
|
189
|
+
r = ipc_land(L, dim(spec.get("terminal_length")), W, fil, S=dim(spec.get("inner_span")))
|
|
190
|
+
pads = [("1", -r["pad_ctr"], 0, r["pad_len"], r["X"]), ("2", r["pad_ctr"], 0, r["pad_len"], r["X"])]
|
|
191
|
+
return pads, (L[1] / 2, dim(spec["body_width"])[1] / 2), {k: r[k] for k in ("Z", "G", "X")}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _gullwing(spec, fil):
|
|
195
|
+
T, bw, span = dim(spec["terminal_length"]), dim(spec["terminal_width"]), dim(spec["lead_span"])
|
|
196
|
+
ep = spec.get("exposed_pad")
|
|
197
|
+
min_gap = (dim(ep["length"])[1] if ep else 0) + 2 * MIN_EP_CLEAR # KiCad: G >= EP + 0.4, so >= 0.4 without EP
|
|
198
|
+
r = ipc_land(span, T, bw, fil, min_gap=min_gap)
|
|
199
|
+
pos = spec.get("pin_positions")
|
|
200
|
+
if not pos: # standard dual row: 1..n/2 down the left, then up the right
|
|
201
|
+
e, per = spec["pitch"], spec["pin_count"] // 2
|
|
202
|
+
pos = [{"number": str(i + 1), "side": "L", "offset": (i - (per - 1) / 2) * e} for i in range(per)]
|
|
203
|
+
pos += [{"number": str(per + i + 1), "side": "R", "offset": ((per - 1) / 2 - i) * e} for i in range(per)]
|
|
204
|
+
pitch = _min_pitch({"pin_positions": pos})
|
|
205
|
+
X = min(r["X"], rdn(pitch - MIN_PAD_GAP)) if pitch else r["X"]
|
|
206
|
+
pads = [(str(p["number"]), (-1 if p["side"] == "L" else 1) * r["pad_ctr"], p["offset"], r["pad_len"], X) for p in pos]
|
|
207
|
+
rep = {"Z": r["Z"], "G": r["G"], "X": X, "X_unclamped": r["X"]}
|
|
208
|
+
tab = spec.get("tab")
|
|
209
|
+
if tab: # SOT-223 / DPAK style wide tab on one side
|
|
210
|
+
tr = ipc_land(span, dim(tab.get("length") or spec["terminal_length"]), dim(tab["width"]), fil, min_gap=min_gap)
|
|
211
|
+
sgn = -1 if tab.get("side", "R") == "L" else 1
|
|
212
|
+
pads.append((str(tab["number"]), sgn * tr["pad_ctr"], tab.get("offset", 0.0), tr["pad_len"], tr["X"]))
|
|
213
|
+
rep["tab"] = {"Z": tr["Z"], "G": tr["G"], "X": tr["X"]}
|
|
214
|
+
body_x, body_y = dim(spec["body_width"])[1] / 2, dim(spec["body_length"])[1] / 2 # E1 across, D along the rows
|
|
215
|
+
return pads, (body_x, body_y), rep
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _qfn(spec, fil):
|
|
219
|
+
"""Quad (or dual) rows: QFN/DFN toes sit at the body edge; QFP toes at the lead span."""
|
|
220
|
+
npins, e = spec["pin_count"], spec["pitch"]
|
|
221
|
+
body_x, body_y, T, bw = dim(spec["body_length"]), dim(spec["body_width"]), dim(spec["terminal_length"]), dim(spec["terminal_width"])
|
|
222
|
+
nlr = spec.get("pins_left_right", npins // 4)
|
|
223
|
+
ntb = spec.get("pins_top_bottom", (npins - 2 * nlr) // 2)
|
|
224
|
+
span_x = dim(spec["lead_span"]) if spec.get("lead_span") else body_x
|
|
225
|
+
span_y = dim(spec.get("lead_span_y") or spec["lead_span"]) if spec.get("lead_span") else body_y
|
|
226
|
+
ep = spec.get("exposed_pad") # heels pulled back to keep MIN_EP_CLEAR to the EP (KLC F6.3)
|
|
227
|
+
gx = dim(ep["length"])[1] + 2 * MIN_EP_CLEAR if ep else None
|
|
228
|
+
gy = dim(ep["width"])[1] + 2 * MIN_EP_CLEAR if ep else None
|
|
229
|
+
rx = ipc_land(span_x, T, bw, fil, min_gap=gx) # left/right pads
|
|
230
|
+
ry = ipc_land(span_y, T, bw, fil, min_gap=gy) # top/bottom pads
|
|
231
|
+
X = min(rx["X"], rdn(e - MIN_PAD_GAP))
|
|
232
|
+
pads, k = [], 1
|
|
233
|
+
for i in range(nlr): # left, top->bottom
|
|
234
|
+
pads.append((str(k), -rx["pad_ctr"], (i - (nlr - 1) / 2) * e, rx["pad_len"], X)); k += 1
|
|
235
|
+
for i in range(ntb): # bottom, left->right
|
|
236
|
+
pads.append((str(k), (i - (ntb - 1) / 2) * e, ry["pad_ctr"], X, ry["pad_len"])); k += 1
|
|
237
|
+
for i in range(nlr): # right, bottom->top
|
|
238
|
+
pads.append((str(k), rx["pad_ctr"], ((nlr - 1) / 2 - i) * e, rx["pad_len"], X)); k += 1
|
|
239
|
+
for i in range(ntb): # top, right->left
|
|
240
|
+
pads.append((str(k), ((ntb - 1) / 2 - i) * e, -ry["pad_ctr"], X, ry["pad_len"])); k += 1
|
|
241
|
+
return pads, (body_x[1] / 2, body_y[1] / 2), {"Z": rx["Z"], "G": rx["G"], "X": X, "Zy": ry["Z"], "Gy": ry["G"]}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def corner_r(num, w, h):
|
|
245
|
+
"""Corner radius of a pad as pad() draws it: roundrect 25 % of the short side, capped at 0.25; EP is a plain rect."""
|
|
246
|
+
return 0.0 if num is None else min(0.25 * min(w, h), 0.25)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _clip(seg, pads, c, min_len=SILK_MIN_LEN):
|
|
250
|
+
"""Cut an axis-aligned silk segment wherever its centre line comes within c of a pad's real (rounded)
|
|
251
|
+
outline, as KiCad measures it. pads as (num, x, y, w, h). Pieces shorter than min_len are dropped."""
|
|
252
|
+
(x1, y1), (x2, y2) = seg
|
|
253
|
+
horiz = abs(y1 - y2) < 1e-9
|
|
254
|
+
a, b = (min(x1, x2), max(x1, x2)) if horiz else (min(y1, y2), max(y1, y2))
|
|
255
|
+
keep = [(a, b)]
|
|
256
|
+
for num, px, py, w, h in pads:
|
|
257
|
+
r = corner_r(num, w, h)
|
|
258
|
+
along, across = (px, py) if horiz else (py, px)
|
|
259
|
+
half_along, half_across = (w / 2, h / 2) if horiz else (h / 2, w / 2)
|
|
260
|
+
e = max(abs((y1 if horiz else x1) - across) - (half_across - r), 0) # distance past the straight edge
|
|
261
|
+
if e >= r + c:
|
|
262
|
+
continue
|
|
263
|
+
half = half_along - r + math.sqrt((r + c) ** 2 - e ** 2)
|
|
264
|
+
lo, hi = along - half, along + half
|
|
265
|
+
keep = [part for s, e_ in keep for part in ((s, min(e_, lo)), (max(s, hi), e_)) if part[1] - part[0] > 1e-6]
|
|
266
|
+
keep = [(s, e) for s, e in keep if e - s >= min_len - 1e-6]
|
|
267
|
+
return [((round(s, 4), y1), (round(e, 4), y1)) if horiz else ((x1, round(s, 4)), (x1, round(e, 4))) for s, e in keep]
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _apply_land(spec, pads):
|
|
271
|
+
"""Replace the IPC-computed signal pads with the datasheet's recommended land (the tab keeps its IPC size)."""
|
|
272
|
+
land = spec["land"]
|
|
273
|
+
ll, lw, lc = land["pad_length"], land["pad_width"], land["pad_center"]
|
|
274
|
+
lcy = land.get("pad_center_y", lc)
|
|
275
|
+
n_sig = len(pads) - (1 if spec.get("tab") else 0)
|
|
276
|
+
out = []
|
|
277
|
+
for i, (num, x, y, w, h) in enumerate(pads):
|
|
278
|
+
if i >= n_sig:
|
|
279
|
+
out.append((num, x, y, w, h))
|
|
280
|
+
elif abs(x) >= abs(y) or w >= h: # left/right row: length runs along X
|
|
281
|
+
out.append((num, math.copysign(lc, x), y, ll, lw))
|
|
282
|
+
else: # top/bottom row (QFN/QFP)
|
|
283
|
+
out.append((num, x, math.copysign(lcy, y), lw, ll))
|
|
284
|
+
return out
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def sanity(spec):
|
|
288
|
+
"""Catch the usual datasheet-reading mistakes before they turn into a plausible-looking but wrong footprint.
|
|
289
|
+
Returns a list of plain-language problems (empty when the numbers hang together)."""
|
|
290
|
+
errs, t = [], spec["package_type"]
|
|
291
|
+
nom = lambda k: dim(spec[k])[1] if spec.get(k) is not None else None
|
|
292
|
+
for k in ("body_length", "body_width", "lead_span", "terminal_length", "terminal_width", "inner_span", "pitch"):
|
|
293
|
+
v = spec.get(k)
|
|
294
|
+
lo, _, hi = dim(v) if v is not None and k != "pitch" else (v, v, v)
|
|
295
|
+
if v is None:
|
|
296
|
+
continue
|
|
297
|
+
if lo > hi:
|
|
298
|
+
errs.append(f"{k}: min {lo} is bigger than max {hi}")
|
|
299
|
+
if hi > 60:
|
|
300
|
+
errs.append(f"{k} = {hi}: that looks like mils or 0.01 mm units; the spec is in millimetres")
|
|
301
|
+
if errs:
|
|
302
|
+
return errs
|
|
303
|
+
L, W, T = nom("body_length"), nom("body_width"), nom("terminal_length")
|
|
304
|
+
if t in ("chip", "molded") and T and not spec.get("inner_span") and 2 * dim(spec["terminal_length"])[0] >= dim(spec["body_length"])[2]:
|
|
305
|
+
errs.append(f"terminal_length {T} is half the body length or more: it should be one end-cap/foot, not the whole part")
|
|
306
|
+
if t in ("gullwing", "qfp"):
|
|
307
|
+
span = nom("lead_span")
|
|
308
|
+
if span is not None and span < W + 0.1: # leads must stick out past the plastic
|
|
309
|
+
errs.append(f"lead_span {span} is not bigger than body_width {round(W, 3)}: lead_span is the tip-to-tip size "
|
|
310
|
+
"across the leads (E), body_width the plastic (E1); are they swapped?")
|
|
311
|
+
if span is not None and T and T >= span / 2:
|
|
312
|
+
errs.append(f"terminal_length {T} is half the lead span or more")
|
|
313
|
+
n = spec.get("pin_count")
|
|
314
|
+
if t == "gullwing" and n and not spec.get("pin_positions") and n % 2:
|
|
315
|
+
errs.append(f"pin_count {n} is odd: a dual-row part needs an even count, or list the pads in pin_positions "
|
|
316
|
+
"(as for SOT-23)")
|
|
317
|
+
if t in ("qfn", "qfp") and n:
|
|
318
|
+
nlr = spec.get("pins_left_right", n // 4)
|
|
319
|
+
ntb = spec.get("pins_top_bottom", (n - 2 * nlr) // 2)
|
|
320
|
+
if 2 * nlr + 2 * ntb != n:
|
|
321
|
+
errs.append(f"pin_count {n} doesn't equal 2 x {nlr} (left/right) + 2 x {ntb} (top/bottom); "
|
|
322
|
+
"set pins_left_right / pins_top_bottom")
|
|
323
|
+
if t in ("gullwing", "qfp", "qfn") and spec.get("pitch") and spec.get("pin_count") and not spec.get("pin_positions"):
|
|
324
|
+
n = spec["pin_count"]
|
|
325
|
+
per = n // 2 if t == "gullwing" else spec.get("pins_left_right", n // 4)
|
|
326
|
+
row = (per - 1) * spec["pitch"]
|
|
327
|
+
if per > 1 and row >= (L if t == "gullwing" else max(L, W)):
|
|
328
|
+
errs.append(f"{per} pins at {spec['pitch']} mm pitch need {row:.2f} mm, more than the {L} mm body: check pitch and pin_count")
|
|
329
|
+
tw = spec.get("terminal_width")
|
|
330
|
+
if tw and dim(tw)[0] >= spec["pitch"]:
|
|
331
|
+
errs.append(f"terminal_width {dim(tw)[0]} is not smaller than the {spec['pitch']} mm pitch")
|
|
332
|
+
if t == "qfn" and T and T >= min(L, W) / 2:
|
|
333
|
+
errs.append(f"terminal_length {T} reaches the middle of the {L} x {W} body")
|
|
334
|
+
ep = spec.get("exposed_pad")
|
|
335
|
+
bx, by = (W, L) if t == "gullwing" else (L, W) # EP length is along X: gullwing X is body_width (E1)
|
|
336
|
+
if ep and (dim(ep["length"])[1] >= bx or dim(ep["width"])[1] >= by):
|
|
337
|
+
errs.append("the exposed pad is as big as the body: check its length/width")
|
|
338
|
+
elif ep and t == "qfn" and T:
|
|
339
|
+
tmin = dim(spec["terminal_length"])[0]
|
|
340
|
+
tb = spec.get("pins_top_bottom", 1)
|
|
341
|
+
if dim(ep["length"])[1] + 2 * tmin > dim(spec["body_length"])[2] or (
|
|
342
|
+
tb and dim(ep["width"])[1] + 2 * tmin > dim(spec["body_width"])[2]):
|
|
343
|
+
errs.append("the exposed pad would overlap the terminals: check its size against the body and terminal_length")
|
|
344
|
+
return errs
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
ADVANCED = ("bga", "pin_header", "connector", "custom")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def build(spec, density=None):
|
|
351
|
+
"""Return (kicad_mod_text, report_dict)."""
|
|
352
|
+
if spec["package_type"] in ADVANCED:
|
|
353
|
+
from . import footprint_adv
|
|
354
|
+
return footprint_adv.build(spec, density)
|
|
355
|
+
problems = sanity(spec)
|
|
356
|
+
if problems:
|
|
357
|
+
raise ValueError("the package numbers don't add up: " + "; ".join(problems))
|
|
358
|
+
density = density or spec.get("density", "B")
|
|
359
|
+
t = spec["package_type"]
|
|
360
|
+
fam = family(spec)
|
|
361
|
+
fil = FILLETS[fam][density]
|
|
362
|
+
rep = {"name": spec["name"], "type": t, "density": density, "ipc_family": fam}
|
|
363
|
+
if t in ("chip", "molded"):
|
|
364
|
+
pads, (bx, by), r = _two_terminal(spec, fil)
|
|
365
|
+
elif t == "gullwing":
|
|
366
|
+
pads, (bx, by), r = _gullwing(spec, fil)
|
|
367
|
+
else: # qfn, qfp
|
|
368
|
+
pads, (bx, by), r = _qfn(spec, fil)
|
|
369
|
+
rep.update(r)
|
|
370
|
+
want = spec.get("pin_count")
|
|
371
|
+
if want and len(pads) != want:
|
|
372
|
+
raise ValueError(f"pin_count is {want} but the layout gives {len(pads)} pads "
|
|
373
|
+
"(count every pad except an exposed pad; a tab counts)")
|
|
374
|
+
short = [p for p in pads if min(p[3], p[4]) < 0.1]
|
|
375
|
+
if short:
|
|
376
|
+
why = "the exposed pad leaves no room for the pads" if spec.get("exposed_pad") else "the lead dimensions leave no room"
|
|
377
|
+
raise ValueError(f"{why}: pad {short[0][0]} would be {min(short[0][3], short[0][4]):.2f} mm (check the drawing)")
|
|
378
|
+
if spec.get("land"): # the datasheet's recommended land pattern wins over IPC
|
|
379
|
+
pads = _apply_land(spec, pads)
|
|
380
|
+
lw, ll, lc = spec["land"]["pad_width"], spec["land"]["pad_length"], spec["land"]["pad_center"]
|
|
381
|
+
rep.update({"Z": round(2 * lc + ll, 4), "G": round(2 * lc - ll, 4), "X": lw, "land_source": "datasheet"})
|
|
382
|
+
polarized = bool(spec.get("polarized"))
|
|
383
|
+
two_term = t in ("chip", "molded") or (t == "gullwing" and len(pads) == 2)
|
|
384
|
+
|
|
385
|
+
ep = spec.get("exposed_pad")
|
|
386
|
+
ep_lines = []
|
|
387
|
+
if ep:
|
|
388
|
+
ew, eh = dim(ep["length"])[1], dim(ep["width"])[1]
|
|
389
|
+
epnum = str(ep.get("number") or (spec.get("pin_count") or len(pads)) + 1) # pin_positions specs may omit pin_count
|
|
390
|
+
lr = (lambda p: abs(p[1]) > abs(p[2])) if t in ("qfn", "qfp") else (lambda p: True) # gullwing: all pads are L/R
|
|
391
|
+
gaps = [abs(p[1]) - p[3] / 2 - ew / 2 for p in pads if lr(p)] # left/right rows vs EP
|
|
392
|
+
gaps += [abs(p[2]) - p[4] / 2 - eh / 2 for p in pads if not lr(p)] # qfn top/bottom rows vs EP
|
|
393
|
+
if gaps and min(gaps) < MIN_PAD_GAP - 1e-6:
|
|
394
|
+
rep["warning"] = f"EP to pad gap {min(gaps):.3f} < {MIN_PAD_GAP}"
|
|
395
|
+
ep_lines.append(pad(epnum, 0, 0, ew, eh, shape="rect", layers=("F.Cu", "F.Mask")))
|
|
396
|
+
cov = ep.get("paste_coverage", 0.65) # KLC F6.3: 50-80 %, 65 % recommended
|
|
397
|
+
grid = ep.get("paste_grid") # split into windows of about 1.2 mm or less
|
|
398
|
+
gx = grid or max(1, min(5, math.ceil(ew / 1.2 - 1e-9)))
|
|
399
|
+
gy = grid or max(1, min(5, math.ceil(eh / 1.2 - 1e-9)))
|
|
400
|
+
sw, sh = ew * math.sqrt(cov) / gx, eh * math.sqrt(cov) / gy
|
|
401
|
+
for i in range(gx):
|
|
402
|
+
for j in range(gy):
|
|
403
|
+
cx, cy = (i - (gx - 1) / 2) * ew / gx, (j - (gy - 1) / 2) * eh / gy
|
|
404
|
+
ep_lines.append(pad("", round(cx, 4), round(cy, 4), round(sw, 3), round(sh, 3), layers=("F.Paste",)))
|
|
405
|
+
rep["EP"] = (ew, eh, f"paste {gx}x{gy} @ {cov:.0%}")
|
|
406
|
+
copper = pads + ([(None, 0, 0, dim(ep["length"])[1], dim(ep["width"])[1])] if ep else [])
|
|
407
|
+
|
|
408
|
+
# ---- courtyard: union(pads, body) + excess, rounded up to the 0.01 mm grid (KLC F5.3)
|
|
409
|
+
exc = fil[3]
|
|
410
|
+
px = max([abs(p[1]) + p[3] / 2 for p in pads] + [bx])
|
|
411
|
+
py = max([abs(p[2]) + p[4] / 2 for p in pads] + [by])
|
|
412
|
+
cx, cy = rup(px + exc), rup(py + exc)
|
|
413
|
+
rep["courtyard"] = (round(2 * cx, 2), round(2 * cy, 2))
|
|
414
|
+
|
|
415
|
+
out = [f'(footprint {q(spec["name"])} (version 20221018) (generator "ecadlib_footprint_gen")',
|
|
416
|
+
' (layer "F.Cu")',
|
|
417
|
+
f' (descr {q(descr(spec, rep))})',
|
|
418
|
+
f' (tags {q(" ".join(filter(None, [t, fam, "polarized" if polarized else "", "IPC-7351B density-" + density])))})',
|
|
419
|
+
' (attr smd)',
|
|
420
|
+
text("reference", "REF**", 0, -cy - 0.7, "F.SilkS"),
|
|
421
|
+
text("value", spec["name"], 0, cy + 0.7, "F.Fab"),
|
|
422
|
+
text("user", "${REFERENCE}", 0, 0, "F.Fab", size=math.floor(100 * min(1.0, max(0.25, 0.5 * max(bx, by)))) / 100)]
|
|
423
|
+
|
|
424
|
+
# ---- fab: body outline; pin-1 / polarity chamfer on everything except plain 2-terminal parts (KLC F5.2)
|
|
425
|
+
if two_term and not polarized:
|
|
426
|
+
out.append(rect(-bx, -by, bx, by, "F.Fab", FAB_W))
|
|
427
|
+
else:
|
|
428
|
+
ch = min(1.0, 0.25 * min(2 * bx, 2 * by))
|
|
429
|
+
if two_term: # KiCad chip LED/diode marker: 0.3 .. 1.0
|
|
430
|
+
ch = max(ch, min(0.3, bx, by))
|
|
431
|
+
out.append(poly([(-bx + ch, -by), (bx, -by), (bx, by), (-bx, by), (-bx, -by + ch)], "F.Fab", FAB_W))
|
|
432
|
+
|
|
433
|
+
# ---- silk: body outline pushed out by SILK_FAB_OFFSET, cut back to SILK_PAD_CLEAR from every pad (KLC F5.1)
|
|
434
|
+
c = SILK_PAD_CLEAR + SILK_W / 2
|
|
435
|
+
sx, sy = bx + SILK_FAB_OFFSET, by + SILK_FAB_OFFSET
|
|
436
|
+
p1 = next((p for p in pads if p[0] == "1"), None)
|
|
437
|
+
if p1 is None: # e.g. A/K numbering: mark the first pad,
|
|
438
|
+
p1 = pads[0] # the checker reports the missing "1"
|
|
439
|
+
rep["warning"] = (rep.get("warning", "") + "; " if rep.get("warning") else "") + \
|
|
440
|
+
f"no pad numbered 1; KiCad numbers pins 1, 2, ... (diodes: 1 = cathode), marked pad {p1[0]} as pin 1"
|
|
441
|
+
if two_term and polarized:
|
|
442
|
+
# KiCad LED/diode style: a bracket that closes on the pin-1 (cathode / +) side
|
|
443
|
+
yl = round(max(sy, max(abs(p[2]) + p[4] / 2 for p in pads) + c), 4)
|
|
444
|
+
xl = -round(max(sx, -(p1[1] - p1[3] / 2) + c), 4)
|
|
445
|
+
xe = max(bx, max(p[1] for p in pads))
|
|
446
|
+
out += [line(xl, -yl, xl, yl, "F.SilkS", SILK_W), line(xl, -yl, xe, -yl, "F.SilkS", SILK_W),
|
|
447
|
+
line(xl, yl, xe, yl, "F.SilkS", SILK_W)]
|
|
448
|
+
silk_y = yl
|
|
449
|
+
else:
|
|
450
|
+
box = [((-sx, -sy), (sx, -sy)), ((sx, -sy), (sx, sy)), ((sx, sy), (-sx, sy)), ((-sx, sy), (-sx, -sy))]
|
|
451
|
+
segs = [s for b in box for s in _clip(b, copper, c)]
|
|
452
|
+
if not segs: # tiny part (0402): KiCad's small-part clearance
|
|
453
|
+
c = SILK_CLEAR_SMALL + SILK_W / 2
|
|
454
|
+
segs = [s for b in box for s in _clip(b, copper, c)]
|
|
455
|
+
out += [line(a[0], a[1], b[0], b[1], "F.SilkS", SILK_W) for a, b in segs]
|
|
456
|
+
silk_y = max((abs(v[1]) for sg in segs for v in sg), default=0.0)
|
|
457
|
+
if not two_term: # pin-1 triangle just above pad 1
|
|
458
|
+
if t == "qfn" and spec.get("pins_top_bottom", 1) != 0:
|
|
459
|
+
ax, ay = -sx, -sy
|
|
460
|
+
else:
|
|
461
|
+
ax, ay = p1[1], p1[2] - p1[4] / 2 - c
|
|
462
|
+
out.append(pin1_triangle(ax, ay))
|
|
463
|
+
|
|
464
|
+
if silk_y + SILK_W / 2 > cy: # silk outline past the courtyard (density C):
|
|
465
|
+
out[5] = text("reference", "REF**", 0, -round(silk_y + SILK_W / 2 + 0.7, 4), "F.SilkS") # keep REF** off it
|
|
466
|
+
out.append(rect(-cx, -cy, cx, cy, "F.CrtYd", CRT_W))
|
|
467
|
+
out += [pad(*p) for p in pads] + ep_lines
|
|
468
|
+
if spec.get("model_3d"):
|
|
469
|
+
out.append(f' (model {q(spec["model_3d"])} (offset (xyz 0 0 0)) (scale (xyz 1 1 1)) (rotate (xyz 0 0 0)))')
|
|
470
|
+
out.append(")")
|
|
471
|
+
text_out = "\n".join(out) + "\n"
|
|
472
|
+
rep["pads"] = [{"num": p[0], "x": p[1], "y": p[2], "w": p[3], "h": p[4]} for p in pads]
|
|
473
|
+
from . import footprint_check
|
|
474
|
+
rep["checks"] = [{"level": l, "rule": r_, "message": m}
|
|
475
|
+
for l, r_, m in footprint_check.check(text_out, allow_duplicate_pads=[str(spec["tab"]["number"])] if spec.get("tab") else None)]
|
|
476
|
+
return text_out, rep
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def altium_footprint(spec, density=None):
|
|
480
|
+
"""Altium footprint dict (altium_lib's writer model) for any spec, from the same .kicad_mod geometry:
|
|
481
|
+
altium_lib.footprint_from_spec handles SMD, through-hole (round, square, slots), NPTH holes, polygon pads (anchor
|
|
482
|
+
pad + copper / mask / paste regions) and keep-outs. Returns (footprint, notes)."""
|
|
483
|
+
from . import altium_lib as AL
|
|
484
|
+
fp, rep = AL.footprint_from_spec(spec, density)
|
|
485
|
+
return fp, rep.get("altium_notes", [])
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def pin1_triangle(x, y):
|
|
489
|
+
"""Filled silk triangle pointing down at (x, y) - the KiCad-library pin-1 convention."""
|
|
490
|
+
return poly([(x, y), (x - 0.24, y - 0.33), (x + 0.24, y - 0.33)], "F.SilkS", SILK_W, fill="solid")
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def descr(spec, r):
|
|
494
|
+
s = spec.get("description", spec["name"])
|
|
495
|
+
s += f" | IPC-7351B {r['ipc_family']} density {r['density']}: Z={r['Z']:.2f} G={r['G']:.2f} X={r['X']:.2f}"
|
|
496
|
+
if spec.get("source", {}).get("datasheet"):
|
|
497
|
+
s += f" | src {spec['source']['datasheet']}"
|
|
498
|
+
return s + " | generated by ecadlib (prototype - review before use)"
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
_VALIDATOR = []
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def validate(spec):
|
|
505
|
+
"""Raise jsonschema.ValidationError (the best match, as jsonschema.validate does) when the spec breaks the schema.
|
|
506
|
+
The validator is built once: jsonschema.validate re-checks the whole schema on every call (~80 ms)."""
|
|
507
|
+
import jsonschema # a runtime dependency: never skip validation silently
|
|
508
|
+
if not _VALIDATOR:
|
|
509
|
+
schema = json.loads((Path(__file__).with_name("package_schema.json")).read_text())
|
|
510
|
+
cls = jsonschema.validators.validator_for(schema)
|
|
511
|
+
cls.check_schema(schema)
|
|
512
|
+
_VALIDATOR.append(cls(schema))
|
|
513
|
+
err = jsonschema.exceptions.best_match(_VALIDATOR[0].iter_errors(spec))
|
|
514
|
+
if err is not None:
|
|
515
|
+
raise err
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
# ---------------------------------------------------------------- comparison vs a reference footprint
|
|
519
|
+
def compare(mine_path, ref_text):
|
|
520
|
+
from . import sexpr
|
|
521
|
+
a, b = sexpr.parse(Path(mine_path).read_text()), sexpr.parse(ref_text)
|
|
522
|
+
pa = {p["number"]: p for p in sexpr.pads(a) if p["number"]}
|
|
523
|
+
pb = {p["number"]: p for p in sexpr.pads(b) if p["number"]}
|
|
524
|
+
rows = []
|
|
525
|
+
for k in sorted(set(pa) & set(pb), key=lambda s: int(s) if s.isdigit() else 999):
|
|
526
|
+
x, y = pa[k], pb[k]
|
|
527
|
+
rows.append({"pad": k, "mine": (x["x"], x["y"], x["w"], x["h"]), "ref": (y["x"], y["y"], y["w"], y["h"]),
|
|
528
|
+
"d_pos": (round(x["x"] - y["x"], 4), round(x["y"] - y["y"], 4)),
|
|
529
|
+
"d_size": (round(x["w"] - y["w"], 4), round(x["h"] - y["h"], 4))})
|
|
530
|
+
return rows, sexpr.courtyard_bbox(a), sexpr.courtyard_bbox(b)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def main(argv=None):
|
|
534
|
+
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
|
535
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
536
|
+
g = sub.add_parser("gen", help="generate .kicad_mod from package JSON files")
|
|
537
|
+
g.add_argument("specs", nargs="+")
|
|
538
|
+
g.add_argument("-o", "--out", default="out/footprints/ecadlib.pretty")
|
|
539
|
+
g.add_argument("--density", choices="ABC")
|
|
540
|
+
g.add_argument("--rules", help="library rules file: [footprint] density (default: ecadlib.toml found from here)")
|
|
541
|
+
c = sub.add_parser("compare", help="compare a generated footprint's pads with a reference (path or URL)")
|
|
542
|
+
c.add_argument("mine")
|
|
543
|
+
c.add_argument("ref")
|
|
544
|
+
a = ap.parse_args(argv)
|
|
545
|
+
|
|
546
|
+
if a.cmd == "gen":
|
|
547
|
+
from . import rules as RU
|
|
548
|
+
outdir = Path(a.out); outdir.mkdir(parents=True, exist_ok=True)
|
|
549
|
+
rules = RU.load(a.rules, start=outdir)
|
|
550
|
+
for sp in a.specs:
|
|
551
|
+
spec = json.loads(Path(sp).read_text())
|
|
552
|
+
validate(spec)
|
|
553
|
+
txt, rep = build(spec, RU.density_for(spec, rules, a.density))
|
|
554
|
+
from . import sexpr
|
|
555
|
+
sexpr.parse(txt) # sanity: balanced S-expression
|
|
556
|
+
(outdir / f"{spec['name']}.kicad_mod").write_text(txt, encoding="utf-8", newline="\n")
|
|
557
|
+
p1 = rep["pads"][0]
|
|
558
|
+
zgx = f"Z={rep['Z']:.2f} G={rep['G']:.2f} X={rep['X']:.2f}" if "G" in rep else f"{len(rep['pads'])} pads"
|
|
559
|
+
print(f"{spec['name']:<44} {rep['ipc_family']:<13} {zgx} "
|
|
560
|
+
f"pad1 {p1['w']:.3f}x{p1['h']:.3f} @({p1['x']:.3f},{p1['y']:.3f}) "
|
|
561
|
+
f"crtyd {rep['courtyard'][0]:.2f}x{rep['courtyard'][1]:.2f}"
|
|
562
|
+
+ (f" WARN {rep['warning']}" if "warning" in rep else "")
|
|
563
|
+
+ "".join(f"\n {c['level']}: {c['message']}" for c in rep.get("table_checks", []) if c["level"] != "info"))
|
|
564
|
+
else:
|
|
565
|
+
if a.ref.startswith("http"):
|
|
566
|
+
from .httpcache import CachedHTTP
|
|
567
|
+
r = CachedHTTP(min_interval=0.5).get(a.ref)
|
|
568
|
+
if not r.ok:
|
|
569
|
+
sys.exit(f"fetch failed: {r.error or r.status}")
|
|
570
|
+
ref = r.text
|
|
571
|
+
else:
|
|
572
|
+
ref = Path(a.ref).read_text()
|
|
573
|
+
rows, ca, cb = compare(a.mine, ref)
|
|
574
|
+
print(f"{'pad':>4} {'mine (x,y,w,h)':>30} {'kicad (x,y,w,h)':>30} d_pos d_size")
|
|
575
|
+
for r in rows[:4] + (rows[-1:] if len(rows) > 4 else []):
|
|
576
|
+
print(f"{r['pad']:>4} {str(r['mine']):>30} {str(r['ref']):>30} {r['d_pos']} {r['d_size']}")
|
|
577
|
+
if ca and cb:
|
|
578
|
+
print(f"courtyard mine {ca[2]-ca[0]:.2f}x{ca[3]-ca[1]:.2f} kicad {cb[2]-cb[0]:.2f}x{cb[3]-cb[1]:.2f}")
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
if __name__ == "__main__":
|
|
582
|
+
main()
|