ecadlib 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. ecadlib/__init__.py +3 -0
  2. ecadlib/__main__.py +5 -0
  3. ecadlib/a365_sync.py +637 -0
  4. ecadlib/agent.py +1407 -0
  5. ecadlib/agent_queue.py +780 -0
  6. ecadlib/agent_watch.py +732 -0
  7. ecadlib/altium365.py +385 -0
  8. ecadlib/altium_dblib_sync.py +530 -0
  9. ecadlib/altium_export.py +209 -0
  10. ecadlib/altium_forms.py +768 -0
  11. ecadlib/altium_lib.py +1089 -0
  12. ecadlib/altium_menu.py +1751 -0
  13. ecadlib/altium_pkg.py +396 -0
  14. ecadlib/altium_project.py +1095 -0
  15. ecadlib/app.py +1458 -0
  16. ecadlib/approvals.py +395 -0
  17. ecadlib/assets/demo_bom.csv +8 -0
  18. ecadlib/assets/kicad_action_plugin.py +607 -0
  19. ecadlib/assets/kicad_reread.py +60 -0
  20. ecadlib/assets/pcblib_template.json.gz +0 -0
  21. ecadlib/bom_inputs.py +339 -0
  22. ecadlib/bom_triage.py +519 -0
  23. ecadlib/cad_model.py +528 -0
  24. ecadlib/cfb.py +171 -0
  25. ecadlib/cli.py +185 -0
  26. ecadlib/clihelp.py +149 -0
  27. ecadlib/cliutil.py +119 -0
  28. ecadlib/completion.py +173 -0
  29. ecadlib/costs.py +276 -0
  30. ecadlib/crosscheck.py +412 -0
  31. ecadlib/datasheet.py +2168 -0
  32. ecadlib/dedupe.py +572 -0
  33. ecadlib/detect.py +170 -0
  34. ecadlib/doctor.py +450 -0
  35. ecadlib/ds_route.py +541 -0
  36. ecadlib/eagle_export.py +1409 -0
  37. ecadlib/easy_setup.py +338 -0
  38. ecadlib/easyeda_convert.py +386 -0
  39. ecadlib/easyeda_pro_export.py +972 -0
  40. ecadlib/eval/__init__.py +0 -0
  41. ecadlib/eval/baselines/kicad-v1.json +404 -0
  42. ecadlib/eval/build_set.py +299 -0
  43. ecadlib/eval/cli.py +119 -0
  44. ecadlib/eval/gate.py +358 -0
  45. ecadlib/eval/golden/demo.txt +379 -0
  46. ecadlib/eval/golden/specs.txt +3120 -0
  47. ecadlib/eval/golden/testpack.txt +463 -0
  48. ecadlib/eval/golden.py +446 -0
  49. ecadlib/eval/replay/kicad-v1.json.gz +0 -0
  50. ecadlib/eval/runner.py +456 -0
  51. ecadlib/eval/score.py +179 -0
  52. ecadlib/eval/sets/kicad-v1.json +630 -0
  53. ecadlib/firstrun.py +400 -0
  54. ecadlib/footprint_adv.py +1006 -0
  55. ecadlib/footprint_bench.py +218 -0
  56. ecadlib/footprint_check.py +562 -0
  57. ecadlib/footprint_gen.py +582 -0
  58. ecadlib/footprint_rules.py +1092 -0
  59. ecadlib/forge.py +267 -0
  60. ecadlib/fp_share.py +95 -0
  61. ecadlib/fp_tidy.py +389 -0
  62. ecadlib/fsutil.py +62 -0
  63. ecadlib/house_style.py +1051 -0
  64. ecadlib/httpcache.py +124 -0
  65. ecadlib/hub.py +1490 -0
  66. ecadlib/hub_copy.py +161 -0
  67. ecadlib/hub_tools.py +79 -0
  68. ecadlib/kicad_dblib.py +257 -0
  69. ecadlib/kicad_export.py +837 -0
  70. ecadlib/kicad_import.py +913 -0
  71. ecadlib/kicad_index.py +160 -0
  72. ecadlib/kicad_menu.py +193 -0
  73. ecadlib/kicad_pcm.py +475 -0
  74. ecadlib/launcher.py +156 -0
  75. ecadlib/librarian.py +963 -0
  76. ecadlib/lifecycle.py +691 -0
  77. ecadlib/llm/__init__.py +276 -0
  78. ecadlib/llm/cli.py +70 -0
  79. ecadlib/llm/openai_compat.py +214 -0
  80. ecadlib/llm/profiles.py +245 -0
  81. ecadlib/llm/rails.py +570 -0
  82. ecadlib/llm/run.py +126 -0
  83. ecadlib/llm/sampling.py +49 -0
  84. ecadlib/mcp_server.py +723 -0
  85. ecadlib/messages.py +419 -0
  86. ecadlib/migrate.py +407 -0
  87. ecadlib/notify.py +329 -0
  88. ecadlib/package_schema.json +1238 -0
  89. ecadlib/package_specs/CP_EIA-3216-18_Kemet-A.json +27 -0
  90. ecadlib/package_specs/C_0402_1005Metric.json +22 -0
  91. ecadlib/package_specs/C_0805_2012Metric.json +22 -0
  92. ecadlib/package_specs/DFN-8-1EP_3x3mm_P0.5mm_EP1.66x2.38mm.json +40 -0
  93. ecadlib/package_specs/D_SMA.json +27 -0
  94. ecadlib/package_specs/D_SMB.json +27 -0
  95. ecadlib/package_specs/D_SOD-123.json +43 -0
  96. ecadlib/package_specs/D_SOD-323.json +43 -0
  97. ecadlib/package_specs/LED_0603_1608Metric.json +23 -0
  98. ecadlib/package_specs/LED_0805_2012Metric.json +23 -0
  99. ecadlib/package_specs/LQFP-48_7x7mm_P0.5mm.json +31 -0
  100. ecadlib/package_specs/L_0805_2012Metric.json +22 -0
  101. ecadlib/package_specs/MSOP-8_3x3mm_P0.65mm.json +31 -0
  102. ecadlib/package_specs/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm.json +39 -0
  103. ecadlib/package_specs/QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm.json +37 -0
  104. ecadlib/package_specs/R_0402_1005Metric.json +22 -0
  105. ecadlib/package_specs/R_0603_1608Metric.json +22 -0
  106. ecadlib/package_specs/R_0805_2012Metric.json +22 -0
  107. ecadlib/package_specs/R_1206_3216Metric.json +22 -0
  108. ecadlib/package_specs/SOIC-16_3.9x9.9mm_P1.27mm.json +32 -0
  109. ecadlib/package_specs/SOIC-8_3.9x4.9mm_P1.27mm.json +32 -0
  110. ecadlib/package_specs/SOT-223-3_TabPin2.json +56 -0
  111. ecadlib/package_specs/SOT-23-5.json +57 -0
  112. ecadlib/package_specs/SOT-23-6.json +62 -0
  113. ecadlib/package_specs/SOT-23.json +48 -0
  114. ecadlib/package_specs/TSSOP-16_4.4x5mm_P0.65mm.json +31 -0
  115. ecadlib/package_specs/VQFN-16-1EP_3x3mm_P0.5mm_EP1.68x1.68mm.json +39 -0
  116. ecadlib/package_specs/advanced/BGA-100_11.0x11.0mm_Layout10x10_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  117. ecadlib/package_specs/advanced/BGA-121_9.0x9.0mm_Layout11x11_P0.8mm_Ball0.4mm_Pad0.35mm_NSMD.json +26 -0
  118. ecadlib/package_specs/advanced/BGA-144_7.0x7.0mm_Layout13x13_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +29 -0
  119. ecadlib/package_specs/advanced/BGA-169_11.0x11.0mm_Layout13x13_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +23 -0
  120. ecadlib/package_specs/advanced/BGA-256_11.0x11.0mm_Layout20x20_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +26 -0
  121. ecadlib/package_specs/advanced/BGA-256_17.0x17.0mm_Layout16x16_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  122. ecadlib/package_specs/advanced/BGA-324_15.0x15.0mm_Layout18x18_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  123. ecadlib/package_specs/advanced/BGA-36_3.396x3.466mm_Layout6x6_P0.4mm_Ball0.25mm_Pad0.2mm_NSMD.json +24 -0
  124. ecadlib/package_specs/advanced/BGA-672_27.0x27.0mm_Layout26x26_P1.0mm_Ball0.6mm_Pad0.5mm_NSMD.json +32 -0
  125. ecadlib/package_specs/advanced/BGA-68_5.0x5.0mm_Layout9x9_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +30 -0
  126. ecadlib/package_specs/advanced/BGA-90_8.0x13.0mm_Layout2x3x15_P0.8mm.json +26 -0
  127. ecadlib/package_specs/advanced/DFN-8-1EP_3x3mm_P0.5mm_EP1.65x2.38mm_ThermalVias.json +60 -0
  128. ecadlib/package_specs/advanced/Example_Custom_LPad_Slot.json +24 -0
  129. ecadlib/package_specs/advanced/JST_GH_SM04B-GHS-TB_1x04-1MP_P1.25mm_Horizontal.json +49 -0
  130. ecadlib/package_specs/advanced/JST_PH_B4B-PH-K_1x04_P2.00mm_Vertical.json +33 -0
  131. ecadlib/package_specs/advanced/JST_PH_S4B-PH-SM4-TB_1x04-1MP_P2.00mm_Horizontal.json +55 -0
  132. ecadlib/package_specs/advanced/JST_SH_SM04B-SRSS-TB_1x04-1MP_P1.00mm_Horizontal.json +49 -0
  133. ecadlib/package_specs/advanced/JST_XH_B2B-XH-A_1x02_P2.50mm_Vertical.json +34 -0
  134. ecadlib/package_specs/advanced/JST_XH_B4B-XH-A_1x04_P2.50mm_Vertical.json +34 -0
  135. ecadlib/package_specs/advanced/JST_XH_B6B-XH-A_1x06_P2.50mm_Vertical.json +33 -0
  136. ecadlib/package_specs/advanced/LFBGA-144_10x10mm_Layout12x12_P0.8mm.json +23 -0
  137. ecadlib/package_specs/advanced/Molex_KK-254_AE-6410-04A_1x04_P2.54mm_Vertical.json +34 -0
  138. ecadlib/package_specs/advanced/Molex_PicoBlade_53398-0471_1x04-1MP_P1.25mm_Vertical.json +54 -0
  139. ecadlib/package_specs/advanced/PinHeader_1x05_P1.00mm_Vertical_SMD_Pin1Left.json +14 -0
  140. ecadlib/package_specs/advanced/PinHeader_1x05_P1.27mm_Vertical_SMD_Pin1Left.json +14 -0
  141. ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical.json +12 -0
  142. ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical_SMD_Pin1Left.json +14 -0
  143. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical.json +12 -0
  144. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Left.json +25 -0
  145. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Right.json +14 -0
  146. ecadlib/package_specs/advanced/PinHeader_1x10_P1.00mm_Vertical.json +15 -0
  147. ecadlib/package_specs/advanced/PinHeader_2x05_P1.00mm_Vertical_SMD.json +13 -0
  148. ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical.json +18 -0
  149. ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical_SMD.json +24 -0
  150. ecadlib/package_specs/advanced/PinHeader_2x05_P2.00mm_Vertical_SMD.json +13 -0
  151. ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical.json +12 -0
  152. ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical_SMD.json +24 -0
  153. ecadlib/package_specs/advanced/PinHeader_2x20_P2.54mm_Vertical.json +12 -0
  154. ecadlib/package_specs/advanced/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm_ThermalVias.json +87 -0
  155. ecadlib/package_specs/advanced/UFBGA-132_7x7mm_Layout12x12_P0.5mm.json +43 -0
  156. ecadlib/package_specs/advanced/USB_C_Receptacle_HRO_TYPE-C-31-M-12.json +218 -0
  157. ecadlib/package_specs/standard.json.gz +0 -0
  158. ecadlib/parity.py +1204 -0
  159. ecadlib/parts_export.py +154 -0
  160. ecadlib/partview.py +215 -0
  161. ecadlib/plugins.py +231 -0
  162. ecadlib/providers.py +226 -0
  163. ecadlib/rebuild.py +471 -0
  164. ecadlib/release_check.py +437 -0
  165. ecadlib/reports.py +274 -0
  166. ecadlib/resolve.py +556 -0
  167. ecadlib/review_cli.py +292 -0
  168. ecadlib/rules.py +979 -0
  169. ecadlib/rules_infer.py +1205 -0
  170. ecadlib/sexpr.py +149 -0
  171. ecadlib/sheetsafe.py +65 -0
  172. ecadlib/sim_models.py +1087 -0
  173. ecadlib/sourcing.py +1161 -0
  174. ecadlib/stdparts.py +755 -0
  175. ecadlib/step_gen.py +929 -0
  176. ecadlib/symbol_layout.py +798 -0
  177. ecadlib/team.py +558 -0
  178. ecadlib/team_setup.py +288 -0
  179. ecadlib/term.py +420 -0
  180. ecadlib/term_views.py +64 -0
  181. ecadlib/testpack.py +334 -0
  182. ecadlib/ui/__init__.py +1 -0
  183. ecadlib/ui/__main__.py +3 -0
  184. ecadlib/ui/server.py +1029 -0
  185. ecadlib/ui/static/hairline.js +2 -0
  186. ecadlib/ui/static/index.html +2098 -0
  187. ecadlib/ui/static/setup.html +294 -0
  188. ecadlib/upkeep.py +717 -0
  189. ecadlib/upstream.py +573 -0
  190. ecadlib/userconfig.py +257 -0
  191. ecadlib/visual_check.py +1603 -0
  192. ecadlib/ws_status.py +203 -0
  193. ecadlib-0.3.0.dist-info/METADATA +2245 -0
  194. ecadlib-0.3.0.dist-info/RECORD +199 -0
  195. ecadlib-0.3.0.dist-info/WHEEL +5 -0
  196. ecadlib-0.3.0.dist-info/entry_points.txt +2 -0
  197. ecadlib-0.3.0.dist-info/licenses/LICENSE +21 -0
  198. ecadlib-0.3.0.dist-info/licenses/NOTICE +53 -0
  199. ecadlib-0.3.0.dist-info/top_level.txt +1 -0
