# -*- coding: utf-8 -*- u"""把**封装自带、API 读不出来的钻孔**从内层铺铜的反焊盘里反解出来。 /c/Python314/python .claude/pcb2_npth.py # 打印 /c/Python314/python .claude/pcb2_npth.py --write # 存 _pcb2_npth.json(布线脚本读它) ## 为什么要这么绕 J23(USB-C 母座)的封装里有两个 **Ø0.60 的非金属化定位孔**。它们不是焊盘: `pcb_PrimitivePad.getAll()` 里没有,`pcb_PrimitiveRegion` / `pcb_PrimitiveFill` 是空的, `pcb_Primitive.getPrimitiveByPrimitiveId('e55')` 也拿不到 —— 封装内部的图元 API 不给。 唯一能看见它们的地方是 **DRC**(「挖槽区域 到 导线」)和**内层铺铜给它们让出来的反焊盘**。 ⇒ 本脚本把 Inner1/Inner2 的铺铜环读回来,找出里面的**圆弧**, 按「已知过孔的反焊盘半径 − 过孔半径」标定出铺铜净距,再反推出每个圆孔的真实半径; 最后把**对不上任何焊盘/过孔的那些圆**报出来 —— 那就是封装自带的孔。 2026-09-06 实测:J23 上两个 **Ø0.600 @ (213.660, 59.100) / (213.660, 64.900)**, 正好卡在 A1B12 / B1A12 两个 GND 焊盘的**外侧角上**, 所以「从 GND 焊盘往东拉一小段线打过孔」这条最自然的走法是走不通的。 DRC 报的「距离为 0mil」就是线正压在孔上。 ⚠️ 这套反推**只对圆孔成立**。槽孔(SLOT)的反焊盘是跑道形,两端的圆弧半径 等于槽半宽,会被当成两个小圆报出来 —— 所以下面按「圆心和已知焊盘中心对得上」 把它们滤掉,剩下的才当未知孔。 """ import json import math import os import sys HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) from eda import run import inner1_verify as IV import pcb2_route_dp as R OUT = os.path.join(HERE, '_pcb2_npth.json') MINPTS = 10 # 至少这么多个连续点拟合同一个圆才算一段弧 TOL = 0.010 # 拟合残差上限(mm) def circle3(a, b, c): u"""三点定圆;共线返回 None。""" ax, ay = a bx, by = b cx, cy = c d = 2.0 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by)) if abs(d) < 1e-9: return None ux = ((ax * ax + ay * ay) * (by - cy) + (bx * bx + by * by) * (cy - ay) + (cx * cx + cy * cy) * (ay - by)) / d uy = ((ax * ax + ay * ay) * (cx - bx) + (bx * bx + by * by) * (ax - cx) + (cx * cx + cy * cy) * (bx - ax)) / d return (ux, uy, math.hypot(ax - ux, ay - uy)) def arcs(poly): u"""从一个环里挑出所有圆弧段 -> [(cx, cy, r), ...](贪心地往后延伸)。""" out = [] n = len(poly) i = 0 while i < n - 2: c = circle3(poly[i], poly[i + 1], poly[i + 2]) if not c or c[2] > 5.0: i += 1 continue j = i + 3 while j < n and abs(math.hypot(poly[j][0] - c[0], poly[j][1] - c[1]) - c[2]) < TOL: j += 1 if j - i >= MINPTS: pts = poly[i:j] sx = sum(p[0] for p in pts) / len(pts) sy = sum(p[1] for p in pts) / len(pts) # 用整段重新拟一次圆心(三点法对噪声敏感) c2 = circle3(pts[0], pts[len(pts) // 2], pts[-1]) out.append(c2 or c) i = j else: i += 1 return out def main(): r = run(IV.JS, timeout=300) if not r.get('success'): sys.exit(u'读回铺铜失败: ' + json.dumps(r, ensure_ascii=False)[:400]) data = r['result'] found = [] for key, d in data.items(): if d['layer'] not in (15, 16): continue for rg in d['rings']: pl = IV.ring_to_poly(rg) if len(pl) >= 3: found += arcs(pl) # 去重(同一个孔在 Inner1 / Inner2 上各有一份) uniq = {} for cx, cy, rr in found: uniq[(round(cx, 2), round(cy, 2), round(rr, 2))] = (cx, cy, rr) circles = sorted(uniq.values(), key=lambda t: (t[1], t[0])) print(u'铺铜环里一共拟出 %d 段不重复的圆弧' % len(circles)) pads, vias, _lines = R.geom() # ── 标定铺铜净距:拿圆心和过孔中心对得上的那些 ── clr = [] for cx, cy, rr in circles: for v in vias: if math.hypot(cx - v['x'], cy - v['y']) < 0.02: clr.append(rr - v['r']) break if not clr: sys.exit(u'一个过孔的反焊盘都没认出来,标定不了净距') clr.sort() C = clr[len(clr) // 2] spread = clr[-1] - clr[0] print(u'反焊盘净距标定:%d 个过孔,中位数 %.4f mm(离散度 %.4f)' % (len(clr), C, spread)) if spread > 0.02: print(u' [!!] 离散度偏大,下面反推出来的孔径要打个问号') # ── 认领:圆心对得上某个过孔 / 某个带孔焊盘的,就不是未知孔 ── holed = [p for p in pads if p['lay'] == 12 or True] unknown = [] for cx, cy, rr in circles: if any(math.hypot(cx - v['x'], cy - v['y']) < 0.05 for v in vias): continue if any(math.hypot(cx - p['x'], cy - p['y']) < 0.05 for p in holed): continue # 槽孔两端的圆弧:圆心落在某个焊盘矩形里 if any(abs(cx - p['x']) <= p['hw'] + 1e-6 and abs(cy - p['y']) <= p['hh'] + 1e-6 for p in holed): continue hr = rr - C # 剩下的圆弧大多是**几何噪声**,两类: # · 铺铜多边形自己的凸角(铜面内缩 0.358 之后就是一段 r=0.358 的圆角)-> hr ≈ 0 # · 板框四角的 R5 -> hr ≈ 4.04 # 真正的孔在 0.1~3.0 mm 之间。 if 0.05 <= hr <= 1.5: unknown.append((cx, cy, hr)) print(u'') print(u'对不上任何焊盘/过孔、孔径又在 0.1~3.0 mm 的圆孔 %d 个' u'(= 封装自带、API 读不出来的那种):' % len(unknown)) for cx, cy, hr in unknown: print(u' (%8.3f, %8.3f) Ø%.3f mm' % (cx, cy, hr * 2)) if '--write' in sys.argv: json.dump([[round(cx, 4), round(cy, 4), round(hr, 4)] for cx, cy, hr in unknown], open(OUT, 'w', encoding='utf-8')) print(u'已写入 %s' % os.path.basename(OUT)) if __name__ == '__main__': main()