# -*- coding: utf-8 -*- u"""R2 刀片 PCB(M4):板框 + 分区参考 + 摆位常量。 /c/Python314/python .claude/r2/pcb_blade.py # 只算 + 自检 /c/Python314/python .claude/r2/pcb_blade.py --outline # 画/重画板框(幂等) /c/Python314/python .claude/r2/pcb_blade.py --verify # 读回校验板框 + 挖槽区域 /c/Python314/python .claude/r2/pcb_blade.py --slots # 只重画两条排线槽(layer 12 填充) ## 坐标系 **板左下角 = (0, 0),x 向右、y 向上**(EasyEDA PCB 里 y 向上为正,见方案 §22.5)。 这正好是方案 §3.1 那张分区图的坐标,图里的数可以直接抄。 ⛔ **2026-09-11 用户拍板:98×76 → 90×68**(方案 §34)。 ⛔⛔ **2026-09-11 晚 用户拍板:主板转 180°,两条排线槽挪到顶部**(方案 §35)。 接口边(J600 / J1401 / 侧键)朝**上**,服务带(C60 + 两条槽 + J3)在板子**顶部**, 手机远端边贴着金手指区上沿(y 11.0)。与手机绑定的一切(七个孔 / 两条槽 / J4 / J5 / K1~K4) 一律经 `phone_xy()` 从本体坐标 (u, v) 推,**不许写死、不许自己算旋转**。 ``` y 68.00 ┬──────────────────────────────────┬─────────────┐ │ 服务带 12.34 尾插槽 / C60 / 电池槽 │ 功率列 19.4 │ y 55.66 ┼──── 接口边(朝上)─────────────────┤ U3/Rs1 (顶) │ │ │ eFuse │ │ 小米8 主板 67.09 × 44.66(转 180)│ L / Buck │ │ │ Cin / F1(底)│ y 11.00 ┼──────────────────────────────────┴─────────────┤ │ 金手指区 11mm(槽体 ~9mm 要让开,这一带不许放件) │ y 0.00 ┴───────────────── 板边(卡边)──────────────────┘ x 0 1.5 ─── 手机主板 ─── 68.59 70.59 ─ 功率列 ─ 90 ``` ## 板框的底边是**断开**的 金手指的防呆缺口画在封装 `EDGE-64P-P1.00_PCIE-X4-GF` 的板框层里(方案 §22.3), 所以这里画的底边必须在缺口口部让开一段:断点 x 由 `fp_edge.notch_mouth()` + `J1_X` 算出, **不要手抄**。两段拼起来才是一个闭合板框;拼不上 DRC 会报开口。 ## ⛔ 不用 pcb_PrimitiveArc 实测(方案 §22.4):`pcb_PrimitiveArc.create()` 在**封装编辑器里画得出但读不回、删不掉**, 在 **PCB 文档里干脆什么都不画**(返回值仍然是成功的)。⇒ 四角 R2 用折线近似, 弦高误差 < 0.01 mm。 """ import io import json import math import os import sys sys.stdout.reconfigure(encoding='utf-8') HERE = os.path.dirname(os.path.abspath(__file__)) # ⚠ 顺序不能反:`.claude` 下有同名的 R1 脚本(pcb_pour / pcb_rules / pcb_place), # 先插 .claude 再插 HERE 的话,`import pcb_pour` 会拿到 R1 那一份, # 而且**不报错**,只在调到 R2 专有的函数时才炸。 sys.path.insert(0, os.path.dirname(HERE)) sys.path.insert(0, HERE) from eda import run # noqa: E402 import fp_edge # noqa: E402 # ⛔⛔ 2026-09-14 起脚本指向 **`BLADE-PCB_1`**(用户新布局,71×62、金手指居中)。 # 原 `BLADE-PCB`(3b9c4e39c8d164db,89.5×67,J1_X 45)已出 Gerber、冻结; # 它那一版的脚本与缓存存档在 `archive_blade_R_0914/`,要改它先把这几个常量换回去。 PCB = 'c575a2e854057614' # BLADE-PCB_1(R 版:'3b9c4e39c8d164db' BLADE-PCB) MIL = fp_edge.MIL # ── 板框(mm,出处 方案 §3.1)───────────────────────────────────────── BX0 = 0.5 # ⛔ 2026-09-14 晚:板框左沿 x(原点不再是板角)。70×58 时保持 J1_X=35.5 居中而不挪任何件/线 ⇒ 板框整体右移 0.5 BW, BH = 70.0, 55.5 # 2026-09-14 晚:主板南移 2.5 后 58 → 55.5; 2026-09-14 晚 用户要求 70×58(槽改顶边开口,见 TOP_NOTCH);此前 71×62;R 版 89.5×67。 2026-09-14 BLADE-PCB_1 按新摆位收(outline_blade1.py);R 版 89.5×67。 2026-09-11 由 98×76 压缩;2026-09-12 深夜 90×68 → 89.5×67(用户拍板) # ⛔ 2026-09-12 深夜这次只裁**东侧 0.5** 和**北侧 1.0**,器件 / 金手指 / 舌头一律不动: # · 南边不能裁 —— y=0 就是金手指所在的板边(指尖在 y=1.30),从南裁等于切金手指。 # · 西边不裁 —— 保持居中就得全板西移(899 段铜 + 346 孔 + 164 件),无现成工具、 # 还会作废一批 `_blade_*.json` 缓存;用户拍板改为接受偏心(现为 **0.25**,板宽的 0.3%)。 # · **东侧为什么只裁 0.5 而不是 1.0**:卡点是 `VO_P`(4 A 主通道,宽 1.283)那条铜的 # 东沿到 **88.391**。裁到 89.0 时铜面界 88.5,而 88.391 + 0.15 = 88.541 > 88.5 # ⇒ `INA_ALERT` 的贴边竖线在 y≈54.1~54.25 处**无解,收线宽也救不了**(0.279/0.20/0.15 全试过)。 # 唯一的外层出路是少裁 0.5:铜面界 89.0 时窗口 [88.68, 88.86] 宽 0.18,够走。 # 裁到 89.0 则必须把那段 12.4 mm 挪到 L15 内层(多 2 个换层孔 + 在 GND 内层切一道缝), # 而多裁的 0.5 mm 只多省 0.6% 面积 —— 用户拍板不拿地平面完整性换。 # · 67 零越界:北侧最高的 `J3.4` 焊盘顶在 66.400,新铜面边界 66.5 刚好让过(余 0.10)。 # 再往 66.5 走就开始撞 `J3` / `R63` / `EDL_EN`。 # ⚠ `IF_Y` 跟手机走(= PHONE_ORG[1]+PHONE_WH[1] = 55.66)、**不跟 BH**,所以改 BH 只是 # 把服务带从 12.34 压到 **11.34** —— 正好是 §34 算过的下限「C60 本体 11 + 0.34」, # 手机投影与两条排线槽都不受影响。再压就放不下 C60。 # ⚠ 判据教训:算东侧窗口时**只看过孔会得出错误结论**。第一版漏了 `VO_P` 那条 1.283 宽的铜 # (只按它的过孔东沿 88.055 算),得出「裁 1.0 可行」;按线段实际占用逐 y 重扫才发现无解。 CORNER_R = 2.0 # ── 金手指两侧的台阶(2026-09-12 用户指出「金手指两侧没有开槽,插不进插槽」)────────── # PCI-E 卡在金手指那一段是「凸」字形:只有舌头伸进插槽,两侧板材必须收到槽体顶面以上。 # 刀片原来是一整块 90×68 的圆角矩形,底边一条直线 ⇒ 塑料体(39 mm,两端封闭)会顶住 # x<27 / x>63 的板材,**插不到底**。 # 舌头原为 36.0(用户拍板,比金手指 33.70 两侧各多 1.15);台阶高 12.0(槽体 ~9,余 3)。 # ⛔ 2026-09-12 晚:**36.0 插不进去**。SolidWorks 量厂商 STEP 里 `PCIE-64P11L` 的 # 顶面内环(= 卡槽开口)得 **35.08**(x ±17.540,中间 0.78 是防呆键筋), # 36.0 比它宽 **0.92**,舌头两端会压在腔的端壁上 —— 这也解释了干涉扫描里 # 「刀片底边停在塑料体顶面、插入深度 d=0」的现象。⇒ 用户拍板收到 **34.5**。 # · 金手指铜 28.15~61.85(33.70 宽)⇒ 两侧余量 1.15 → **0.40**, # 仍大于 DRC 的板边规则 0.3(`pcb_rules` 只把 ≤0.2 的格子归一到 MINC, # 0.254/0.3 那几格是孔/板边相关、不动),可做。 # · 对插槽开口留 (35.08−34.5)/2 = **0.29/侧**。 # ⚠ 35.08 是**厂商 STEP 的标称值、不含公差**,待卡尺实测复核(PROGRESS 待办 ⭐①)。 # 内凹角必须倒圆角:外形铣刀下不去直角,取 R1.0。 # ⚠ 台阶 12.0 > 手机主板落点 y0 = 11.0 ⇒ 主板投影底边在舌头两侧**悬空 1.0 mm** # (左 x 1.50~27.00 / 右 x 63.00~68.59)。7 个定位孔最南 y=14.46 全在板材上、 # 主板靠 M1.6 尼龙立柱固定而不是靠边缘托住 ⇒ 无害,但 selfcheck 的分区断言要按舌头口径判。 TONGUE_X0, TONGUE_X1 = 18.25, 52.75 # 2026-09-14 跟 J1_X 35.5 居中(R 版 27.75 / 62.25)。 34.5 宽(2026-09-12 晚由 36.0 收窄,见上) # ⛔ 2026-09-12 深夜 用户拍板:台阶高 **12.0 → 9.9**(「让舌头变短」)。 # `STEP_H` 是**台阶切掉的高度**:y <= STEP_H 只有舌头(x TONGUE_X0~X1)有板材,y > STEP_H 是全宽板主体。 # ⇒ 降低它 = 舌头变短、两侧原本被切掉的部分**恢复成板材** ⇒ **板材反而多 115.5 mm²** # (两侧切掉 660.0 → 544.5),外接尺寸 89.5×67 不变。想减面积的话方向是反的,别搞错。 # ## 9.9 是下限,再低就出结构问题 # · **桥宽** = STEP_H − NOTCH_D = 9.9 − 8.3 = **1.60**:缺口从板边往上挖 8.3 深, # 缺口顶到台阶肩部这段板材是**舌头左右两半唯一的连接**。8.31 时桥宽只有 0.01 ⇒ 等于切断 # (那个数只保证「缺口不被切开」,没保证「缺口上方还有板材」—— 定档时踩过这个坑)。 # · **肩部余量** = STEP_H − 插入深度 8.00 = **1.90**(零件本地坐标:塑料体顶面 10.000 / 槽底 2.000)。 # 8.31 时只剩 0.31,太浅。⚠ 别拿底板坐标的 z=12.33 算这个数,那是另一套坐标系。 # · **丝印** 带上沿 9.40,离肩部正好 0.5(= silk_blade.EDGE)。 # ## 实判:丝印与 J1 位号都不会被铣掉 # 丝印 8.40~9.40 完全在舌头内;J1 位号 9.56~10.70 **跨过肩部线**,但下半在舌头上、 # 上半在全宽主体上,四角都有板材。⚠ `silk_blade` 会因「离轮廓 0」多报 1 条越框 —— # 那是判据保守(轮廓线穿过文字框),不是真悬空;J1 已在 KEEP 白名单,算法不会乱挪它。 STEP_H = 9.9 STEP_R = 1.0 # 台阶内凹角(铣刀半径,嘉立创外形最小 R0.5,取 1.0 稳) CORNER_SEG = 8 # 每个 R2 圆角用几段折线 OUTLINE_W = 0.15 # 金手指阵列中心。**居中**:刀片 98 宽、插槽体只有 39,居中让两侧悬臂等长 # (左边是手机主板、右边是功率列,两头都不轻)。⚠ 底板的 5 个插槽必须按同一个数摆。 # ⛔ 2026-09-12 深夜:原来是 `BW / 2.0`(跟着板宽居中)。板宽 90→89 之后那会算出 44.5, # **金手指连同舌头整体平移 0.5,全板器件与 64 根线立刻对不上**。这次是「只裁东侧、 # 器件不动」,所以把它**解耦成常数 45.0**(= 原来的 90/2,金手指绝对位置一点没变)。 # 代价是金手指不再严格居中:左 45.00 / 右 44.50,偏心 **0.25** = 板宽的 0.3%,用户已拍板接受。 # ⚠ 以后再改 BW,J1_X **不会**自动跟着走 —— 要居中就手动改这个数,并且记住那意味着全板平移。 BX1 = BX0 + BW # 板框右沿 x J1_X, J1_Y, J1_ROT = 35.5, 0.0, 0 # 2026-09-14 BLADE-PCB_1 居中 = BX0 + BW/2(R 版 45.0) # ── 小米8 主板的落点 + 定位孔(板级事实,摆位/布线/铺铜都从这里取)──────── # 主板 67.09 × 44.66,长边水平、**接口边(v=0,J600/J1401/侧键都在上面)朝下**。 # ⛔ 2026-09-11 晚:**主板转 180°**(接口边朝上,方案 §35)。`PHONE_ORG` 仍是手机投影矩形的 # **左下角**(板坐标),但本体 (u, v) → 板 (x, y) 要经 `phone_xy()`:u 从右往左、v 从上往下。 # y 的取法:手机远端边贴金手指区上沿 11.0(原来 C60 也是贴这条线放的,同一条禁布规则); # 接口边 = 11 + 44.66 = 55.66,上面 12.34 的服务带放 C60(本体 11)+ 两条槽 + J3。 # x 不变(1.5):H6/H7(u≈64.7)转过来落在左边,螺钉头离板边 2.2 − 1.6 = 0.6 以上。 # ⚠ SIM 卡托在 u 大的那一端(mi8_mech 注释:轮廓 70.3 含卡托 3.3)⇒ 转过来在**左边**, # 装着卡托的话会伸出板左沿 ~1.8 mm(机箱侧壁要让)。 # ⛔ 2026-09-14 晚 用户要求「主板南移,进一步缩小」:y 11.0 → **8.5**(南移 2.5 = 上限)。 # 上限来自插槽:刀片坐到槽底时塑料体顶面在刀片 y = 8.0(厂商 STEP 本地 z 10.000 − 槽底 2.000),塑料体沿 x 占 J1_X±19.5, # 正好在主板投影下 ⇒ 主板远端边留 0.5 到塑料体顶。台阶 STEP_H 9.9 以下舌头两侧没板材 ⇒ 主板那一段悬空 1.4(selfcheck 上限 1.5)。 PHONE_ORG = (1.5, 8.5) PHONE_WH = (67.09, 44.66) PHONE_ROT = 180 IF_Y = round(PHONE_ORG[1] + PHONE_WH[1], 3) # 接口边(v=0)在板上的 y:55.66 def phone_xy(u, v): u"""主板本体坐标 (u, v) → 刀片板坐标 (x, y)。**唯一的旋转实现**,别处不许自己算。 PHONE_ROT = 180:本体绕投影中心转半圈 ⇒ x = 右沿 − u,y = 上沿 − v(接口边 v=0 在上)。""" if PHONE_ROT == 180: return (round(PHONE_ORG[0] + PHONE_WH[0] - u, 3), round(PHONE_ORG[1] + PHONE_WH[1] - v, 3)) return (round(PHONE_ORG[0] + u, 3), round(PHONE_ORG[1] + v, 3)) # ⛔ **主板定位孔**。整个 §3.2 的高度预算 / §21.1 的槽距结论都建立在 # 「螺丝把主板压在刀片上、螺柱间隙 1.0 mm」(R1 §22.3e 的中框式焊盘)之上 —— # 没有这几个孔,`K1~K4` 那四个裸铜盘就没有压力,整套接触方式不成立。 # 孔位是实测值(`.claude/mi8_mech.json` 的 `holes`,u/v 本体坐标)。 # 直径沿用 R1 已经定稿的一组(`pcb2_place.py`): # 主板自己的孔 **Φ2.0**(用户卡尺)⇒ 配 **M1.6 尼龙立柱**(M2 螺杆塞 Φ2.0 没余量), # 刀片这一侧是螺钉过孔,取 M1.6 的通孔 **Φ1.8 NPTH**。 # ⚠ R1 载板上 `H5` 没打(撞 EDL 座),**刀片上七个孔全都够得开**(实测最近焊盘 3.06 mm # 是 H1 到 K2),所以七个全打。 HOLE_D = 1.8 HOLE_KEEP = 2.2 # 螺钉头避让(M1.6 尼龙螺钉头 Ø3.2 + 余量) HOLE_VIA_KEEP = 1.5 # 孔到过孔/铜的最小中心距(孔半径 0.9 + 过孔半径 0.3 + 净距) def phone_holes(): u"""{名字: (x, y)},刀片坐标。""" # ⛔ 2026-09-15 BLADE-PCB_1 新摆位:用户在板上挪过 H2 / H3(与 mi8_mech 推算的不再一致)⇒ 板面为准, # 有读回快照 `_blade_holes_live.json` 就用它。 live = os.path.join(HERE, '_blade_holes_live.json') if os.path.exists(live): return {k: tuple(v) for k, v in json.load(io.open(live, encoding='utf-8')).items()} m = json.load(io.open(os.path.join(os.path.dirname(HERE), 'mi8_mech.json'), encoding='utf-8')) return {k: phone_xy(u, v) for k, (u, v) in m['holes'].items()} # ── 排线穿板槽(2026-09-09 用户拍板:屏蔽窗去掉、器件全到 B 面、给两条排线开槽)── # ⛔ **屏蔽罩避让窗已删**。它是「屏蔽罩轮廓没实测所以全挖」的保守项,而 R1 §22.3e 的 # 定稿是「载板不挖空:用户实测贴合面元件全低于 1 mm」。去掉之后 L2 地平面整块、 # 板子恢复刚度,B 面手机投影 67×44 全部可放件(高度预算见 pcb_place.H_B_*)。 # # **排线走法**(照搬 R1 `pcb2_place.SLOTS`,2026-09-06 实测排线:电池 8×38、尾插 18×55): # J600/J1401 在主板朝上那一面 → 朝接口边(y=29)走 → 翻过板边落到刀片 A 面 # → 到接口边外侧 2.7 mm 的槽 → 穿板 → **U 形折回,贴着 B 面往 +y 走**到手机投影下方的 # `J5` / `J4`。折回是绕 x 轴翻 180°:x 不变、y 镜像 —— 而立创底层正是「先 y 镜像再转」 # (方案 §25.3),所以 `J5`/`J4` 在底层 **rot 0** 就是对的(R1 §25.16 v4 同一结论)。 # ⚠ 槽是 **layer 12 (MULTI) 的填充 = 挖槽区域**(`pcb_PrimitiveFill`),不是板框内环。 # DRC 认它(「挖槽区域 到 导线」),内层铺铜也会给它让反焊盘。 # ⚠ J1401 的 x 取设计文档 §22.3b 的**本体中心 53.58**(±0.3),不是 `mi8_mech.json` # `contacts_up.J1401 = 48.76` —— 那是座子西端(两者差 4.82 ≈ 半本体 6.1 − 1.3)。 # TP1204/1205 两份数据吻合到 0.1,说明坐标系相同、只是 J1401 取的点不同。 # 按 53.58 定,尾插车道西沿 47.6 正好让开 H4 螺钉头 (44.9 + 2.2),与 R1 自洽。 J600_U = 23.77 # 电池座中心(两份数据一致;§22.3b 标 ±2,槽放宽到 12) J1401_U = 53.58 # 尾插座本体中心(§22.3b,±0.3) # 槽的 y 区间:接口边外侧 1.2~4.2,中心离边 2.7(R1 同)。**跟着接口边走**。 # ⛔ 2026-09-11 晚主板转 180°:接口边朝上 ⇒ 槽在接口边**以北**(顶部服务带里), # 排线翻过接口边 → 穿槽 → 在 B 面 U 形折回、往 **−y** 走到 J5 / J4。 SLOT_Y = (round(IF_Y + 1.2, 3), round(IF_Y + 4.2, 3)) SLOT_RIB = 2.0 # 槽顶到板边留的肋(顶边导轨也咬在这条肋上) SLOT_W = {'BAT': 12.0, 'TAIL': 20.0} # 排线 8 / 18,各留余量 FLEX_W = {'BAT': 8.0, 'TAIL': 18.0} SLOT_R = 1.0 # 槽内圆角(铣刀半径,嘉立创内槽最小 R0.5) # ⛔ 2026-09-14 晚:板高 58 < 槽顶 59.86 + 肋 ⇒ **两条槽改成从顶边开口的缺口**(用户拍板)。 # 缺口画进板框折线(不再是 layer 12 挖槽区域),底部两个内角 R=SLOT_R;外角(与顶边相交处)直角。 # 代价:顶边 70 里 32 mm 是开口,顶部导轨只能咬 x 0.5~5.0 / 25.0~38.8 / 50.8~70.5 三段。 TOP_NOTCH = True def flex_x(): u"""两条排线的中心 x(刀片坐标)。""" return {'BAT': phone_xy(J600_U, 0.0)[0], 'TAIL': phone_xy(J1401_U, 0.0)[0]} def flex_slots(): u"""{名字: (x0, y0, x1, y1)},刀片坐标,铣穿。""" out = {} for k, cx in flex_x().items(): w = SLOT_W[k] out[k] = (round(cx - w / 2, 3), SLOT_Y[0], round(cx + w / 2, 3), SLOT_Y[1]) return out def mill_cuts(): u"""板上全部铣穿的内槽 [(x0,y0,x1,y1)] —— 布线 / 铺铜 / 摆位都从这里取障碍。 TOP_NOTCH 时槽一直开到板外(y1 = BH + 1),障碍语义不变。""" if TOP_NOTCH: return [(a, b, c, BH + 1.0) for (a, b, c, d) in flex_slots().values()] return list(flex_slots().values()) def slot_check(): u"""槽不许啃到螺钉孔避让圈,两条槽之间要留肋,槽不许进手机投影。""" bad = [] for k, w in flex_slots().items(): for hn, (hx, hy) in phone_holes().items(): dx = max(w[0] - hx, 0.0, hx - w[2]) dy = max(w[1] - hy, 0.0, hy - w[3]) if math.hypot(dx, dy) < HOLE_KEEP: bad.append(u'槽 %s 离螺钉孔 %s 只有 %.2f < %.1f' % (k, hn, math.hypot(dx, dy), HOLE_KEEP)) if w[1] < IF_Y: bad.append(u'槽 %s 底 %.1f 进了手机投影(接口边 %.1f)' % (k, w[1], IF_Y)) if not TOP_NOTCH and w[3] > BH - SLOT_RIB: bad.append(u'槽 %s 顶 %.1f 离板边不到 %.1f 的肋' % (k, w[3], SLOT_RIB)) s = flex_slots() a, b = sorted(s.values()) rib = b[0] - a[2] if rib < 3.0: bad.append(u'两槽之间只剩 %.1f mm 肋(要 >= 3)' % rib) return bad # 分区(只是参考矩形,画在文档层,摆位脚本从这里取) PHONE_X1 = round(PHONE_ORG[0] + PHONE_WH[0], 3) # 手机右沿 68.59 POWER_X0 = round(PHONE_X1 + 2.0, 3) # 功率列西沿 70.59(离手机 2 mm) ZONES = { 'finger': (TONGUE_X0, 0.0, TONGUE_X1, STEP_H), # 舌头(唯一伸进插槽的那块):不许放件 'service': (BX0, IF_Y, POWER_X0, BH), # 服务带(2026-09-11 晚起在顶部):C60 / 两条排线槽 / J3 'phone': (PHONE_ORG[0], PHONE_ORG[1], PHONE_X1, round(PHONE_ORG[1] + PHONE_WH[1], 3)), 'power': (POWER_X0, STEP_H, BX1, BH), # 功率列 } def corner(cx, cy, a0, a1, r=None): u"""圆角折线点列(角度 deg,逆时针)。`r` 省略时用 `CORNER_R`(板四角)。 ⛔⛔ 2026-09-12 晚修的 bug:这里原来**写死 `CORNER_R`**,而台阶那两个内凹角 调用时只把 `STEP_R` 用在了**弧心偏移**上 ⇒ 弧心 (TONGUE_X0−1.0, 11.0) 配半径 2.0, 弧的终点落在 x = 26.75+2.0 = **28.75** 而不是 27.75,于是舌头侧边变成一条 从 (28.75, 11.0) 斜到 (27.75, 0.0) 的**斜线** —— 舌头尖端 34.5 宽,到 y=11 处 只剩 **32.5**。几何校验、DRC 在旧尺寸下都不报(斜边最大只到 x=28.0,够不着线), **把舌头收窄之后才显形**:x=28.50 的 +12V / PRSNT# 在 y>8.25 处被斜边切到板外。 ⇒ 教训:**参数传进去了不等于用上了**;`STEP_R` 只影响弧心、不影响半径, 而这类错误画出来的形状仍然“看着像个圆角”,靠截图也看不出来。""" rr = CORNER_R if r is None else r pts = [] for k in range(CORNER_SEG + 1): a = math.radians(a0 + (a1 - a0) * k / CORNER_SEG) pts.append((cx + rr * math.cos(a), cy + rr * math.sin(a))) return pts def outline_pts(): u"""板框点列(逆时针,从舌头左下角起,闭合)。**凸字形**:底边只剩舌头那一段。 ⚠ 相邻重合点必须去掉:圆角的首点和前一条直边的终点是同一个点,留着就会生成 一条**零长度线段**,编辑器会静默把它吃掉(下发 41 段读回 37 段), 于是「读回条数 != 期望」变成一个没完没了的假警报。这正是 CLAUDE.md 里 原理图那条「零长度导线」坑的 PCB 版。 ⛔ 第一段必须仍是底边 `(TONGUE_X0, 0) -> (TONGUE_X1, 0)`:`segments()` 和 `outline_polygon()` 都按这个前提把防呆缺口插进去。 """ r, ri, hs = CORNER_R, STEP_R, STEP_H pts = [(TONGUE_X0, 0.0), (TONGUE_X1, 0.0)] # 舌头底边(缺口在中间断开) pts += [(TONGUE_X1, hs - ri)] pts += corner(TONGUE_X1 + ri, hs - ri, 180, 90, ri) # 右侧台阶内凹角(半径必须传 ri) x0, x1 = BX0, BX1 pts += [(x1 - r, hs)] pts += corner(x1 - r, hs + r, 270, 360) # 右下外角 pts += [(x1, BH - r)] pts += corner(x1 - r, BH - r, 0, 90) # 右上 if TOP_NOTCH: # 顶边自右向左,逐个绕进缺口 for (a, b, c, _d) in sorted(flex_slots().values(), reverse=True): R = SLOT_R pts += [(c, BH), (c, b + R)] pts += corner(c - R, b + R, 0, -90, R) # 缺口右下内角 pts += [(a + R, b)] pts += corner(a + R, b + R, 270, 180, R) # 缺口左下内角 pts += [(a, BH)] pts += [(x0 + r, BH)] pts += corner(x0 + r, BH - r, 90, 180) # 左上 pts += [(x0, hs + r)] pts += corner(x0 + r, hs + r, 180, 270) # 左下外角 pts += [(TONGUE_X0 - ri, hs)] pts += corner(TONGUE_X0 - ri, hs - ri, 90, 0, ri) # 左侧台阶内凹角(半径必须传 ri) pts += [(TONGUE_X0, 0.0)] out = [pts[0]] for q in pts[1:]: if abs(q[0] - out[-1][0]) > 1e-9 or abs(q[1] - out[-1][1]) > 1e-9: out.append(q) if abs(out[0][0] - out[-1][0]) > 1e-9 or abs(out[0][1] - out[-1][1]) > 1e-9: out.append(out[0]) assert abs(out[0][0] - out[-1][0]) < 1e-9 and abs(out[0][1] - out[-1][1]) < 1e-9, u'板框点列没有闭合' return out def notch_break(): u"""底边要断开的区间(板坐标 mm)。""" ml, mr = fp_edge.notch_mouth() return round(J1_X + ml, 4), round(J1_X + mr, 4) def segments(): u"""板框全部线段。底边在缺口口部断开。""" pts = outline_pts() segs = [] for a, b in zip(pts, pts[1:]): segs.append((a[0], a[1], b[0], b[1])) # 第一段是底边 (r,0)->(BW-r,0),切成两段 bl, br = notch_break() x0, y0, x1, y1 = segs[0] assert (y0, y1) == (0.0, 0.0) and x0 < bl < br < x1, u'底边不是第一段或断点跑出去了' segs[0:1] = [(x0, 0.0, bl, 0.0), (br, 0.0, x1, 0.0)] # ⚠ 排线槽**不在这里**:它们是 layer 12 的填充(挖槽区域),见 push_slots() return segs def notch_pts(): u"""缺口的点列(板坐标),**自左向右**:口部左 → 导入角 → 左壁 → 顶半圆 → 右壁 → 口部右。 几何仍然只有 `fp_edge` 一个出处,这里只做「封装坐标 + J1_X」的平移和顺序调整。 """ hw = fp_edge.NOTCH_W / 2.0 ml, mr = notch_break() cx = J1_X + fp_edge.NOTCH_CX top = fp_edge.NOTCH_D - hw pts = [(ml, 0.0), (cx - hw, fp_edge.LEAD_IN), (cx - hw, top)] for k in range(fp_edge.ARC_SEG, -1, -1): # 半圆从左(π)走到右(0) a = math.pi * k / fp_edge.ARC_SEG pts.append((cx + hw * math.cos(a), top + hw * math.sin(a))) pts += [(cx + hw, fp_edge.LEAD_IN), (mr, 0.0)] out = [pts[0]] for q in pts[1:]: if abs(q[0] - out[-1][0]) > 1e-9 or abs(q[1] - out[-1][1]) > 1e-9: out.append(q) return out def outline_polygon(): u"""**一条闭合折线**的完整板框点列:圆角矩形 + 底边中间的金手指防呆缺口。 ⛔ 为什么不用 `segments()` 那 37 段线(2026-09-09,M7): `pcb_PrimitiveLine` 在嘉立创里是**导线(铜)**,不是图形线 —— 选中它属性面板写的是 「导线」,还带网络 / 网络长度,`图层` 那一格 `data-value=11` 但**显示是空的** (下拉里只有铜层,板框层不在选项里)。渲染成品红、DRC 也不报, 所以四道几何校验全过,而**客户端根本不把它当板框**: 自动布线一路弹「请先绘制板框!」。板框要用 `pcb_PrimitivePolyline`(折线)画。 """ pts = outline_pts() # 闭合,第一段是底边 (r,0)->(BW-r,0) bl, br = notch_break() assert pts[0][1] == 0.0 and pts[1][1] == 0.0 and pts[0][0] < bl < br < pts[1][0], u'底边不是第一段或断点跑出去了' return [pts[0]] + notch_pts() + list(pts[1:]) def polygon_src(pts): u"""点列 -> pcb_MathPolygon.createPolygon 的源数组(mil,显式闭合)。""" a = [mil(pts[0][0]), mil(pts[0][1])] for q in pts[1:]: a += ['L', mil(q[0]), mil(q[1])] if pts[0] != pts[-1]: a += ['L', mil(pts[0][0]), mil(pts[0][1])] return a def rrect_pts(x0, y0, x1, y1, r, seg=6): u"""圆角矩形闭合点列。内槽必须圆角:铣刀下不去直角,嘉立创内槽最小 R0.5。""" pts = [] for cx, cy, a0 in ((x1 - r, y0 + r, 270), (x1 - r, y1 - r, 0), (x0 + r, y1 - r, 90), (x0 + r, y0 + r, 180)): for k in range(seg + 1): a = math.radians(a0 + 90.0 * k / seg) pts.append((cx + r * math.cos(a), cy + r * math.sin(a))) out = [pts[0]] for q in pts[1:]: if abs(q[0] - out[-1][0]) > 1e-9 or abs(q[1] - out[-1][1]) > 1e-9: out.append(q) out.append(out[0]) return out def selfcheck(): bl, br = notch_break() assert round(br - bl, 4) == round(fp_edge.NOTCH_W + 2 * fp_edge.LEAD_IN, 4), (bl, br) # 金手指必须整个落在板内,且不侵占服务带 for pin in ('A1', 'A32'): x, y, w, h = fp_edge.pad_geom(pin) ax = J1_X + x assert BX0 + 0.5 < ax < BX1 - 0.5, u'%s 跑出板外: x=%.2f' % (pin, ax) top = fp_edge.EDGE_CLR + fp_edge.LONG_L assert top < ZONES['finger'][3], u'手指顶到服务带' assert fp_edge.NOTCH_D < ZONES['finger'][3], u'缺口比金手指区还深' # 分区不许互相重叠(除了 finger/service 只共边) z = ZONES assert z['service'][1] == z['phone'][3] == IF_Y # ⚠ 台阶之后 finger 区只剩舌头那一块(x TONGUE_X0~TONGUE_X1)。手机投影 y0=11.0 # 低于台阶 12.0,但它在舌头正上方那一段是有板材的;两侧那 1 mm 是**有意的悬空** # (定位孔最南 14.46 全在板材上,主板靠尼龙立柱固定)⇒ 只判「不压舌头」。 # 主板投影底边在舌头两侧的**悬空量**(有意为之,见 STEP_H 处的说明):只判别超标。 _hang = max(0.0, STEP_H - z['phone'][1]) assert _hang <= 1.5, u'主板投影底边悬空 %.2f mm 过多(台阶 %.1f vs 主板 y0 %.2f)' % ( _hang, STEP_H, z['phone'][1]) assert min(v[1] for v in phone_holes().values()) > STEP_H, u'有定位孔落进台阶切掉的区域' assert fp_edge.EDGE_CLR + fp_edge.LONG_L < STEP_H, u'金手指比台阶还高' assert z['phone'][2] <= z['power'][0], u'手机投影压进功率列' assert z['service'][2] <= z['power'][0], u'服务带压进功率列' assert z['phone'][3] <= BH, u'手机投影超出板顶' # 台阶:金手指与防呆缺口必须整个落在舌头里,内凹角要铣得出来 _fx0 = J1_X + fp_edge.PIN_X['A1'] - fp_edge.FW / 2 _fx1 = J1_X + fp_edge.PIN_X['A32'] + fp_edge.FW / 2 assert TONGUE_X0 < _fx0 and _fx1 < TONGUE_X1, u'金手指 %.2f~%.2f 超出舌头 %.1f~%.1f' % (_fx0, _fx1, TONGUE_X0, TONGUE_X1) _bl, _br = notch_break() assert TONGUE_X0 < _bl < _br < TONGUE_X1, u'防呆缺口超出舌头' assert STEP_R >= 0.5, u'台阶内凹角 R%.1f 铣刀下不去' % STEP_R assert TONGUE_X1 - TONGUE_X0 < BW, u'舌头没窄于板宽,等于没开台阶' bad = slot_check() assert not bad, u'排线槽校核不过: %s' % bad return True def call(js, timeout=180): r = run(js, timeout=timeout) if not r.get('success'): raise RuntimeError(json.dumps(r, ensure_ascii=False)[:800]) return r['result'] def mil(v): return round(v * MIL, 4) def push_outline(): u"""画板框:**一条闭合折线**(`pcb_PrimitivePolyline`),含金手指缺口。 顺带把层 11 上遗留的**导线**全删掉 —— M4b~M6 一直是用 `pcb_PrimitiveLine` 画的,那是铜,客户端不认(见 `outline_polygon()` 的说明)。 """ src = polygon_src(outline_polygon()) js = ("await eda.dmt_EditorControl.openDocument('%s');" % PCB + "await new Promise(r=>setTimeout(r,2500));" + "const oldL = (await eda.pcb_PrimitiveLine.getAll()||[]).filter(l=>l.layer===11)" ".map(l=>l.primitiveId).filter(i=>i.length<=20);" + "if (oldL.length) await eda.pcb_PrimitiveLine.delete(oldL);" + "const oldP = (await eda.pcb_PrimitivePolyline.getAll()||[])" ".filter(p=>p.getState_Layer()===11).map(p=>p.getState_PrimitiveId());" + "if (oldP.length) await eda.pcb_PrimitivePolyline.delete(oldP);" + "const poly = await eda.pcb_MathPolygon.createPolygon(%s);" % json.dumps(src) + "const r = await eda.pcb_PrimitivePolyline.create('', 11, poly, %s);" % mil(OUTLINE_W) + "await eda.pcb_Document.save();" + "return {delLine: oldL.length, delPoly: oldP.length, made: !!r," " now: (await eda.pcb_PrimitivePolyline.getAll()||[])" ".filter(p=>p.getState_Layer()===11).length};") r = call(js, timeout=300) print(u'板框折线: 建 %s(删旧导线 %d / 旧折线 %d);层 11 上现有折线 %d 条' % (u'成功' if r['made'] else u'失败', r['delLine'], r['delPoly'], r['now'])) if not r['made'] or r['now'] != 1: raise SystemExit(u'!! 板框没画成') return r PUSH_SLOTS_JS = u""" await eda.dmt_EditorControl.openDocument('%s'); await new Promise(r=>setTimeout(r,2500)); const S = %s; let del = 0; for (const f of await eda.pcb_PrimitiveFill.getAll()) if (f.getState_Layer() === 12) { await eda.pcb_PrimitiveFill.delete(f.getState_PrimitiveId()); del++; } let made = 0, fail = []; for (const [name, src] of S) { const poly = await eda.pcb_MathPolygon.createPolygon(src); const r = await eda.pcb_PrimitiveFill.create(12, poly); if (r) made++; else fail.push(name); } await eda.pcb_Document.save(); const now = (await eda.pcb_PrimitiveFill.getAll()).filter(f => f.getState_Layer() === 12).length; return {del, made, fail, now}; """ def slot_src(x0, y0, x1, y1): u"""圆角矩形 -> pcb_MathPolygon 的点列源(mil,显式闭合)。照抄 R1 pcb2_place.slot_poly。""" pts = rrect_pts(x0, y0, x1, y1, SLOT_R) a = [mil(pts[0][0]), mil(pts[0][1])] for p in pts[1:]: a += ['L', mil(p[0]), mil(p[1])] return a def push_slots(): # TOP_NOTCH:槽已经画进板框折线,这里只负责把旧的 layer 12 挖槽区域删光(S 为空) S = [] if TOP_NOTCH else [[k, slot_src(*w)] for k, w in sorted(flex_slots().items())] r = call(PUSH_SLOTS_JS % (PCB, json.dumps(S)), timeout=300) print(u'排线槽(layer 12 挖槽区域): 建 %d / 失败 %s(删旧 %d);板上现有 %d 个' % (r['made'], r['fail'] or u'无', r['del'], r['now'])) for k, w in sorted(flex_slots().items()): print(u' %-5s x %.2f~%.2f y %.2f~%.2f (%.1f × %.1f,排线 %.0f 宽)' % (k, w[0], w[2], w[1], w[3], w[2] - w[0], w[3] - w[1], FLEX_W[k])) if r['now'] != len(S): raise SystemExit(u'!! 槽没建全') return r def verify(): u"""读回板框折线,逐点和 `outline_polygon()` 对账 + 校验闭合与外接矩形。""" r = call("await eda.dmt_EditorControl.openDocument('%s');" "await new Promise(r=>setTimeout(r,2000));" "const ps = (await eda.pcb_PrimitivePolyline.getAll()||[])" ".filter(p=>p.getState_Layer()===11);" "const line11 = (await eda.pcb_PrimitiveLine.getAll()||[])" ".filter(l=>l.layer===11).length;" "const fills = (await eda.pcb_PrimitiveFill.getAll()).filter(f=>f.getState_Layer()===12).length;" "return {n: ps.length, poly: ps.length ? ps[0].getState_Polygon() : null," " line11, fills};" % PCB, timeout=180) bad = [] if r['n'] != 1: bad.append(u'层 11 上有 %d 条折线(应为 1 条)' % r['n']) # ⚠ 层 11 上**允许**留 16 条导线:那是 J1 金手指封装内部的缺口线(fp_edge 画的), # 删不掉也不必删 —— 板框已经由折线自己闭合,它们只是重复的图形。 if r['line11'] not in (0, 16): bad.append(u'层 11 上还有 %d 条导线(只该剩 J1 封装那 16 条或 0 条)' % r['line11']) want_fills = 0 if TOP_NOTCH else len(flex_slots()) if r['fills'] != want_fills: bad.append(u'layer 12 挖槽区域 %d 个 != %d' % (r['fills'], want_fills)) pts = [] src = r['poly'] if isinstance(src, dict): src = src.get('pointArr') or src.get('points') or src.get('polygon') or src if isinstance(src, list): i, cur = 0, None while i < len(src): if src[i] == 'L' or src[i] == 'l': i += 1 continue if isinstance(src[i], (int, float)) and i + 1 < len(src) and isinstance(src[i + 1], (int, float)): pts.append((src[i] / MIL, src[i + 1] / MIL)) i += 2 else: i += 1 want = outline_polygon() if pts: xs = [p[0] for p in pts] ys = [p[1] for p in pts] if abs(min(xs) - BX0) > 0.01 or abs(max(xs) - BX1) > 0.01 or abs(min(ys)) > 0.01 or abs(max(ys) - BH) > 0.01: bad.append(u'板框外接矩形 %.2f~%.2f x %.2f~%.2f != 0~%.1f x 0~%.1f' % (min(xs), max(xs), min(ys), max(ys), BX1, BH)) if abs(pts[0][0] - pts[-1][0]) > 0.01 or abs(pts[0][1] - pts[-1][1]) > 0.01: bad.append(u'折线首尾不重合:%s vs %s' % (pts[0], pts[-1])) if abs(len(pts) - len(want)) > 1: bad.append(u'点数 %d != %d' % (len(pts), len(want))) print(u'读回板框折线 1 条 / %d 点,外接 %.2f~%.2f x %.2f~%.2f;' u'层 11 遗留导线 %d 条;挖槽区域 %d 个' % (len(pts), min(xs), max(xs), min(ys), max(ys), r['line11'], r['fills'])) else: bad.append(u'读不回折线点列(原始值 %s)' % str(r['poly'])[:120]) if bad: print(u'[X] 校验不过:') for b in bad: print(u' ' + b) else: print(u'[OK] 板框是一条闭合折线(缺口在里面),外接矩形对上,挖槽区域齐') return bad PUSH_HOLES_JS = u""" await eda.dmt_EditorControl.openDocument('%s'); await new Promise(r=>setTimeout(r,2500)); const H = %s, D = %s; for (const p of await eda.pcb_PrimitivePad.getAll()) { const num = p.getState_PadNumber(); if (typeof num === 'string' && /^H[1-7]$/.test(num)) await eda.pcb_PrimitivePad.delete(p.getState_PrimitiveId()); } let k = 0; for (const [name, x, y] of H) { // layer 12 = 多层;metallization=false ⇒ NPTH。参数位序照抄 R1 pcb2_place.py await eda.pcb_PrimitivePad.create(12, name, x, y, 0, ['ELLIPSE', D, D], '', ['ROUND', D], 0, 0, 0, false); k++; } await eda.pcb_Document.save(); return {made: k, now: (await eda.pcb_PrimitivePad.getAll()) .filter(p => /^H[1-7]$/.test(String(p.padNumber))).length}; """ def push_holes(): hs = phone_holes() arr = [[k, round(v[0] * MIL, 4), round(v[1] * MIL, 4)] for k, v in sorted(hs.items())] r = call(PUSH_HOLES_JS % (PCB, json.dumps(arr), round(HOLE_D * MIL, 4)), timeout=300) print(u'主板定位孔: 下发 %d 个 / 读回 %d 个,NPTH Φ%.1f(⚠ 必须配 M1.6 尼龙立柱)' % (r['made'], r['now'], HOLE_D)) return r if __name__ == '__main__': selfcheck() if '--holes' in sys.argv: push_holes() for k, v in sorted(phone_holes().items()): print(u' %-3s (%6.2f, %6.2f)' % (k, v[0], v[1])) sys.exit(0) bl, br = notch_break() # ⚠ 必须 %.1f:2026-09-12 深夜板宽变成 89.5 之后,原来的 %.0f 把它印成「90」, # 而板宽正是这次改动的主角 —— 下个会话一眼看过去会以为没改成。 print(u'刀片 %.1f x %.1f,四角 R%.1f;金手指中心 x=%.2f(偏心 %.2f)' % (BW, BH, CORNER_R, J1_X, abs(J1_X - (BX0 + BW / 2.0)))) print(u'底边断点: %.2f ~ %.2f(缺口口部)' % (bl, br)) print(u'金手指跨度: x %.2f ~ %.2f,指顶 y=%.2f,缺口顶 y=%.2f' % (J1_X + fp_edge.PIN_X['A1'] - fp_edge.FW / 2, J1_X + fp_edge.PIN_X['A32'] + fp_edge.FW / 2, fp_edge.EDGE_CLR + fp_edge.LONG_L, fp_edge.NOTCH_D)) for k, z in ZONES.items(): print(u' %-8s x %6.2f~%6.2f y %6.2f~%6.2f' % (k, z[0], z[2], z[1], z[3])) for k, w in sorted(flex_slots().items()): print(u' 槽 %-5s x %6.2f~%6.2f y %6.2f~%6.2f' % (k, w[0], w[2], w[1], w[3])) if '--outline' in sys.argv: push_outline() push_slots() verify() elif '--slots' in sys.argv: push_slots() verify() elif '--verify' in sys.argv: verify()