ecadlib/step_gen.py ADDED
@@ -0,0 +1,929 @@
1
+ """Package-shaped 3D models as STEP files (ISO 10303-21, AP214), built from a package spec or a footprint name.
2
+
3
+ python3 -m ecadlib.step_gen ecadlib/package_specs/SOIC-8_3.9x4.9mm_P1.27mm.json -o out/SOIC-8.step [--height MM]
4
+ python3 -m ecadlib.step_gen SOIC-8_3.9x4.9mm_P1.27mm -o out/SOIC-8.step (a footprint name)
5
+ python3 -m ecadlib.step_gen PinHeader_1x04_P2.54mm_Vertical -o out/hdr.step
6
+ python3 -m ecadlib.step_gen --check out/SOIC-8.step [more.step ...] (validate any STEP file)
7
+
8
+ Pure Python, no CAD kernel. Each model is a set of manifold solid B-reps with planar faces (round parts are
9
+ 24-sided prisms), styled with STEP colours (dark body, silver leads, light pin-1 / polarity mark). Units mm.
10
+ Placement follows the KiCad library models, so a model lines up with the footprint at offset 0, rotation 0:
11
+ origin at the footprint origin, z = 0 on the board, x right, y up (KiCad footprint y is down), pin 1 where the
12
+ footprint has it (2-terminal parts: pin 1 at -x; dual/quad rows: pin 1 top left; through-hole: pin 1 at 0,0).
13
+
14
+ Families (package_schema.json `package_type`, else parsed from the footprint name):
15
+ chip R/C/L/LED chips: body + two metallised end caps (LED: white body, green cathode mark; MELF: round)
16
+ molded tantalum (CP_EIA), SMA/SMB/SMC, molded 2-terminal: body + terminals folded under it, polarity band
17
+ gullwing SOIC/SSOP/TSSOP/MSOP/SOT-23(-5/-6)/SOT-223/SOD-123/SOD-323: two-tier tapered body, gull-wing leads,
18
+ tab lead (SOT-223), flat leads (lead_type flat, SOT-89); pin-1 dot or polarity band
19
+ qfp LQFP/TQFP/QFP: four rows of gull-wing leads
20
+ qfn QFN/DFN/SON: body, bottom terminals flush with the edge, exposed pad, mould between them
21
+ TH PinHeader_RxC_P2.54/2.00/1.27mm_Vertical, C/CP_Radial_D..mm_P..mm, R/D_Axial / D_DO-35/41/201AD
22
+ bga BGA/LGA/CSP: balls on the footprint_adv grid (depopulated balls left out) under a flat body, A1 dot
23
+ connector connector / custom pad_table specs: box over the body, pins through the plated holes, flat SMD contacts
24
+ Height: --height, else the spec's height_max, else the CP_EIA-xxxx-HH suffix, else HEIGHT below, else the middle
25
+ of release_check.HEIGHTS for the family.
26
+
27
+ check() parses a STEP file (any producer) and verifies: every #reference resolves; each CLOSED_SHELL is closed
28
+ (every edge used by exactly two faces, in opposite directions); loops are connected; face vertices lie on their
29
+ PLANE and the loop turns the right way round it; LINE geometry passes through its vertices; each solid's
30
+ volume is positive; it reports the bounding box and which solids carry a colour.
31
+ kicad_import.add_body(gen_step=True) and the librarian embed these models where they would otherwise
32
+ extrude a plain box (the box stays the last resort)."""
33
+ import argparse, json, math, re, sys
34
+ from pathlib import Path
35
+
36
+ from .footprint_gen import dim
37
+
38
+ SPECS = Path(__file__).with_name("package_specs")
39
+ COL = {"body": (0.1, 0.1, 0.1), "metal": (0.82, 0.82, 0.85), "mark": (0.78, 0.78, 0.78), "mlcc": (0.62, 0.49, 0.33),
40
+ "led": (0.95, 0.95, 0.92), "led_mark": (0.1, 0.6, 0.2), "tant": (0.88, 0.66, 0.12), "tant_mark": (0.45, 0.28, 0.08),
41
+ "gold": (0.86, 0.7, 0.3), "ecap": (0.12, 0.14, 0.32), "resistor": (0.76, 0.66, 0.46), "inductor": (0.25, 0.25, 0.27)}
42
+ MARK_T = 0.01 # pin-1 / polarity mark thickness (the body top sits this much lower)
43
+ SEG = 24 # sides of a "round" prism
44
+ # overall height (mm) when neither the caller nor the spec gives one: first regex on the footprint name wins
45
+ HEIGHT = [(r"^R_0201", 0.26), (r"^R_0402", 0.35), (r"^R_0603", 0.45), (r"^R_0805", 0.5), (r"^R_(1206|1210)", 0.55), (r"^R_", 0.6),
46
+ (r"^C_0201", 0.3), (r"^C_0402", 0.5), (r"^C_0603", 0.8), (r"^C_0805", 1.25), (r"^C_1206", 1.6), (r"^C_", 2.0),
47
+ (r"^L_0402", 0.5), (r"^L_0603", 0.8), (r"^L_0805", 1.0), (r"^L_", 1.2), (r"^LED_0402", 0.4), (r"^LED_0603", 0.6),
48
+ (r"^LED_0805", 0.8), (r"^LED_", 1.1), (r"^CP_EIA", 1.6), (r"^(D_)?SOD-?123", 1.17), (r"^(D_)?SOD-?323", 0.9),
49
+ (r"^(D_)?SOD-?523", 0.6), (r"^(D_)?SM[ABC]\b", 2.3), (r"^SOT-223", 1.8), (r"^SOT-89", 1.5), (r"^SOT-?(323|363|353)", 0.95),
50
+ (r"^SOT-23", 1.1), (r"^(TSSOP|MSOP|VSSOP|HTSSOP|TSOP)", 1.1), (r"^SSOP", 1.75), (r"^(SOIC|SOP|SO-)", 1.75),
51
+ (r"(QFN|DFN|SON)", 0.8), (r"^LQFP", 1.6), (r"^TQFP", 1.2), (r"QFP", 2.0)]
52
+
53
+
54
+ # ---------------------------------------------------------------- vectors
55
+ def _add(a, b): return (a[0] + b[0], a[1] + b[1], a[2] + b[2])
56
+ def _sub(a, b): return (a[0] - b[0], a[1] - b[1], a[2] - b[2])
57
+ def _mul(a, k): return (a[0] * k, a[1] * k, a[2] * k)
58
+ def _dot(a, b): return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
59
+ def _cross(a, b): return (a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0])
60
+ def _len(a): return math.sqrt(_dot(a, a))
61
+ def _unit(a): return _mul(a, 1.0 / _len(a))
62
+
63
+
64
+ def _newell(pts):
65
+ n = (0.0, 0.0, 0.0)
66
+ for a, b in zip(pts, pts[1:] + pts[:1]):
67
+ n = _add(n, ((a[1] - b[1]) * (a[2] + b[2]), (a[2] - b[2]) * (a[0] + b[0]), (a[0] - b[0]) * (a[1] + b[1])))
68
+ return n
69
+
70
+
71
+ def _area2(poly):
72
+ return sum(a[0] * b[1] - b[0] * a[1] for a, b in zip(poly, poly[1:] + poly[:1])) / 2
73
+
74
+
75
+ # ---------------------------------------------------------------- solids
76
+ class Solid:
77
+ """Closed polyhedron: verts [(x, y, z)], faces [[outer loop, *hole loops]] of vertex indices, each loop
78
+ counter-clockwise seen from outside the solid (holes clockwise)."""
79
+ def __init__(self, name, verts, faces, color):
80
+ self.name, self.verts, self.faces, self.color = name, verts, faces, color
81
+
82
+
83
+ def prism(poly, depth, origin=(0, 0, 0), u=(1, 0, 0), v=(0, 1, 0), holes=(), name="", color=COL["body"]):
84
+ """2D polygon (in the u, v frame at origin) extruded by depth along u x v; holes are inner polygons."""
85
+ w = _cross(u, v)
86
+ poly = list(poly) if _area2(poly) > 0 else list(poly)[::-1]
87
+ loops = [poly] + [list(h) if _area2(h) < 0 else list(h)[::-1] for h in holes]
88
+ P = lambda p, d: _add(_add(_add(origin, _mul(u, p[0])), _mul(v, p[1])), _mul(w, d))
89
+ verts, bot, top = [], [], []
90
+ for lp in loops:
91
+ bot.append(list(range(len(verts), len(verts) + len(lp)))); verts += [P(p, 0) for p in lp]
92
+ top.append(list(range(len(verts), len(verts) + len(lp)))); verts += [P(p, depth) for p in lp]
93
+ faces = [[t for t in top], [b[::-1] for b in bot]]
94
+ for b, t in zip(bot, top):
95
+ k = len(b)
96
+ faces += [[[b[i], b[(i + 1) % k], t[(i + 1) % k], t[i]]] for i in range(k)]
97
+ return Solid(name, verts, faces, color)
98
+
99
+
100
+ def box(x0, y0, z0, x1, y1, z1, name="", color=COL["body"]):
101
+ return prism([(x0, y0), (x1, y0), (x1, y1), (x0, y1)], z1 - z0, (0, 0, z0), name=name, color=color)
102
+
103
+
104
+ def loft(rings, zs, name="", color=COL["body"]):
105
+ """Stack of horizontal polygons with the same vertex count; consecutive rings must have parallel edges
106
+ (offset_poly of one another) so every side quad is planar."""
107
+ rings = [r if _area2(r) > 0 else r[::-1] for r in rings]
108
+ k, verts = len(rings[0]), []
109
+ for r, z in zip(rings, zs):
110
+ verts += [(x, y, z) for x, y in r]
111
+ idx = lambda j, i: j * k + i % k
112
+ faces = [[[idx(len(rings) - 1, i) for i in range(k)]], [[idx(0, i) for i in range(k)][::-1]]]
113
+ for j in range(len(rings) - 1):
114
+ faces += [[[idx(j, i), idx(j, i + 1), idx(j + 1, i + 1), idx(j + 1, i)]] for i in range(k)]
115
+ return Solid(name, verts, faces, color)
116
+
117
+
118
+ def offset_poly(poly, d):
119
+ """Move every edge of a counter-clockwise polygon inward by d (edges stay parallel)."""
120
+ lines = []
121
+ for a, b in zip(poly, poly[1:] + poly[:1]):
122
+ t = (b[0] - a[0], b[1] - a[1]); L = math.hypot(*t); t = (t[0] / L, t[1] / L)
123
+ nrm = (-t[1], t[0])
124
+ lines.append(((a[0] + nrm[0] * d, a[1] + nrm[1] * d), t))
125
+ out = []
126
+ for (p1, t1), (p2, t2) in zip(lines[-1:] + lines[:-1], lines):
127
+ den = t1[0] * t2[1] - t1[1] * t2[0]
128
+ s = ((p2[0] - p1[0]) * t2[1] - (p2[1] - p1[1]) * t2[0]) / den
129
+ out.append((p1[0] + t1[0] * s, p1[1] + t1[1] * s))
130
+ return out
131
+
132
+
133
+ def rect(hx, hy, cx=0.0, cy=0.0):
134
+ return [(cx - hx, cy - hy), (cx + hx, cy - hy), (cx + hx, cy + hy), (cx - hx, cy + hy)]
135
+
136
+
137
+ def circle(r, cx=0.0, cy=0.0, n=SEG):
138
+ return [(cx + r * math.cos(2 * math.pi * i / n), cy + r * math.sin(2 * math.pi * i / n)) for i in range(n)]
139
+
140
+
141
+ def thick_polyline(pts, t):
142
+ """Polygon around a 2D polyline, t thick, mitred corners."""
143
+ def nrm(a, b):
144
+ d = (b[0] - a[0], b[1] - a[1]); L = math.hypot(*d)
145
+ return (-d[1] / L, d[0] / L)
146
+ left, right = [], []
147
+ for i, p in enumerate(pts):
148
+ ns = [nrm(pts[i - 1], p)] if i else []
149
+ ns += [nrm(p, pts[i + 1])] if i + 1 < len(pts) else []
150
+ m = (sum(n[0] for n in ns), sum(n[1] for n in ns)); L = math.hypot(*m); m = (m[0] / L, m[1] / L)
151
+ k = (t / 2) / max(0.25, m[0] * ns[0][0] + m[1] * ns[0][1])
152
+ left.append((p[0] + m[0] * k, p[1] + m[1] * k)); right.append((p[0] - m[0] * k, p[1] - m[1] * k))
153
+ return left + right[::-1]
154
+
155
+
156
+ def side_profile(profile, out_dir, lateral, width, name, color=COL["metal"]):
157
+ """A lead: profile polygon in (s = distance from the centre along out_dir, z), `width` wide, centred at
158
+ `lateral` (the pin's coordinate along the row: y for out_dir +-x, x for out_dir +-y)."""
159
+ o = (out_dir[0], out_dir[1], 0.0)
160
+ w = _cross(o, (0, 0, 1))
161
+ c = lateral * (w[0] + w[1]) # w is +-x or +-y: the pin's coordinate along w
162
+ return prism(profile, width, _mul(w, c - width / 2), o, (0, 0, 1), name=name, color=color)
163
+
164
+
165
+ def mark_dot(x, y, r, z, name="pin 1 mark"):
166
+ return prism(circle(r, x, y, 8), MARK_T, (0, 0, z), name=name, color=COL["mark"])
167
+
168
+
169
+ # ---------------------------------------------------------------- package builders
170
+ def _n(spec, k):
171
+ return dim(spec[k])[1] if spec.get(k) is not None else None
172
+
173
+
174
+ def _kind(name):
175
+ m = re.match(r"(LED|CP|R|C|L|D|F)_", name or "")
176
+ return m.group(1) if m else ""
177
+
178
+
179
+ def chip(spec, H):
180
+ L, W = _n(spec, "body_length"), _n(spec, "body_width")
181
+ T = _n(spec, "terminal_length") or (L - _n(spec, "inner_span")) / 2
182
+ kind, pol = _kind(spec["name"]), bool(spec.get("polarized"))
183
+ out = []
184
+ if spec.get("lead_type") == "melf": # round body and caps along x
185
+ r = min(W, H) / 2
186
+ cyl = lambda x0, x1, rr, nm, col: prism(circle(rr, 0, r), x1 - x0, (x0, 0, 0), (0, 1, 0), (0, 0, 1), name=nm, color=col)
187
+ out += [cyl(-L / 2, -L / 2 + T, r, "terminal 1", COL["metal"]), cyl(L / 2 - T, L / 2, r, "terminal 2", COL["metal"])]
188
+ bx0, bx1 = -L / 2 + T, L / 2 - T
189
+ if pol:
190
+ band = bx0 + 0.25 * (bx1 - bx0)
191
+ out += [cyl(bx0, band, r * 0.97, "cathode band", COL["mark"]), cyl(band, bx1, r * 0.97, "body", COL["body"])]
192
+ else:
193
+ out.append(cyl(bx0, bx1, r * 0.97, "body", COL["resistor"] if kind == "R" else COL["body"]))
194
+ return out
195
+ e = min(0.025, 0.04 * min(W, H)) # body sits slightly inside the caps
196
+ out += [box(-L / 2, -W / 2, 0, -L / 2 + T, W / 2, H, "terminal 1", COL["metal"]),
197
+ box(L / 2 - T, -W / 2, 0, L / 2, W / 2, H, "terminal 2", COL["metal"])]
198
+ col = {"C": COL["mlcc"], "LED": COL["led"], "L": COL["inductor"]}.get(kind, COL["body"])
199
+ top = H - e - (MARK_T if pol else 0)
200
+ out.append(box(-L / 2 + T, -W / 2 + e, e, L / 2 - T, W / 2 - e, top, "body", col))
201
+ if pol: # cathode (LED, diode) / + mark on the pin-1 end
202
+ x0 = -L / 2 + T
203
+ out.append(box(x0, -W / 2 + 2 * e, top, x0 + 0.3 * (L - 2 * T), W / 2 - 2 * e, top + MARK_T, "polarity mark",
204
+ COL["led_mark"] if kind == "LED" else COL["mark"]))
205
+ return out
206
+
207
+
208
+ def molded(spec, H):
209
+ L, W = _n(spec, "body_length"), _n(spec, "body_width")
210
+ Wt = _n(spec, "terminal_width") or W
211
+ T = _n(spec, "terminal_length") or (L - _n(spec, "inner_span")) / 2
212
+ tant = _kind(spec["name"]) in ("CP", "C")
213
+ t = 0.1 if tant else 0.15 # terminal sheet thickness
214
+ hl = H * (0.6 if tant else 0.45) # terminal height up the end face
215
+ prof = [(L / 2 - T, 0), (L / 2, 0), (L / 2, hl), (L / 2 - t, hl), (L / 2 - t, t), (L / 2 - T, t)]
216
+ out = [side_profile(prof, (-1, 0), 0, Wt, "terminal 1"), side_profile(prof, (1, 0), 0, Wt, "terminal 2")]
217
+ top = H - (MARK_T if spec.get("polarized") else 0)
218
+ out.append(box(-L / 2 + t, -W / 2, t, L / 2 - t, W / 2, top, "body", COL["tant"] if tant else COL["body"]))
219
+ if spec.get("polarized"):
220
+ x0 = -L / 2 + t + 0.06 * L
221
+ out.append(box(x0, -W / 2 + 0.1, top, x0 + 0.18 * L, W / 2 - 0.1, H, "polarity band", COL["tant_mark"] if tant else COL["mark"]))
222
+ return out
223
+
224
+
225
+ def _ic_body(hx, hy, A1, zlo, zhi, H):
226
+ """Two-tier moulded body: tapered from the standoff up to the lead band, straight band, tapered to the top."""
227
+ base = rect(hx, hy)
228
+ dt = min(0.1, 0.05 * min(2 * hx, 2 * hy))
229
+ di = min(dt / 2, max(zlo - A1, 0) * 0.15)
230
+ rings, zs = [offset_poly(base, di), base, base, offset_poly(base, dt)], [A1, zlo, zhi, H - MARK_T]
231
+ if di <= 1e-6 or zlo - A1 < 1e-6: # no lower taper (flat leads at the board)
232
+ rings, zs = rings[1:], [A1, zhi, H - MARK_T]
233
+ return loft(rings, zs, "body"), dt
234
+
235
+
236
+ def _lead_path(s_body, s_toe, Lf, zm, c, flat):
237
+ if flat:
238
+ return [(s_body, c / 2), (s_toe, c / 2)]
239
+ s_knee = max(s_toe - Lf, s_body + c)
240
+ s_knee = min(s_knee, s_toe - c)
241
+ s_sh = s_body + 0.35 * max(s_knee - s_body, 0)
242
+ return [(s_body, zm), (s_sh, zm), (s_knee, c / 2), (s_toe, c / 2)]
243
+
244
+
245
+ def _lead_params(spec, H, pitch):
246
+ flat = spec.get("lead_type") == "flat"
247
+ c = 0.25 if spec.get("tab") and not flat else 0.2 if (pitch or 1) >= 1.0 else 0.15
248
+ A1 = 0.0 if flat else 0.1 if H > 1.3 else 0.05
249
+ zm = c / 2 if flat else max(A1 + (H - A1) * 0.3, c + 0.1)
250
+ return flat, c, A1, zm
251
+
252
+
253
+ def gullwing(spec, H):
254
+ E, E1, D = _n(spec, "lead_span"), _n(spec, "body_width"), _n(spec, "body_length")
255
+ b, Lf = _n(spec, "terminal_width"), _n(spec, "terminal_length")
256
+ pos = spec.get("pin_positions")
257
+ if not pos:
258
+ e, per = spec["pitch"], spec["pin_count"] // 2
259
+ pos = [{"number": str(i + 1), "side": "L", "offset": (i - (per - 1) / 2) * e} for i in range(per)]
260
+ pos += [{"number": str(per + i + 1), "side": "R", "offset": ((per - 1) / 2 - i) * e} for i in range(per)]
261
+ offs = sorted(p["offset"] for p in pos)
262
+ pitch = spec.get("pitch") or min([b2 - a for a, b2 in zip(offs, offs[1:]) if b2 - a > 1e-6] or [1.0])
263
+ flat, c, A1, zm = _lead_params(spec, H, pitch)
264
+ body, dt = _ic_body(E1 / 2, D / 2, A1, zm - c / 2, zm + c / 2, H)
265
+ out = [body]
266
+ path = thick_polyline(_lead_path(E1 / 2, E / 2, Lf, zm, c, flat), c)
267
+ for p in pos:
268
+ out.append(side_profile(path, (-1, 0) if p["side"] == "L" else (1, 0), -p["offset"], b, f"pin {p['number']}"))
269
+ tab = spec.get("tab")
270
+ if tab:
271
+ tl = dim(tab["length"])[1] if tab.get("length") else Lf
272
+ tpath = thick_polyline(_lead_path(E1 / 2, E / 2, tl, zm, c, flat), c)
273
+ out.append(side_profile(tpath, (-1, 0) if tab.get("side", "R") == "L" else (1, 0), -tab.get("offset", 0.0),
274
+ dim(tab["width"])[1], f"tab {tab['number']}"))
275
+ p1 = next((p for p in pos if str(p["number"]) == "1"), pos[0])
276
+ if spec.get("polarized") and len(pos) == 2:
277
+ x0 = -E1 / 2 + dt + 0.05
278
+ out.append(box(x0, -D / 2 + dt + 0.05, H - MARK_T, x0 + 0.2 * E1, D / 2 - dt - 0.05, H, "polarity band", COL["mark"]))
279
+ else:
280
+ r = min(0.3, 0.12 * min(E1, D))
281
+ sx = -1 if p1["side"] == "L" else 1
282
+ sy = -math.copysign(1, p1["offset"]) if abs(p1["offset"]) > 1e-6 else 0
283
+ out.append(mark_dot(sx * (E1 / 2 - dt - 1.6 * r), sy * (D / 2 - dt - 1.6 * r), r, H - MARK_T))
284
+ return out
285
+
286
+
287
+ def _quad_pins(spec):
288
+ """[(number, side, offset along the side)] in KiCad's order: left top->bottom, bottom, right, top (model y up)."""
289
+ n, e = spec["pin_count"], spec["pitch"]
290
+ nlr = spec.get("pins_left_right", n // 4)
291
+ ntb = spec.get("pins_top_bottom", (n - 2 * nlr) // 2)
292
+ pins, k = [], 1
293
+ for side, cnt, sgn in (("L", nlr, -1), ("B", ntb, 1), ("R", nlr, 1), ("T", ntb, -1)):
294
+ for i in range(cnt):
295
+ pins.append((str(k), side, sgn * (i - (cnt - 1) / 2) * e)); k += 1
296
+ return pins # offset: model y for L/R rows, model x for B/T rows
297
+
298
+
299
+ OUT = {"L": (-1, 0), "R": (1, 0), "B": (0, -1), "T": (0, 1)}
300
+
301
+
302
+ def qfp(spec, H):
303
+ Bx, By = _n(spec, "body_length"), _n(spec, "body_width")
304
+ Ex = _n(spec, "lead_span")
305
+ Ey = dim(spec.get("lead_span_y") or spec["lead_span"])[1]
306
+ b, Lf = _n(spec, "terminal_width"), _n(spec, "terminal_length")
307
+ flat, c, A1, zm = _lead_params(spec, H, spec["pitch"])
308
+ body, dt = _ic_body(Bx / 2, By / 2, A1, zm - c / 2, zm + c / 2, H)
309
+ out = [body]
310
+ px = thick_polyline(_lead_path(Bx / 2, Ex / 2, Lf, zm, c, flat), c)
311
+ py = thick_polyline(_lead_path(By / 2, Ey / 2, Lf, zm, c, flat), c)
312
+ for num, side, off in _quad_pins(spec):
313
+ out.append(side_profile(px if side in "LR" else py, OUT[side], off, b, f"pin {num}"))
314
+ r = min(0.5, 0.08 * min(Bx, By))
315
+ out.append(mark_dot(-(Bx / 2 - dt - 2 * r), By / 2 - dt - 2 * r, r, H - MARK_T))
316
+ return out
317
+
318
+
319
+ def qfn(spec, H):
320
+ Bx, By = _n(spec, "body_length"), _n(spec, "body_width")
321
+ b, T = _n(spec, "terminal_width"), _n(spec, "terminal_length")
322
+ tt = min(0.2, 0.3 * H) # lead frame thickness
323
+ pins = _quad_pins(spec)
324
+ out, notches = [], {"L": [], "R": [], "B": [], "T": []}
325
+ for num, side, off in pins:
326
+ if side in "LR":
327
+ sx = -1 if side == "L" else 1
328
+ out.append(box(min(sx * Bx / 2, sx * (Bx / 2 - T)), off - b / 2, 0, max(sx * Bx / 2, sx * (Bx / 2 - T)), off + b / 2, tt,
329
+ f"pin {num}", COL["metal"]))
330
+ else:
331
+ sy = -1 if side == "B" else 1
332
+ out.append(box(off - b / 2, min(sy * By / 2, sy * (By / 2 - T)), 0, off + b / 2, max(sy * By / 2, sy * (By / 2 - T)), tt,
333
+ f"pin {num}", COL["metal"]))
334
+ notches[side].append(off)
335
+ # mould between the terminals: the body outline (counter-clockwise) with a notch per terminal
336
+ poly = []
337
+ for side, start, d in (("B", (-Bx / 2, -By / 2), (1, 0)), ("R", (Bx / 2, -By / 2), (0, 1)),
338
+ ("T", (Bx / 2, By / 2), (-1, 0)), ("L", (-Bx / 2, By / 2), (0, -1))):
339
+ poly.append(start)
340
+ n_ = (-d[1], d[0]) # inward
341
+ along = sorted(((o - start[0]) * d[0] if side in "BT" else (o - start[1]) * d[1]) for o in notches[side])
342
+ for a in along:
343
+ for s, k in ((a - b / 2, 0), (a - b / 2, T), (a + b / 2, T), (a + b / 2, 0)):
344
+ poly.append((start[0] + d[0] * s + n_[0] * k, start[1] + d[1] * s + n_[1] * k))
345
+ ep = spec.get("exposed_pad")
346
+ holes = []
347
+ if ep:
348
+ ex, ey = dim(ep["length"])[1], dim(ep["width"])[1]
349
+ out.append(box(-ex / 2, -ey / 2, 0, ex / 2, ey / 2, tt, "exposed pad", COL["metal"]))
350
+ holes.append(rect(ex / 2, ey / 2)[::-1])
351
+ out.append(prism(poly, tt, holes=holes, name="mould", color=COL["body"]))
352
+ out.append(box(-Bx / 2, -By / 2, tt, Bx / 2, By / 2, H - MARK_T, "body", COL["body"]))
353
+ r = min(0.25, 0.08 * min(Bx, By))
354
+ out.append(mark_dot(-(Bx / 2 - 2 * r), By / 2 - 2 * r, r, H - MARK_T))
355
+ return out
356
+
357
+
358
+ def pin_header(spec, H=None):
359
+ rows, cols, p = spec["rows"], spec["cols"], spec["pitch"]
360
+ hb, top, a = {2.54: (2.5, 8.5, 0.64), 2.0: (2.0, 6.0, 0.5), 1.27: (1.5, 4.0, 0.4)}.get(round(p, 2), (p, 3 * p, 0.25 * p))
361
+ pins = [(c * p, -r * p) for r in range(rows) for c in range(cols)]
362
+ holes = [rect(a / 2, a / 2, x, y)[::-1] for x, y in pins]
363
+ outline = [(-p / 2, -(rows - 1) * p - p / 2), ((cols - 1) * p + p / 2, -(rows - 1) * p - p / 2),
364
+ ((cols - 1) * p + p / 2, p / 2), (-p / 2, p / 2)]
365
+ out = [prism(outline, hb, holes=holes, name="body", color=COL["body"])]
366
+ out += [box(x - a / 2, y - a / 2, -3.0, x + a / 2, y + a / 2, top, f"pin {i + 1}", COL["gold"]) for i, (x, y) in enumerate(pins)]
367
+ return out
368
+
369
+
370
+ def radial(spec, H):
371
+ D, P = spec["diameter"], spec["pitch"]
372
+ wd = 0.5 if D <= 6.3 else 0.6
373
+ out = [prism(circle(D / 2, P / 2, 0), H, name="body", color=COL["ecap"] if spec.get("polarized") else COL["mlcc"])]
374
+ out += [box(x - wd / 2, -wd / 2, -3.0, x + wd / 2, wd / 2, 0, f"pin {i + 1}", COL["metal"]) for i, x in enumerate((0.0, P))]
375
+ return out
376
+
377
+
378
+ def axial(spec, H=None):
379
+ L, D, P = spec["length"], spec["diameter"], spec["pitch"]
380
+ wd = 0.8 if D > 3.5 else 0.6
381
+ x0, x1, zc = P / 2 - L / 2, P / 2 + L / 2, D / 2
382
+ cyl = lambda a, b2, nm, col: prism(circle(D / 2, 0, zc), b2 - a, (a, 0, 0), (0, 1, 0), (0, 0, 1), name=nm, color=col)
383
+ if spec.get("polarized"): # cathode band at the pin-1 end
384
+ out = [cyl(x0, x0 + 0.15 * L, "cathode band", COL["mark"]), cyl(x0 + 0.15 * L, x1, "body", COL["body"])]
385
+ else:
386
+ out = [cyl(x0, x1, "body", COL["resistor"])]
387
+ for i, path in enumerate(([(x0, zc), (0.0, zc), (0.0, -3.0)], [(x1, zc), (P, zc), (P, -3.0)])):
388
+ out.append(prism(thick_polyline(path, wd), wd, (0, wd / 2, 0), (1, 0, 0), (0, 0, 1), name=f"pin {i + 1}", color=COL["metal"]))
389
+ return out
390
+
391
+
392
+ def bga(spec, H):
393
+ """BGA / LGA / CSP: balls (8-sided prisms, the collapsed ball height) under a flat body, A1 dot on top.
394
+ Balls sit where footprint_adv puts the pads (same grid, same depopulated balls)."""
395
+ from .footprint_adv import bga_balls
396
+ Bx, By = _n(spec, "body_length"), _n(spec, "body_width")
397
+ e = spec["pitch"]
398
+ ey = spec.get("pitch_y") or e
399
+ rows, cols = spec["rows"], spec["cols"]
400
+ d = _n(spec, "ball_diameter") or 1.25 * _n(spec, "land_diameter")
401
+ lga = spec.get("ball_type") == "lga"
402
+ A1 = 0.05 if lga else min(0.8 * d, 0.5 * H) # standoff: lands are flush, balls collapse to ~80 %
403
+ out = []
404
+ for name, i, j in bga_balls(spec):
405
+ x, y = (j - (cols - 1) / 2) * e, -((i - (rows - 1) / 2) * ey)
406
+ out.append(prism(rect(d / 2, d / 2, x, y) if lga else circle(d / 2, x, y, 8), A1, (0, 0, 0),
407
+ name=f"ball {name}", color=COL["metal"]))
408
+ out.append(box(-Bx / 2, -By / 2, A1, Bx / 2, By / 2, H - MARK_T, "body", COL["body"]))
409
+ r = min(0.4, 0.06 * min(Bx, By))
410
+ out.append(mark_dot(-(Bx / 2 - 2 * r), By / 2 - 2 * r, r, H - MARK_T))
411
+ return out
412
+
413
+
414
+ def connector(spec, H):
415
+ """Connector / custom part from its pad table: a box over the body outline, a pin through every plated hole
416
+ (3 mm below the board) and a flat contact on every SMD pad; pin 1 marked on the body top."""
417
+ from .footprint_adv import table_geometry
418
+ pads, body, _, _, _, _ = table_geometry(spec)
419
+ out = []
420
+ if body is None:
421
+ xs = [p["x"] + s * p["w"] / 2 for p in pads for s in (-1, 1)]
422
+ ys = [p["y"] + s * p["h"] / 2 for p in pads for s in (-1, 1)]
423
+ body = (min(xs), min(ys), max(xs), max(ys))
424
+ x0, y0, x1, y1 = body[0], -body[3], body[2], -body[1] # KiCad y down -> y up
425
+ for p in pads:
426
+ if p.get("via") or p["kind"] == "np_thru_hole":
427
+ continue
428
+ x, y = p["x"], -p["y"]
429
+ turn = round((p.get("rot") or 0) / 90) % 2 # pad sizes and drills are in the pad's frame
430
+ if p["kind"] == "thru_hole":
431
+ a = 0.64 * min(p["drill"]) / 0.95 if min(p["drill"]) < 0.95 else 0.64
432
+ hx, hy = (a / 2, (max(p["drill"]) - min(p["drill"]) + a) / 2) if p["drill"][1] > p["drill"][0] else \
433
+ ((max(p["drill"]) - min(p["drill"]) + a) / 2, a / 2)
434
+ hx, hy = (hy, hx) if turn else (hx, hy)
435
+ out.append(box(x - hx, y - hy, -3.0, x + hx, y + hy, min(H, 1.0), f"pin {p['num']}", COL["gold"]))
436
+ else:
437
+ w, h = (p["h"], p["w"]) if turn and not p.get("poly") else (p["w"], p["h"])
438
+ out.append(box(x - 0.4 * w, y - 0.4 * h, 0, x + 0.4 * w, y + 0.4 * h, 0.1, f"pin {p['num']}", COL["metal"]))
439
+ out.append(box(x0, y0, 0.1, x1, y1, H - MARK_T, "body", COL["led"] if spec["package_type"] == "connector" else COL["body"]))
440
+ p1 = next((p for p in pads if p["num"] in ("1", "A1")), None)
441
+ if p1:
442
+ mx = min(max(p1["x"], x0 + 0.4), x1 - 0.4)
443
+ my = min(max(-p1["y"], y0 + 0.4), y1 - 0.4)
444
+ out.append(mark_dot(mx, my, 0.25, H - MARK_T))
445
+ return out
446
+
447
+
448
+ def _header_any(spec, H=None):
449
+ """pin_header specs from package_schema: SMD headers have their origin at the centre (footprint_adv), not pin 1."""
450
+ out = pin_header(dict(spec, cols=spec.get("cols", 1)), H)
451
+ if spec.get("mount") == "smd":
452
+ dx, dy = -(spec.get("cols", 1) - 1) * spec["pitch"] / 2, (spec["rows"] - 1) * spec["pitch"] / 2
453
+ for so in out: # SMD: no holes in the board, so the posts stop at the board surface
454
+ so.verts = [(x + dx, y + dy, max(z, 0.0) if so.name.startswith("pin ") else z) for x, y, z in so.verts]
455
+ from .footprint_adv import _header
456
+ posts = {so.name: so for so in out if so.name.startswith("pin ")}
457
+ for p in _header(spec, spec.get("density", "B"))[2]: # a foot from each post out to its land
458
+ so = posts.get(f"pin {p['num']}")
459
+ if so is None:
460
+ continue
461
+ xs, ys = [v[0] for v in so.verts], [v[1] for v in so.verts]
462
+ a, px, py = max(xs) - min(xs), (min(xs) + max(xs)) / 2, -p["y"]
463
+ end = p["x"] + (p["w"] / 2 - 0.05) * (1 if p["x"] >= px else -1)
464
+ out.append(box(min(px, end), py - a / 2, 0.0, max(px, end), py + a / 2, a / 2, f"foot {p['num']}", COL["gold"]))
465
+ return out
466
+
467
+
468
+ BUILDERS = {"chip": chip, "molded": molded, "gullwing": gullwing, "qfp": qfp, "qfn": qfn,
469
+ "pin_header": _header_any, "radial": radial, "axial": axial, "bga": bga, "connector": connector, "custom": connector}
470
+
471
+
472
+ # ---------------------------------------------------------------- footprint names -> spec
473
+ def _d(v):
474
+ return {"nom": v}
475
+
476
+
477
+ def _chip_spec(name, kind, code):
478
+ L, W = int(code[:2]) / 10, int(code[2:]) / 10
479
+ T = round(min(0.6, max(0.1, 0.2 * L)), 2)
480
+ if kind == "LED":
481
+ return {"name": name, "package_type": "chip", "pin_count": 2, "body_length": _d(L), "body_width": _d(W),
482
+ "terminal_length": _d(T), "polarized": True}
483
+ return {"name": name, "package_type": "chip", "pin_count": 2, "body_length": _d(L), "body_width": _d(W), "terminal_length": _d(T)}
484
+
485
+
486
+ # common packages that have no file in package_specs (typical JEDEC outlines)
487
+ NAMED = {
488
+ "D_SMC": {"package_type": "molded", "body_length": _d(7.0), "body_width": _d(5.9), "terminal_width": _d(3.1), "terminal_length": _d(1.1),
489
+ "polarized": True},
490
+ "D_SOD-523": {"package_type": "gullwing", "lead_span": _d(1.6), "body_width": _d(1.2), "body_length": _d(0.8), "terminal_width": _d(0.3),
491
+ "terminal_length": _d(0.2), "lead_type": "flat", "polarized": True,
492
+ "pin_positions": [{"number": "1", "side": "L", "offset": 0}, {"number": "2", "side": "R", "offset": 0}]},
493
+ "SOT-23-6": {"package_type": "gullwing", "lead_span": _d(2.8), "body_width": _d(1.6), "body_length": _d(2.9), "terminal_width": _d(0.4),
494
+ "terminal_length": _d(0.45), "pitch": 0.95, "pin_count": 6},
495
+ "SOT-323_SC-70": {"package_type": "gullwing", "lead_span": _d(2.1), "body_width": _d(1.25), "body_length": _d(2.0), "terminal_width": _d(0.3),
496
+ "terminal_length": _d(0.3), "pin_positions": [{"number": "1", "side": "L", "offset": -0.65},
497
+ {"number": "2", "side": "L", "offset": 0.65}, {"number": "3", "side": "R", "offset": 0}]},
498
+ "SOT-363_SC-70-6": {"package_type": "gullwing", "lead_span": _d(2.1), "body_width": _d(1.25), "body_length": _d(2.0),
499
+ "terminal_width": _d(0.25), "terminal_length": _d(0.3), "pitch": 0.65, "pin_count": 6},
500
+ "SOT-89-3": {"package_type": "gullwing", "lead_span": _d(4.1), "body_width": _d(2.5), "body_length": _d(4.5), "terminal_width": _d(0.48),
501
+ "terminal_length": _d(0.9), "lead_type": "flat",
502
+ "pin_positions": [{"number": "1", "side": "L", "offset": -1.5}, {"number": "2", "side": "L", "offset": 0},
503
+ {"number": "3", "side": "L", "offset": 1.5}],
504
+ "tab": {"number": "2", "side": "R", "offset": 0, "width": _d(1.7)}},
505
+ }
506
+ GW_SPAN = {3.9: 6.0, 7.5: 10.3, 5.3: 7.8, 4.4: 6.4, 3.0: 4.9, 6.1: 8.1} # body width E1 -> lead span E
507
+ AXIAL = {"DO-35": (4.0, 2.0), "DO-41": (5.2, 2.7), "DO-201AD": (9.5, 5.3), "DO-201": (9.5, 5.3), "DO-15": (7.6, 3.6)}
508
+
509
+
510
+ def spec_from_name(name):
511
+ """Footprint name (KiCad style, `Lib:` prefix allowed) -> spec dict, or None when the family is unknown.
512
+ A file in package_specs wins; otherwise the dimensions come from the name and typical JEDEC/EIA outlines."""
513
+ name = name.split(":")[-1]
514
+ f = SPECS / f"{name}.json"
515
+ if f.exists():
516
+ return json.loads(f.read_text())
517
+ key = name if name in NAMED else name[2:] if name.startswith("D_") and name[2:] in NAMED else None
518
+ if key:
519
+ s = dict(NAMED[key], name=name)
520
+ s.setdefault("pin_count", len(s.get("pin_positions", [])) + (1 if s.get("tab") else 0) or 2)
521
+ return s
522
+ m = re.match(r"(R|C|L|LED|D|F|Fuse)_(\d{4})_(\d{4})Metric", name)
523
+ if m:
524
+ return _chip_spec(name, m.group(1), m.group(3))
525
+ m = re.match(r"CP_EIA-(\d{4})-(\d{2})", name)
526
+ if m:
527
+ L, W = int(m.group(1)[:2]) / 10, int(m.group(1)[2:]) / 10
528
+ return {"name": name, "package_type": "molded", "pin_count": 2, "body_length": _d(L), "body_width": _d(W),
529
+ "terminal_width": _d(round(0.7 * W, 2)), "terminal_length": _d(round(min(1.3, 0.25 * L), 2)), "polarized": True,
530
+ "height_max": int(m.group(2)) / 10}
531
+ m = re.match(r"(SOIC|SO|SOP|SSOP|TSSOP|MSOP|VSSOP|HTSSOP|TSOP|QSOP)-(\d+)(?:-1EP)?_([\d.]+)x([\d.]+)mm_P([\d.]+)mm", name)
532
+ if m:
533
+ n, E1, D, e = int(m.group(2)), float(m.group(3)), float(m.group(4)), float(m.group(5))
534
+ return {"name": name, "package_type": "gullwing", "pin_count": n, "pitch": e, "lead_span": _d(GW_SPAN.get(E1, E1 + 2.0)),
535
+ "body_width": _d(E1), "body_length": _d(D), "terminal_width": _d(round(min(0.42, 0.33 * e), 3)),
536
+ "terminal_length": _d(0.8 if e >= 1.0 else 0.6)}
537
+ m = re.match(r"([A-Z]*(?:QFN|DFN|SON))-(\d+)(?:-\d*EP)?_([\d.]+)x([\d.]+)mm_P([\d.]+)mm(?:_EP([\d.]+)x([\d.]+)mm)?", name)
538
+ if m:
539
+ n, Bx, By, e = int(m.group(2)), float(m.group(3)), float(m.group(4)), float(m.group(5))
540
+ s = {"name": name, "package_type": "qfn", "pin_count": n, "pitch": e, "body_length": _d(Bx), "body_width": _d(By),
541
+ "terminal_width": _d(round(min(0.3, 0.5 * e), 3)), "terminal_length": _d(0.4)}
542
+ if "QFN" not in m.group(1):
543
+ s.update(pins_left_right=n // 2, pins_top_bottom=0)
544
+ if m.group(6):
545
+ s["exposed_pad"] = {"length": _d(float(m.group(6))), "width": _d(float(m.group(7)))}
546
+ return s
547
+ m = re.match(r"([A-Z]*QFP)-(\d+)(?:-1EP)?_([\d.]+)x([\d.]+)mm_P([\d.]+)mm", name)
548
+ if m:
549
+ n, Bx, By, e = int(m.group(2)), float(m.group(3)), float(m.group(4)), float(m.group(5))
550
+ return {"name": name, "package_type": "qfp", "pin_count": n, "pitch": e, "body_length": _d(Bx), "body_width": _d(By),
551
+ "lead_span": _d(Bx + 2.0), "lead_span_y": _d(By + 2.0), "terminal_width": _d(round(min(0.37, 0.45 * e), 3)),
552
+ "terminal_length": _d(0.6)}
553
+ m = re.match(r"PinHeader_(\d+)x(\d+)_P([\d.]+)mm_Vertical$", name)
554
+ if m:
555
+ return {"name": name, "package_type": "pin_header", "cols": int(m.group(1)), "rows": int(m.group(2)), "pitch": float(m.group(3))}
556
+ m = re.match(r"(CP|C)_Radial_D([\d.]+)mm(?:_H([\d.]+)mm)?_P([\d.]+)mm", name)
557
+ if m:
558
+ D = float(m.group(2))
559
+ H = float(m.group(3)) if m.group(3) else 11.0 if D <= 6.3 else 11.5 if D <= 8 else 16.0 if D <= 10 else 20.0 if D <= 13 else 25.0
560
+ return {"name": name, "package_type": "radial", "diameter": D, "pitch": float(m.group(4)), "height_max": H,
561
+ "polarized": m.group(1) == "CP"}
562
+ m = re.match(r"(R|C|L|D)_Axial_.*?_L([\d.]+)mm_D([\d.]+)mm_P([\d.]+)mm_Horizontal", name) or \
563
+ re.match(r"(D)_(DO-[\dA-Z]+)\w*?_P([\d.]+)mm_Horizontal", name)
564
+ if m:
565
+ if len(m.groups()) == 4:
566
+ L, D, P = float(m.group(2)), float(m.group(3)), float(m.group(4))
567
+ else:
568
+ key = next((k for k in sorted(AXIAL, key=len, reverse=True) if m.group(2).startswith(k)), None)
569
+ if not key:
570
+ return None
571
+ (L, D), P = AXIAL[key], float(m.group(3))
572
+ return {"name": name, "package_type": "axial", "length": L, "diameter": D, "pitch": P, "polarized": m.group(1) == "D"}
573
+ return None
574
+
575
+
576
+ def default_height(spec):
577
+ if spec.get("height_max"):
578
+ return float(spec["height_max"])
579
+ if spec["package_type"] == "pin_header":
580
+ return {2.54: 8.5, 2.0: 6.0, 1.27: 4.0}.get(round(spec["pitch"], 2), 3 * spec["pitch"])
581
+ if spec["package_type"] == "axial":
582
+ return spec["diameter"]
583
+ m = re.match(r"CP_EIA-\d{4}-(\d{2})", spec["name"])
584
+ if m:
585
+ return int(m.group(1)) / 10
586
+ h = next((v for rx, v in HEIGHT if re.search(rx, spec["name"])), None)
587
+ if h is None:
588
+ from .release_check import height_range
589
+ lo, hi, rx = height_range(spec["name"])
590
+ h = round((lo + hi) / 2, 2) if rx else None
591
+ return h
592
+
593
+
594
+ # ---------------------------------------------------------------- STEP writer
595
+ def _r(x):
596
+ s = f"{x:.6f}".rstrip("0")
597
+ return "0." if s in ("-0.", "0.") else s
598
+
599
+
600
+ def _s(text):
601
+ return "'" + str(text).replace("'", "''").encode("ascii", "replace").decode() + "'"
602
+
603
+
604
+ def write_step(solids, name, description="ecadlib generated package model"):
605
+ """Solids -> STEP AP214 text: one product, one ADVANCED_BREP_SHAPE_REPRESENTATION, colours per solid."""
606
+ ents = []
607
+ def add(s):
608
+ ents.append(s); return len(ents)
609
+ dirs = {}
610
+ def direction(v):
611
+ k = tuple(round(c, 9) + 0.0 for c in v)
612
+ if k not in dirs:
613
+ dirs[k] = add(f"DIRECTION('',({','.join(_r(c) for c in k)}))")
614
+ return dirs[k]
615
+ app = add("APPLICATION_CONTEXT('core data for automotive mechanical design processes')")
616
+ add(f"APPLICATION_PROTOCOL_DEFINITION('international standard','automotive_design',2000,#{app})")
617
+ pctx = add(f"PRODUCT_CONTEXT('',#{app},'mechanical')")
618
+ prod = add(f"PRODUCT({_s(name)},{_s(name)},{_s(description)},(#{pctx}))")
619
+ add(f"PRODUCT_RELATED_PRODUCT_CATEGORY('part',$,(#{prod}))")
620
+ pdf = add(f"PRODUCT_DEFINITION_FORMATION('','',#{prod})")
621
+ pdc = add(f"PRODUCT_DEFINITION_CONTEXT('part definition',#{app},'design')")
622
+ pd = add(f"PRODUCT_DEFINITION('design','',#{pdf},#{pdc})")
623
+ pds = add(f"PRODUCT_DEFINITION_SHAPE('','',#{pd})")
624
+ mm = add("( LENGTH_UNIT() NAMED_UNIT(*) SI_UNIT(.MILLI.,.METRE.) )")
625
+ rad = add("( NAMED_UNIT(*) PLANE_ANGLE_UNIT() SI_UNIT($,.RADIAN.) )")
626
+ sr = add("( NAMED_UNIT(*) SI_UNIT($,.STERADIAN.) SOLID_ANGLE_UNIT() )")
627
+ unc = add(f"UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-07),#{mm},'distance_accuracy_value','confusion accuracy')")
628
+ ctx = add(f"( GEOMETRIC_REPRESENTATION_CONTEXT(3) GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#{unc})) "
629
+ f"GLOBAL_UNIT_ASSIGNED_CONTEXT((#{mm},#{rad},#{sr})) REPRESENTATION_CONTEXT('Context #1','3D Context with UNIT and UNCERTAINTY') )")
630
+ o = add("CARTESIAN_POINT('',(0.,0.,0.))")
631
+ ax0 = add(f"AXIS2_PLACEMENT_3D('',#{o},#{direction((0, 0, 1))},#{direction((1, 0, 0))})")
632
+ breps, styled, styles = [], [], {}
633
+ for sol in solids:
634
+ cps = [add(f"CARTESIAN_POINT('',({_r(x)},{_r(y)},{_r(z)}))") for x, y, z in sol.verts]
635
+ vps = [add(f"VERTEX_POINT('',#{c})") for c in cps]
636
+ edges, faces = {}, []
637
+ def edge(a, b):
638
+ k = (min(a, b), max(a, b))
639
+ if k not in edges:
640
+ d = _sub(sol.verts[k[1]], sol.verts[k[0]])
641
+ vec = add(f"VECTOR('',#{direction(_unit(d))},{_r(_len(d))})")
642
+ ln = add(f"LINE('',#{cps[k[0]]},#{vec})")
643
+ edges[k] = add(f"EDGE_CURVE('',#{vps[k[0]]},#{vps[k[1]]},#{ln},.T.)")
644
+ return edges[k], ".T." if a < b else ".F."
645
+ for loops in sol.faces:
646
+ bounds = []
647
+ for j, lp in enumerate(loops):
648
+ oes = [add("ORIENTED_EDGE('',*,*,#%d,%s)" % edge(a, b)) for a, b in zip(lp, lp[1:] + lp[:1])]
649
+ el = add(f"EDGE_LOOP('',({','.join(f'#{x}' for x in oes)}))")
650
+ bounds.append(add(f"{'FACE_OUTER_BOUND' if j == 0 else 'FACE_BOUND'}('',#{el},.T.)"))
651
+ outer = [sol.verts[i] for i in loops[0]]
652
+ nrm = _unit(_newell(outer))
653
+ ref = _unit(_sub(outer[1], outer[0]))
654
+ ax = add(f"AXIS2_PLACEMENT_3D('',#{cps[loops[0][0]]},#{direction(nrm)},#{direction(ref)})")
655
+ pl = add(f"PLANE('',#{ax})")
656
+ faces.append(add(f"ADVANCED_FACE('',({','.join(f'#{x}' for x in bounds)}),#{pl},.T.)"))
657
+ shell = add(f"CLOSED_SHELL('',({','.join(f'#{x}' for x in faces)}))")
658
+ breps.append(add(f"MANIFOLD_SOLID_BREP({_s(sol.name)},#{shell})"))
659
+ if sol.color not in styles:
660
+ rgb = add(f"COLOUR_RGB('',{','.join(_r(c) for c in sol.color)})")
661
+ fasc = add(f"FILL_AREA_STYLE_COLOUR('',#{rgb})")
662
+ fas = add(f"FILL_AREA_STYLE('',(#{fasc}))")
663
+ ssfa = add(f"SURFACE_STYLE_FILL_AREA(#{fas})")
664
+ sss = add(f"SURFACE_SIDE_STYLE('',(#{ssfa}))")
665
+ ssu = add(f"SURFACE_STYLE_USAGE(.BOTH.,#{sss})")
666
+ styles[sol.color] = add(f"PRESENTATION_STYLE_ASSIGNMENT((#{ssu}))")
667
+ styled.append(add(f"STYLED_ITEM('color',(#{styles[sol.color]}),#{breps[-1]})"))
668
+ rep = add(f"ADVANCED_BREP_SHAPE_REPRESENTATION({_s(name)},(#{ax0},{','.join(f'#{b}' for b in breps)}),#{ctx})")
669
+ add(f"SHAPE_DEFINITION_REPRESENTATION(#{pds},#{rep})")
670
+ add(f"MECHANICAL_DESIGN_GEOMETRIC_PRESENTATION_REPRESENTATION('',({','.join(f'#{s}' for s in styled)}),#{ctx})")
671
+ head = ["ISO-10303-21;", "HEADER;", f"FILE_DESCRIPTION(({_s(description)}),'2;1');",
672
+ f"FILE_NAME({_s(name + '.step')},'2026-01-01T00:00:00',('ecadlib'),('ecadlib'),'ecadlib step_gen','ecadlib step_gen','');",
673
+ "FILE_SCHEMA(('AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }'));", "ENDSEC;", "DATA;"]
674
+ return "\n".join(head + [f"#{i + 1}={e};" for i, e in enumerate(ents)] + ["ENDSEC;", "END-ISO-10303-21;", ""])
675
+
676
+
677
+ # ---------------------------------------------------------------- public entry points
678
+ def build(spec, height=None):
679
+ """spec dict -> (solids, info). info: family, height, height_src."""
680
+ t = spec["package_type"]
681
+ if t not in BUILDERS:
682
+ raise ValueError(f"no 3D builder for package_type {t!r}")
683
+ H, src = (float(height), "caller") if height else (default_height(spec), "spec/table")
684
+ if not H:
685
+ raise ValueError(f"{spec['name']}: no height (no height_max, no table entry); pass a height")
686
+ solids = BUILDERS[t](spec, H)
687
+ return solids, {"family": t, "height": H, "height_src": src, "solids": len(solids)}
688
+
689
+
690
+ def generate(src, height=None, name=None):
691
+ """spec dict, spec .json path or footprint name -> (STEP text, info). Raises ValueError when the
692
+ footprint family is unknown."""
693
+ if isinstance(src, dict):
694
+ spec = src
695
+ elif str(src).endswith(".json") and Path(src).exists():
696
+ spec = json.loads(Path(src).read_text())
697
+ else:
698
+ spec = spec_from_name(str(src))
699
+ if spec is None:
700
+ raise ValueError(f"{src}: no package spec and the footprint name is not a known package family")
701
+ try:
702
+ solids, info = build(spec, height)
703
+ info["name"] = name or spec["name"]
704
+ text = write_step(solids, info["name"]) # zero-size dimensions fail here (zero-length edge)
705
+ except (KeyError, TypeError, IndexError, ZeroDivisionError) as e: # incomplete spec: no model, not a crash
706
+ raise ValueError(f"{spec.get('name', src)}: spec incomplete for a 3D model ({type(e).__name__}: {e})")
707
+ rep = check(text)
708
+ if rep["errors"]:
709
+ raise ValueError(f"{info['name']}: generated STEP failed its own check: {rep['errors'][:3]}")
710
+ info.update(bbox=rep["bbox"], volume=rep["volume"], bytes=len(text))
711
+ return text, info
712
+
713
+
714
+ # ---------------------------------------------------------------- STEP reader / checker
715
+ TOK = re.compile(r"\s*(?:(#\d+)|('(?:[^']|'')*')|(\.[A-Z_0-9]+\.)|([-+]?(?:\d+\.?\d*(?:[Ee][-+]?\d+)?|\.\d+(?:[Ee][-+]?\d+)?))|"
716
+ r"([A-Za-z_][A-Za-z_0-9]*)|([()=,;$*]))")
717
+
718
+
719
+ def parse(text):
720
+ """STEP DATA section -> {id: (TYPE, args)}; complex entities -> ('COMPLEX', [(TYPE, args), ...]).
721
+ args: nested lists; ('ref', n), str (quoted strings keep their quotes), float, ('enum', X), None for $ and *."""
722
+ m = re.search(r"\bDATA\s*;(.*?)\bENDSEC\s*;", text, re.S)
723
+ if not m:
724
+ raise ValueError("no DATA section")
725
+ data = re.sub(r"/\*.*?\*/", "", m.group(1), flags=re.S)
726
+ toks, pos = [], 0
727
+ while pos < len(data):
728
+ mt = TOK.match(data, pos)
729
+ if not mt or mt.end() == pos:
730
+ if data[pos:].strip():
731
+ raise ValueError(f"bad token at {data[pos:pos + 30]!r}")
732
+ break
733
+ pos = mt.end()
734
+ ref, st, en, num, kw, p = mt.groups()
735
+ toks.append(("ref", int(ref[1:])) if ref else ("str", st) if st else ("enum", en[1:-1]) if en else
736
+ ("num", float(num)) if num else ("kw", kw) if kw else ("p", p))
737
+ i = 0
738
+ def args():
739
+ nonlocal i
740
+ out = [] # toks[i] is '('
741
+ i += 1
742
+ while toks[i] != ("p", ")"):
743
+ k, v = toks[i]
744
+ if (k, v) == ("p", ","):
745
+ i += 1; continue
746
+ if (k, v) == ("p", "("):
747
+ out.append(args()); continue
748
+ if k == "kw" and toks[i + 1] == ("p", "("):
749
+ i += 1; out.append(("typed", v, args())); continue
750
+ out.append(("ref", v) if k == "ref" else ("enum", v) if k == "enum" else None if k == "p" else v)
751
+ i += 1
752
+ i += 1
753
+ return out
754
+ ents = {}
755
+ while i < len(toks):
756
+ k, v = toks[i]
757
+ if k != "ref" or toks[i + 1] != ("p", "="):
758
+ raise ValueError(f"expected '#n =' at token {i}: {toks[i:i + 3]}")
759
+ eid, i = v, i + 2
760
+ if toks[i] == ("p", "("): # complex entity: ( A(..) B(..) )
761
+ i += 1; parts = []
762
+ while toks[i] != ("p", ")"):
763
+ kw = toks[i][1]; i += 1
764
+ parts.append((kw, args()))
765
+ i += 1
766
+ ents[eid] = ("COMPLEX", parts)
767
+ else:
768
+ kw = toks[i][1]; i += 1
769
+ ents[eid] = (kw, args())
770
+ if toks[i] != ("p", ";"):
771
+ raise ValueError(f"#{eid}: expected ';'")
772
+ i += 1
773
+ return ents
774
+
775
+
776
+ def _refs(a):
777
+ if isinstance(a, tuple) and a and a[0] == "ref":
778
+ yield a[1]
779
+ elif isinstance(a, tuple) and a and a[0] == "typed":
780
+ yield from _refs(a[2])
781
+ elif isinstance(a, list):
782
+ for x in a:
783
+ yield from _refs(x)
784
+
785
+
786
+ def check(text, tol=1e-6):
787
+ """Validate a STEP file's B-rep (see module docstring) -> {errors, solids, bbox, volume, unstyled, ...}."""
788
+ errs = []
789
+ try:
790
+ E = parse(text)
791
+ except (ValueError, IndexError) as e:
792
+ return {"errors": [f"parse: {e}"], "solids": 0, "bbox": None, "volume": 0.0, "unstyled": [], "entities": 0}
793
+ for eid, (t, a) in E.items():
794
+ for r in _refs(a):
795
+ if r not in E:
796
+ errs.append(f"#{eid} {t} refers to missing #{r}")
797
+ if errs:
798
+ return {"errors": errs, "solids": 0, "bbox": None, "volume": 0.0, "unstyled": [], "entities": len(E)}
799
+ try:
800
+ return _check_breps(E, tol)
801
+ except (KeyError, IndexError, TypeError, ValueError, ZeroDivisionError) as e: # wrong entity types / arity
802
+ return {"errors": [f"malformed B-rep: {type(e).__name__}: {e}"], "solids": 0, "bbox": None, "volume": 0.0,
803
+ "unstyled": [], "entities": len(E)}
804
+
805
+
806
+ def _check_breps(E, tol):
807
+ errs = []
808
+ ref = lambda x: E[x[1]]
809
+ pt = lambda r: tuple(E[r][1][1]) # CARTESIAN_POINT id -> xyz
810
+ vtx = lambda r: pt(E[r][1][1][1]) # VERTEX_POINT id -> xyz
811
+ def dirv(r):
812
+ t, a = E[r]
813
+ return tuple(a[1])
814
+ breps = [k for k, (t, a) in E.items() if t in ("MANIFOLD_SOLID_BREP", "BREP_WITH_VOIDS")]
815
+ styled = {r for k, (t, a) in E.items() if t in ("STYLED_ITEM", "OVER_RIDING_STYLED_ITEM") for r in [a[2][1]] if isinstance(a[2], tuple)}
816
+ allp, vols, per = [], [], []
817
+ for b in breps:
818
+ sh = E[b][1][1][1]
819
+ if E[sh][0] != "CLOSED_SHELL":
820
+ errs.append(f"#{b}: shell #{sh} is {E[sh][0]}"); continue
821
+ use, vol, pts = {}, 0.0, []
822
+ for fr in E[sh][1][1]:
823
+ fid = fr[1]
824
+ ft, fa = E[fid]
825
+ if ft not in ("ADVANCED_FACE", "FACE_SURFACE"):
826
+ errs.append(f"#{fid}: face type {ft}"); continue
827
+ surf = E[fa[2][1]]
828
+ sense = fa[3] == ("enum", "T")
829
+ plane = None
830
+ if surf[0] == "PLANE":
831
+ ax = E[surf[1][1][1]][1]
832
+ plane = (pt(ax[1][1]), _unit(dirv(ax[2][1])))
833
+ area = (0.0, 0.0, 0.0)
834
+ p0 = None
835
+ for bi, br in enumerate(fa[1]):
836
+ bt, ba = E[br[1]]
837
+ lt, la = E[ba[1][1]]
838
+ if lt != "EDGE_LOOP":
839
+ if lt == "VERTEX_LOOP":
840
+ continue
841
+ errs.append(f"#{br[1]}: loop type {lt}"); continue
842
+ seq = []
843
+ for oe in la[1]:
844
+ ot, oa = E[oe[1]]
845
+ ec = oa[3][1]
846
+ et, ea = E[ec]
847
+ v1, v2 = ea[1][1], ea[2][1]
848
+ fwd = oa[4] == ("enum", "T")
849
+ s, e = (v1, v2) if oa[4] == ("enum", "T") else (v2, v1)
850
+ seq.append((s, e))
851
+ use.setdefault(ec, []).append((fid, fwd))
852
+ g = E[ea[3][1]]
853
+ if g[0] == "LINE":
854
+ lp, d = pt(g[1][1][1]), _unit(dirv(E[g[1][2][1]][1][1][1]))
855
+ for v in (vtx(v1), vtx(v2)):
856
+ if _len(_cross(_sub(v, lp), d)) > 1e-5:
857
+ errs.append(f"#{ec}: vertex {v} not on its LINE"); break
858
+ for (s1, e1), (s2, e2) in zip(seq, seq[1:] + seq[:1]):
859
+ if e1 != s2:
860
+ errs.append(f"#{ba[1][1]}: loop not connected (#{e1} -> #{s2})"); break
861
+ poly = [vtx(s) for s, _ in seq]
862
+ if bt == "FACE_BOUND" and ba[2] == ("enum", "F") or bt == "FACE_OUTER_BOUND" and ba[2] == ("enum", "F"):
863
+ poly = poly[::-1]
864
+ pts += poly
865
+ nw = _newell(poly)
866
+ area = _add(area, nw)
867
+ p0 = p0 or poly[0]
868
+ if plane:
869
+ for v in poly:
870
+ if abs(_dot(_sub(v, plane[0]), plane[1])) > 1e-5:
871
+ errs.append(f"#{fid}: vertex {v} off its plane"); break
872
+ if bi == 0 and _dot(nw, plane[1]) * (1 if sense else -1) <= 0:
873
+ errs.append(f"#{fid}: outer loop runs the wrong way round the face normal")
874
+ if p0 is not None:
875
+ vol += _dot(p0, area) / 6.0 # Newell vector = 2 x area vector
876
+ for ec, us in use.items():
877
+ if len(us) != 2:
878
+ errs.append(f"#{b}: edge #{ec} used by {len(us)} face(s), not 2 (shell not closed)")
879
+ elif us[0][1] == us[1][1]:
880
+ errs.append(f"#{b}: edge #{ec} used twice in the same direction (inconsistent orientation)")
881
+ if vol <= tol:
882
+ errs.append(f"#{b} {E[b][1][0]}: volume {vol:.6g} is not positive")
883
+ vols.append(vol); allp += pts
884
+ per.append({"id": b, "name": E[b][1][0].strip("'"), "volume": round(vol, 6), "faces": len(E[sh][1][1]),
885
+ "styled": b in styled})
886
+ if not breps:
887
+ errs.append("no MANIFOLD_SOLID_BREP")
888
+ bbox = tuple(round(min(p[i] for p in allp), 6) for i in range(3)) + tuple(round(max(p[i] for p in allp), 6) for i in range(3)) if allp else None
889
+ return {"errors": errs, "solids": len(breps), "bbox": bbox, "volume": round(sum(vols), 6), "entities": len(E),
890
+ "unstyled": [s["name"] for s in per if not s["styled"]], "per_solid": per}
891
+
892
+
893
+ # ---------------------------------------------------------------- CLI
894
+ def main(argv=None):
895
+ ap = argparse.ArgumentParser(description="Generate a package-shaped STEP model, or check STEP files")
896
+ ap.add_argument("src", nargs="+", help="package spec .json, footprint name (e.g. SOIC-8_3.9x4.9mm_P1.27mm), or with --check STEP files")
897
+ ap.add_argument("-o", "--out", help="output .step (default <name>.step)")
898
+ ap.add_argument("--height", type=float, help="overall height, mm (default: spec height_max, else a per-package table)")
899
+ ap.add_argument("--check", action="store_true", help="validate the given STEP files instead")
900
+ a = ap.parse_args(argv)
901
+ if a.check:
902
+ bad = 0
903
+ for f in a.src:
904
+ r = check(Path(f).read_text(errors="replace"))
905
+ bb = " x ".join(f"{r['bbox'][i + 3] - r['bbox'][i]:.3f}" for i in range(3)) if r["bbox"] else "-"
906
+ print(f"{'ok ' if not r['errors'] else 'FAIL'} {f}: {r['solids']} solids, volume {r['volume']:.4g} mm3, bbox {bb} mm "
907
+ f"{r['bbox']}" + (f", no colour: {r['unstyled']}" if r["unstyled"] else ""))
908
+ for e in r["errors"][:20]:
909
+ print(f" {e}")
910
+ bad += bool(r["errors"])
911
+ return 1 if bad else 0
912
+ if len(a.src) != 1:
913
+ ap.error("give one spec or footprint name (or --check FILES)")
914
+ try:
915
+ text, info = generate(a.src[0], a.height)
916
+ except ValueError as e:
917
+ print(f"step_gen: {e}", file=sys.stderr)
918
+ return 2
919
+ out = Path(a.out or f"{info['name']}.step")
920
+ out.parent.mkdir(parents=True, exist_ok=True)
921
+ out.write_text(text)
922
+ bb = info["bbox"]
923
+ print(f"{info['name']}: {info['family']}, {info['solids']} solids, height {info['height']} mm ({info['height_src']}), "
924
+ f"bbox x {bb[0]:.3f}..{bb[3]:.3f} y {bb[1]:.3f}..{bb[4]:.3f} z {bb[2]:.3f}..{bb[5]:.3f}, {info['bytes'] // 1024} KiB -> {out}")
925
+ return 0
926
+
927
+
928
+ if __name__ == "__main__":
929
+ sys.exit(main())