# -*- coding: utf-8 -*- u"""把载板 PCB2 导成 **1:1 打印用的尺寸图 PDF**(拿真主板去比孔位 / 外形)。 /c/Python314/python .claude/pcb2_pdf.py # 读实盘几何 -> 出 PDF /c/Python314/python .claude/pcb2_pdf.py --offline # 不连桥接,用上次的 _tmp_mech_dump.json 几何**全部从客户端实读**(板框多段线 layer 11、槽 layer 12 填充、 定位孔 / M3 的自由焊盘、主板投影丝印 layer 3);pcb2_place.py 的标称值只用来 交叉校验和写标注 —— 图上画的永远是实盘那一份。 ## 三页 1. **A4 横向**:整块载板 1:1(板框 / 5 个主板投影 / 35 个 Φ2.5 定位孔 / M3 / 穿线槽) 2. **A4 纵向**:单个主板位 1:1 详图,两种朝向各一份(CH4/CH5 与 CH1~CH3) 3. **A4 纵向**:35 个孔的载板绝对坐标表 + 复核步骤 ## 两个坑 - **字体**:`fitz` 内置的 `china-s` 会把 ASCII 排成全角,一行就撑出纸外。 这里嵌 `msyh.ttc`(微软雅黑)走 `TextWriter`,顺带能算真实文字宽度做换行。 - **打印**:必须选「实际大小 / 100%」。前两页各印了一条 100 mm 标尺, 先量标尺再量孔 —— 量出来不是 100.0 就是被缩放了,图别用。 """ import json import math import os import sys sys.stdout.reconfigure(encoding='utf-8') HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) import fitz import pcb2_place as P MIL = 39.3700787401575 PT = 72.0 / 25.4 # mm -> pt DUMP = os.path.join(HERE, '_tmp_mech_dump.json') OUT = os.path.join(os.path.dirname(HERE), 'shots', 'PCB2-carrier-1to1.pdf') FONTFILE = 'C:/Windows/Fonts/msyh.ttc' GRAY = (0.45, 0.45, 0.45) RED = (0.75, 0, 0) JS = u""" await eda.dmt_EditorControl.openDocument('%s'); await new Promise(r=>setTimeout(r,1500)); const out = {pads: [], poly: [], fill: [], line3: []}; for (const p of await eda.pcb_PrimitivePad.getAll()) { const num = p.getState_PadNumber(); let parent = null; try { parent = p.getState_ParentComponentPrimitiveId(); } catch(e) {} if (parent) continue; if (typeof num === 'string' && (/^P[1-5]H[1-7]$/.test(num) || /^MH\\d+$/.test(num))) out.pads.push([num, p.getState_X(), p.getState_Y(), p.getState_Pad(), p.getState_Hole(), p.getState_Net()]); } for (const p of await eda.pcb_PrimitivePolyline.getAll()) if (p.getState_Layer() === 11) out.poly.push(p.getState_Polygon()); for (const f of await eda.pcb_PrimitiveFill.getAll()) if (f.getState_Layer() === 12) out.fill.push(f.getState_ComplexPolygon()); for (const l of await eda.pcb_PrimitiveLine.getAll()) if (l.getState_Layer() === 3) out.line3.push([l.getState_StartX(), l.getState_StartY(), l.getState_EndX(), l.getState_EndY()]); return out; """ % P.PCB # ---------------------------------------------------------------- 读几何 def fetch(): from eda import run r = run(JS, timeout=180) if not r.get('success'): sys.exit(u'读几何失败: ' + json.dumps(r, ensure_ascii=False)[:400]) json.dump(r['result'], open(DUMP, 'w', encoding='utf-8'), ensure_ascii=False) return r['result'] def poly_pts(src, step=2.0): u"""EasyEDA 多边形源数组 -> [(x_mm, y_mm), ...],圆弧按 step 度采样。 源格式:[x0, y0, ('L', x, y | 'ARC', 角度, x, y)...],角度**逆时针为正**(y 向上)。 """ if isinstance(src, dict): src = src.get('polygon', src.get('outline')) if src and isinstance(src[0], (list, dict)): # 复合多边形:外环在前 return poly_pts(src[0], step) x, y = src[0] / MIL, src[1] / MIL pts = [(x, y)] i = 2 while i < len(src): op = src[i] if op == 'L': x, y = src[i + 1] / MIL, src[i + 2] / MIL pts.append((x, y)) i += 3 elif op == 'ARC': ang = float(src[i + 1]) ex, ey = src[i + 2] / MIL, src[i + 3] / MIL pts += arc_pts((x, y), (ex, ey), ang, step) x, y = ex, ey i += 4 else: # 少见:直接跟一对坐标 x, y = src[i] / MIL, src[i + 1] / MIL pts.append((x, y)) i += 2 return pts def arc_pts(a, b, ang, step=2.0): u"""从 a 到 b、扫过 ang 度(逆时针为正)的圆弧采样点(不含起点)。""" ax, ay = a bx, by = b vx, vy = bx - ax, by - ay L = math.hypot(vx, vy) half = math.radians(ang) / 2.0 if L < 1e-9 or abs(math.tan(half)) < 1e-9: return [b] h = (L / 2.0) / math.tan(half) cx = (ax + bx) / 2.0 - vy / L * h cy = (ay + by) / 2.0 + vx / L * h r = math.hypot(ax - cx, ay - cy) a0 = math.atan2(ay - cy, ax - cx) n = max(2, int(abs(ang) / step) + 1) return [(cx + r * math.cos(a0 + math.radians(ang) * k / n), cy + r * math.sin(a0 + math.radians(ang) * k / n)) for k in range(1, n + 1)] # ---------------------------------------------------------------- 画布 FONT = fitz.Font(fontfile=FONTFILE) class Sheet(object): u"""一页纸:图纸 mm(y 向上)-> PDF 点(y 向下)。ox/oy 是图纸原点在纸上的位置, 可以中途 `origin()` 改,用来在一页上摆几个坐标块。""" def __init__(self, doc, w_mm, h_mm, ox=0.0, oy=0.0): self.page = doc.new_page(width=w_mm * PT, height=h_mm * PT) self.h = h_mm self.w = w_mm self.ox, self.oy = ox, oy self.sh = self.page.new_shape() self.tw = fitz.TextWriter(self.page.rect) self.colors = [] # [(color, [(point, text, size)...])] def origin(self, ox, oy): self.ox, self.oy = ox, oy def p(self, x, y): return fitz.Point((x + self.ox) * PT, (self.h - (y + self.oy)) * PT) # ---- 图元 ---- def line(self, x1, y1, x2, y2, w=0.35, color=(0, 0, 0), dash=None): self.sh.draw_line(self.p(x1, y1), self.p(x2, y2)) self.sh.finish(width=w, color=color, dashes=dash) def polyline(self, pts, w=0.35, color=(0, 0, 0), dash=None, close=True): q = [self.p(*t) for t in pts] for i in range(len(q) - 1): self.sh.draw_line(q[i], q[i + 1]) if close and q[0] != q[-1]: self.sh.draw_line(q[-1], q[0]) self.sh.finish(width=w, color=color, dashes=dash) def rect(self, x0, y0, x1, y1, w=0.35, color=(0, 0, 0), dash=None): self.polyline([(x0, y0), (x1, y0), (x1, y1), (x0, y1)], w, color, dash) def circle(self, x, y, d, w=0.35, color=(0, 0, 0), dash=None): self.sh.draw_circle(self.p(x, y), d / 2.0 * PT) self.sh.finish(width=w, color=color, dashes=dash) def cross(self, x, y, arm=1.6, w=0.25, color=(0, 0, 0)): self.line(x - arm, y, x + arm, y, w, color) self.line(x, y - arm, x, y + arm, w, color) # ---- 文字(嵌微软雅黑,走 TextWriter,宽度可算)---- def text(self, x, y, s, size=7.0, color=(0, 0, 0), anchor='l'): pt = self.p(x, y) if anchor == 'c': pt = fitz.Point(pt.x - FONT.text_length(s, size) / 2.0, pt.y) elif anchor == 'r': pt = fitz.Point(pt.x - FONT.text_length(s, size), pt.y) for c, items in self.colors: if c == color: items.append((pt, s, size)) break else: self.colors.append((color, [(pt, s, size)])) def para(self, x, y, s, size=7.0, color=(0, 0, 0), width=None, lead=4.2): u"""按纸面宽度自动折行,返回下一行的 y。""" width = width or (self.w - 2 * self.ox - 2.0) line, out = u'', [] for ch in s: if FONT.text_length(line + ch, size) > width * PT and line: out.append(line) line = ch else: line += ch out.append(line) for t in out: self.text(x, y, t, size, color) y -= lead return y def done(self): self.sh.commit() for color, items in self.colors: tw = fitz.TextWriter(self.page.rect) for pt, s, size in items: tw.append(pt, s, font=FONT, fontsize=size) tw.write_text(self.page, color=color) def scalebar(s, x, y, length=100.0): u"""带 10 mm 刻度的标尺,用来验证打印没被缩放。""" s.line(x, y, x + length, y, 0.5) for k in range(0, int(length) + 1, 10): s.line(x + k, y, x + k, y + (4.0 if k % 50 == 0 else 2.5), 0.5) if k % 50 == 0: s.text(x + k, y + 5.2, '%d' % k, 6, anchor='c') s.text(x, y - 3.6, u'标尺:这一段必须量出 %.0f mm,量不出就是打印被缩放了' % length, 7) NOTE = (u'打印必须选「实际大小 / 100%」,不能选「适合页面」「缩小以适合可打印区域」。' u'印完先量标尺,再量孔。') # ---------------------------------------------------------------- 第 1 页:整板 def page_full(doc, geo, holes, mh): s = Sheet(doc, 297.0, 210.0, (297.0 - P.BW) / 2.0, (210.0 - P.BH) / 2.0 + 6.0) for src in geo['poly']: s.polyline(poly_pts(src), 0.5) for src in geo['fill']: s.polyline(poly_pts(src), 0.35, (0.55, 0.55, 0.55)) for x1, y1, x2, y2 in geo['line3']: s.line(x1 / MIL, y1 / MIL, x2 / MIL, y2 / MIL, 0.3, GRAY, dash='[1.5 1.5] 0') for name, (x, y, d) in sorted(mh.items()): s.circle(x, y, P.MH_PAD, 0.25, GRAY, dash='[1 1] 0') s.circle(x, y, d, 0.5) s.cross(x, y, 2.2, 0.25) s.text(x + 3.6, y + 1.0, name, 5.5) for name, (x, y, d) in sorted(holes.items()): s.circle(x, y, d, 0.5) s.cross(x, y, 2.0, 0.25) for n, (x0, y0, rho) in sorted(P.SITES.items()): s.text(x0 + P.PW / 2.0, y0 + P.PH / 2.0 - 1.0, u'CH%d 主板' % n, 8, (0.4, 0.4, 0.4), anchor='c') for cx, cy in [(0, 0), (P.BW, 0), (0, P.BH), (P.BW, P.BH)]: s.cross(cx, cy, 4.0, 0.4) s.text(0, P.BH + 9.0, u'PhoneRail 载板 PCB2 · 1:1 尺寸图 · %.0f × %.0f mm(顶视)' % (P.BW, P.BH), 11) s.text(0, P.BH + 3.6, u'主板定位孔 %d 个 Φ%.1f NPTH · M3 孔 %d 个 Φ%.1f(外圈虚线是 Φ%.1f 焊盘) · ' u'穿线槽 %d 条 · 灰虚线框 = 小米8 主板本体投影 %.2f × %.2f mm' % (len(holes), P.HOLE_D, len(mh), P.MH_HOLE, P.MH_PAD, len(geo['fill']), P.PW, P.PH), 7) scalebar(s, 0, -16.0) y = s.para(0, -25.0, NOTE, 7, RED) s.para(0, y - 1.0, u'主板按装配朝向(触点面朝下)平放到纸上比对;单站详图和坐标表见第 2 / 3 页。', 7) s.done() # ---------------------------------------------------------------- 第 2 页:单站详图 def site_block(s, ox, oy, flip, caption): u"""一个主板位 1:1。flip=False 接口边朝下(CH4/CH5),True 转 180°(CH1~CH3)。""" s.origin(ox, oy) m = (lambda u, v: (P.PW - u, P.PH - v)) if flip else (lambda u, v: (u, v)) s.rect(0, 0, P.PW, P.PH, 0.5) ey = P.PH - 1.5 if flip else 1.5 s.line(2.0, ey, P.PW - 2.0, ey, 0.4, GRAY, dash='[1.5 1.5] 0') s.text(P.PW / 2.0, P.PH + 2.6 if flip else -4.0, u'接口边(J600 电池座 / J1401 尾插在这一边)', 6, GRAY, anchor='c') for hn, (u, v) in sorted(P.HOLES.items()): x, y = m(u, v) s.circle(x, y, P.HOLE_D, 0.5) s.cross(x, y, 2.4, 0.25) right = x < P.PW - 22.0 # 右边放不下就把标签甩到左边 up = y < P.PH - 5.0 # 顶边附近的标签改放下方,别压着板边 s.text(x + (3.0 if right else -3.0), y + (2.4 if up else -4.4), '%s (%.1f, %.1f)' % (hn, u, v), 5.5, anchor='l' if right else 'r') for i, ((u, v), tag) in enumerate(zip(P.POGO_XY, P.POGO_TAG)): x, y = m(u, v) s.circle(x, y, 1.2, 0.35, RED) s.text(x + 1.2, y + 1.4, '%d' % (i + 1), 5.0, RED) for cx, cy in [(0, 0), (P.PW, 0), (0, P.PH), (P.PW, P.PH)]: s.cross(cx, cy, 4.0, 0.4) s.text(0, -8.5, caption, 8) def page_site(doc): ox = (210.0 - P.PW) / 2.0 TX, TW = 18.0, 174.0 # 文字块的左边和折行宽度 s = Sheet(doc, 210.0, 297.0, TX, 0.0) s.text(0, 280.0, u'小米8 主板位 · 1:1 详图(五站几何相同)', 11) s.para(0, 274.0, u'本体 %.2f × %.2f mm(实测),原点 = 本体左下角,顶视,孔 Φ%.1f NPTH。' u'把真主板按装配朝向(触点面朝下)压进框里:先看四条边,再看 7 个孔。' % (P.PW, P.PH, P.HOLE_D), 7, width=TW) site_block(s, ox, 213.0, False, u'① 接口边朝下 —— 载板上 CH4 / CH5 就是这个朝向') site_block(s, ox, 143.0, True, u'② 转 180°,接口边朝上 —— 载板上 CH1 / CH2 / CH3 是这个朝向') s.origin(TX, 0.0) y = 126.0 s.text(0, y, u'红圈 = 侧键顶针触点(Φ1.2 示意),本体坐标:', 7); y -= 4.6 for i, ((u, v), tag) in enumerate(zip(P.POGO_XY, P.POGO_TAG)): s.text(4.0, y, u'%d %s (%.2f, %.2f)' % (i + 1, tag, u, v), 6.5) y -= 4.2 y -= 3.0 y = s.para(0, y, u'⚠ 孔位是从用户标注的照片提取的,标称公差 ±0.5 mm;本体尺寸 %.2f × %.2f 是卡尺实测。' u'这张图就是拿来把那 ±0.5 消掉的。' % (P.PW, P.PH), 7, RED, width=TW) y = s.para(0, y - 1.0, u'孔对不上就量出真孔的本体坐标 (u, v),填回 .claude/mi8_mech.json 的 holes,' u'再跑 pcb2_place.py --mech 重打那 35 个孔(其它布局不动)。', 7, width=TW) scalebar(s, 0, y - 14.0) s.para(0, y - 23.0, NOTE, 7, RED, width=TW) s.done() # ---------------------------------------------------------------- 第 3 页:坐标表 def page_table(doc, holes, mh, dev): s = Sheet(doc, 210.0, 297.0, 18.0, 0.0) y = 278.0 s.text(0, y, u'载板绝对坐标表(mm,原点 = 板左下角,顶视)', 11); y -= 8 y = s.para(0, y, u'主板本体坐标 (u, v):' + u' '.join( '%s(%.1f, %.1f)' % (k, v[0], v[1]) for k, v in sorted(P.HOLES.items())), 6.5, lead=4.0) y = s.para(0, y, u'站位左下角 / 旋转:' + u' '.join( 'CH%d(%.2f, %.2f, %d°)' % (n, a, b, r) for n, (a, b, r) in sorted(P.SITES.items())), 6.5, lead=4.0) y = s.para(0, y, u'实盘孔位与 pcb2_place.py 标称值最大偏差 %.3f mm;孔 Φ%.1f NPTH。' % (dev, P.HOLE_D), 6.5, lead=4.0) y -= 5 cols = [0, 36, 72, 108, 144] rows = sorted(holes.items()) per = (len(rows) + len(cols) - 1) // len(cols) top = y for i, (name, (x, yy, d)) in enumerate(rows): s.text(cols[i // per], top - (i % per) * 5.2, '%s %7.2f %7.2f' % (name, x, yy), 6.5) y = top - per * 5.2 - 9 s.text(0, y, u'载板自己的 M3 孔 Φ%.1f(金属化,接 GND,焊盘 Φ%.1f)' % (P.MH_HOLE, P.MH_PAD), 9) y -= 6 for i, (name, (x, yy, d)) in enumerate(sorted(mh.items())): s.text(cols[i % 4], y - (i // 4) * 5.2, '%s %7.2f %7.2f' % (name, x, yy), 6.5) y -= ((len(mh) + 3) // 4) * 5.2 + 9 s.text(0, y, u'复核步骤', 9); y -= 6 for t in [u'1. A4 打印第 1 / 2 页,先量那条 100 mm 标尺 —— 不是 100.0 就重印。', u'2. 真主板按装配朝向(触点面朝下)压进第 2 页的框里,先看四条边,再看 7 个孔。', u'3. 对不上就量出真孔的本体坐标 (u, v),填回 .claude/mi8_mech.json 的 holes,' u'再跑 pcb2_place.py --mech 重打这 35 个孔。', u'4. 顺带量孔径:现在按 Φ%.1f 孔 / M2.5 尼龙立柱做的(金属立柱会把五台手机的地并起来)。' % P.HOLE_D]: y = s.para(4.0, y, t, 7, lead=4.6) - 1.0 s.done() # ---------------------------------------------------------------- main def main(): geo = json.load(open(DUMP, encoding='utf-8')) if '--offline' in sys.argv else fetch() holes, mh = {}, {} for name, x, y, pad, hole, net in geo['pads']: rec = (round(x / MIL, 3), round(y / MIL, 3), round(hole[1] / MIL, 3)) (mh if name.startswith('MH') else holes)[name] = rec dev = 0.0 # 实盘孔位 vs pcb2_place.py 标称 for n in P.SITES: for hn, (u, v) in P.HOLES.items(): bx, by, _ = P.to_board(n, u, v) hx, hy, _ = holes['P%d%s' % (n, hn)] dev = max(dev, math.hypot(bx - hx, by - hy)) print(u'实盘:定位孔 %d 个 Φ%s · M3 %d 个 Φ%s · 板框 %d 条 · 槽 %d 个 · 投影丝印 %d 段' % (len(holes), sorted(set(v[2] for v in holes.values())), len(mh), sorted(set(v[2] for v in mh.values())), len(geo['poly']), len(geo['fill']), len(geo['line3']))) print(u'孔位与标称值最大偏差 %.4f mm' % dev) if len(holes) != 35: print(u'!! 定位孔不是 35 个') if len(mh) != len(P.MH): print(u'!! 板上 M3 孔 %d 个,pcb2_place.py 的 MH 表有 %d 个 —— 图按实盘画' % (len(mh), len(P.MH))) doc = fitz.open() page_full(doc, geo, holes, mh) page_site(doc) page_table(doc, holes, mh, dev) doc.set_metadata({'title': 'PhoneRail 载板 PCB2 1:1 尺寸图', 'subject': u'220x135 载板 / 35 个小米8 主板定位孔 / 1:1 打印核对'}) if not os.path.isdir(os.path.dirname(OUT)): os.makedirs(os.path.dirname(OUT)) doc.subset_fonts() doc.save(OUT, garbage=4, deflate=True) print(u'写出 %s(%d 页,%.0f KB)' % (OUT, doc.page_count, os.path.getsize(OUT) / 1024.0)) if __name__ == '__main__': main()