Files
Robot_Arm/arm/dataio/draw_sheet.py
T
cbb 649bd69810 refactor(layout): src/ 收成顶层包 arm/,入口统一 python -m;提升承担构建的 scratch 脚本
单文件约束取消后,Python 侧没必要再靠 16 处 sys.path.insert 维持扁平模块互认。
现在按职责分包:
  arm/paths.py 路径唯一来源(含新增 CACHE、TPL)
  arm/constants.py 实测接口数值(原 armcore 的 M/C/CLR/FIT/MOTOR_TOL 段)
  arm/cad/{helpers,arm_build,part_tools,parts/} 几何与装配
  arm/kinematics.py(原 human_layout.py,唯一运动学事实源)
  arm/analysis/{sweep_v2,strength_check,torque_balance,finalize_travel,ws_sweep,gen_env_mesh}
  arm/dataio/{screw_mesh,draw_sheet}   arm/viewer/{build_viewer,shot}
  arm/build_model.py(原 arm_model.py)

顺带修掉的既有缺陷(都是实测撞出来的,不是预防性改动):
- arm_model 第二次 sys.path 跳到 gitignore 的 scratch/ 去 import 构建必需的包络脚本:
  ws_sweep/gen_env_mesh 提升进 arm/analysis/,缓存挪到 data/cache/(gitignore,可重生成)。
- finalize_travel / draw_sheet 顶层就是"算完写文件",一次 importlib 冒烟测试就把
  docs/travel.json 重写了一遍;现在被 import 直接抛错。
- strength_check 用 OUT.replace("/out","/web") 靠字符串形状推路径,改成直接用 WEB。
- build_viewer 里 WORKSPACE 使用点漏改导致 NameError —— 已由构建关卡抓到并修掉。

os._exit 的处置与计划里的假设相反:实测光是 `python -c "import arm.cad.helpers"`
就会在解释器退出时段错误(exit 139,OCC 静态析构),全流程跑完也一样。所以入口
脚本尾部保留 flush + os._exit,并把原因写进注释;被 import 时不生效。

关卡(tests/):
- verify.py 一条命令跑三层:产物 md5、IK 数值 exact 段、截图。
- 截图基线重做并分级:同一构建连跑两轮实测 102 张逐字节可复现(要求 md5 相同),
  3 张线束 x-ray 不可复现(mean<=0.0017、无像素差>8)改为与参考像素比容差;
  这 3 张不再入库,免得每次跑关卡都脏 git。
- 新增 ?view=sheet / ?sheet=N / ?theme / ?guide=N 四个 URL 参数:工程图与装配向导
  占了约一半 CSS 和 700 行 JS,原先没有任何截图覆盖,拆它们等于没有关卡。
- 关卡灵敏度已验:改坏 IK 基线一个数 -> FAIL;换旧版求解器 -> 5 处 exact 差异。

实测:python -m arm.build_model 退出码 0,全部产物 md5 与基线一致,
105 视图截图全过,IK exact 段逐字节一致。
2026-10-15 03:31:20 +08:00

339 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""Assembly drawings (SVG) for the arm.
Round-2 approach, after the first version was rejected: per-part convex hulls FILLED made
the whole arm read as one blob and the balloons' leaders crossed the sheet. Now:
- parts are stroked only (no fill), so overlaps still read as separate outlines
- balloons live in two side columns sorted by projected height, each with a short leader
to the nearest point of its own outline (no long crossing leaders, no collisions)
- joint axes are drawn as dash-dot centrelines and the pitch chain is dimensioned
- the scale is computed from the actual pixels-per-mm, not hard-coded
Sheets: 01 总装侧视 02 关节局部放大(A-A) 03 爆炸轴测 04 前视
"""
import io
import json
import math
import os
import sys
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
from arm.paths import WEB, DOCS
# 这两个脚本的顶层就是"算完并写文件",被 import 时会静默重写 travel/mesh 派生产物
# (实测:一次 importlib 冒烟测试就把 docs/travel.json 重写了一遍)。这里改成直接炸。
if __name__ != "__main__":
raise RuntimeError(
"draw_sheet 是脚本:顶层代码一执行就重写产物,不能 import。"
"要用就跑 python -m arm.dataio.draw_sheet。")
D = json.load(io.open(WEB + "/mesh_arm.json", encoding="utf-8"))
P, LAY, MS = D["parts"], D["layout"], D["measured"]
MT, CT = MS["M"], MS["C"]
JOINTS = {j["id"]: j for j in D["joints"]}
def hull(pts):
pts = sorted(set(pts))
if len(pts) < 3:
return pts
def cr(o, a, b):
return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
lo = []
for p in pts:
while len(lo) >= 2 and cr(lo[-2], lo[-1], p) <= 0:
lo.pop()
lo.append(p)
up = []
for p in reversed(pts):
while len(up) >= 2 and cr(up[-2], up[-1], p) <= 0:
up.pop()
up.append(p)
return lo[:-1] + up[:-1]
def proj(p, kind):
x, y, z = p
if kind == "side":
return (x, z)
if kind == "front":
return (y, z)
if kind == "iso":
a, b = math.radians(30), math.radians(35)
u = x * math.cos(a) - y * math.sin(a)
v = -x * math.sin(a) * math.sin(b) - y * math.cos(a) * math.sin(b) + z * math.cos(b)
return (u, v)
raise ValueError(kind)
def part_pts(p, kind, ex=0.0):
vv = p["v"]
step = max(1, len(vv) // 1400)
out = []
for i in range(0, len(vv), step):
q = [vv[i][k] + p["ex"][k] * ex for k in range(3)] if ex else list(vv[i])
out.append(proj(q, kind))
return out
def nearest(h, pt):
return min(h, key=lambda q: (q[0] - pt[0]) ** 2 + (q[1] - pt[1]) ** 2)
def esc(t):
return str(t).replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
def frame(W, H, title, note, scale, sheet_id=""):
return ['<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" width="%d" height="%d" '
'class="assembly-sheet-svg" data-sheet-id="%s" data-sheet-title="%s" data-sheet-note="%s" data-sheet-scale="%s" '
'font-family="Microsoft YaHei,sans-serif">' % (W, H, W, H, sheet_id, esc(title), esc(note), scale),
'<defs>'
'<filter id="partGlow" x="-20%" y="-20%" width="140%" height="140%">'
'<feDropShadow dx="0" dy="0" stdDeviation="3.5" flood-color="#2563eb" flood-opacity="0.85"/>'
'</filter>'
'<filter id="balloonShadow" x="-20%" y="-20%" width="140%" height="140%">'
'<feDropShadow dx="0" dy="1.5" stdDeviation="1.5" flood-color="#000" flood-opacity="0.25"/>'
'</filter>'
'</defs>',
'<rect class="sheet-bg" width="%d" height="%d" fill="#fff"/>' % (W, H),
'<g class="sheet-border">',
'<rect class="sheet-border-outer" x="8" y="8" width="%d" height="%d" fill="none" stroke="#111" stroke-width="1.6"/>' % (W - 16, H - 16),
'<rect class="sheet-border-inner" x="14" y="14" width="%d" height="%d" fill="none" stroke="#111" stroke-width=".5"/>' % (W - 28, H - 28),
'<line class="sheet-title-line" x1="14" y1="%d" x2="%d" y2="%d" stroke="#111" stroke-width=".8"/>' % (H - 44, W - 15, H - 44),
'<text class="sheet-title-text" x="24" y="%d" font-size="13" font-weight="bold">%s</text>' % (H - 27, esc(title)),
'<text class="sheet-note-text" x="24" y="%d" font-size="9" fill="#444">%s</text>' % (H - 16, esc(note)),
'<line class="sheet-meta-line" x1="%d" y1="%d" x2="%d" y2="%d" stroke="#111" stroke-width=".8"/>' % (W - 268, H - 44, W - 268, H - 15),
'<text class="sheet-scale-text" x="%d" y="%d" font-size="9">比例 1:%s(按图幅适配) 单位 mm</text>' % (W - 260, H - 29, scale),
'<text class="sheet-ver-text" x="%d" y="%d" font-size="9">六电机人形机器人手臂 %s / 迭代 %s</text>' % (W - 260, H - 17, LAY["ver"], LAY["it"]),
'</g>']
def balloon(x, y, p):
idx = p.get("idx", 0)
name = esc(p.get("name", ""))
cn = esc(p.get("cn", ""))
return ('<g class="sheet-balloon" data-idx="%d" data-name="%s" data-cn="%s">'
'<circle class="balloon-circle" cx="%.0f" cy="%.0f" r="10" fill="#fff" stroke="#111" stroke-width="1"/>'
'<text class="balloon-text" x="%.0f" y="%.0f" font-size="9.5" text-anchor="middle">%d</text>'
'</g>' % (idx, name, cn, x, y, x, y + 3.4, idx))
def build_sheet(kind, title, W, H, ex=0.0, note="", axes=True, dims=None, legend=True, sheet_id=""):
shp = []
for p in P:
if p["name"].startswith("ENV_") or p["name"].startswith("S"):
continue
h = hull(part_pts(p, kind, ex))
if len(h) >= 3:
shp.append((p, h))
if not shp:
return ""
allp = [q for _, hs in shp for q in hs]
xs, ys = [q[0] for q in allp], [q[1] for q in allp]
lo_x, hi_x, lo_y, hi_y = min(xs), max(xs), min(ys), max(ys)
ML, MR = 78, (300 if legend else 18)
aw, ah = W - ML - MR - 20, H - 2 * 40 - 60
if (hi_y - lo_y) > 1.25 * (hi_x - lo_x):
aw -= 34.0 # room for the single balloon column
sc = min(aw / max(1e-6, hi_x - lo_x), ah / max(1e-6, hi_y - lo_y))
ox = ML + (aw - (hi_x - lo_x) * sc) / 2 - lo_x * sc
oy = 34 + (ah + (hi_y - lo_y) * sc) / 2 + lo_y * sc
T = lambda q: (q[0] * sc + ox, oy - q[1] * sc)
out = frame(W, H, title, note, round(1 / sc, 2), sheet_id)
if axes:
out.append('<g class="sheet-axes-group">')
for jid in ("J1", "J2", "J3", "J4", "J5"):
j = JOINTS.get(jid)
if not j:
continue
a = proj(j["rear"], kind)
b = proj([j["rear"][k] + j["d"][k] * 90 for k in range(3)], kind)
pa, pb = T(a), T(b)
out.append('<line class="sheet-axis" x1="%.0f" y1="%.0f" x2="%.0f" y2="%.0f" stroke="#0b57a4" '
'stroke-width=".8" stroke-dasharray="14 3 2 3"/>' % (pa[0], pa[1], pb[0], pb[1]))
out.append('<text class="sheet-axis-label" x="%.0f" y="%.0f" font-size="10" fill="#0b57a4">%s</text>'
% (pb[0] + 3, pb[1] - 3, jid))
out.append('</g>')
out.append('<g class="sheet-parts-group">')
for p, h in shp:
d = "M" + " L".join("%.1f %.1f" % T(q) for q in h) + "Z"
col = "#%06x" % p["color"]
idx = p.get("idx", 0)
cn = esc(p.get("cn", ""))
name = esc(p.get("name", ""))
printed = 1 if p.get("printed") else 0
attrs = 'class="sheet-part-path" data-idx="%d" data-name="%s" data-cn="%s" data-color="%s" data-printed="%d"' % (
idx, name, cn, col, printed)
if p["printed"]:
out.append('<path d="%s" fill="none" stroke="%s" stroke-width="1.35" %s/>' % (d, col, attrs))
else:
out.append('<path d="%s" fill="none" stroke="#9aa1ad" stroke-width=".7" '
'stroke-dasharray="4 2" %s/>' % (d, attrs))
out.append('</g>')
# balloons in two side columns.
side = []
for p, h in shp:
cx = sum(T(q)[0] for q in h) / len(h)
cy = sum(T(q)[1] for q in h) / len(h)
anchor = T(nearest(h, (cx, cy)))
side.append((p, anchor, cx))
tall = (hi_y - lo_y) > 1.25 * (hi_x - lo_x)
right = sorted(side, key=lambda s: s[1][1]) if tall else sorted([s for s in side if s[2] >= ox + (hi_x - lo_x) * sc / 2.0], key=lambda s: s[1][1])
left = [] if tall else sorted([s for s in side if s[2] < ox + (hi_x - lo_x) * sc / 2.0],
key=lambda s: s[1][1])
out.append('<g class="sheet-balloons-group">')
def col(lst, x):
n = max(1, len(lst))
span = H - 130
for i, (p, t, _cx) in enumerate(lst):
by = 52 + span * (i + 0.5) / n
out.append('<polyline class="sheet-leader" data-idx="%d" points="%.0f,%.0f %.0f,%.0f %.0f,%.0f" fill="none" '
'stroke="#555" stroke-width=".6"/>' % (p["idx"], t[0], t[1], (t[0] + x) / 2, by, x, by))
out.append(balloon(x, by, p))
col(left, ML - 30)
col(right, (ox + (hi_x - lo_x) * sc + 52.0) if tall else (W - MR + 44))
out.append('</g>')
if legend:
out.append('<g class="sheet-legend-box">')
rows = sorted(side, key=lambda s: s[0]["idx"])
half = (len(rows) + 1) // 2
tx, ty0 = W - MR + 74, 62
out.append('<text x="%d" y="%d" font-size="10" font-weight="bold">零 件 表</text>' % (tx, ty0 - 8))
for c in (0, 1):
for i, (p, _t, _cx) in enumerate(rows[c * half:(c + 1) * half]):
y = ty0 + 12 + i * 12.6
nm = esc(p["cn"].replace(" 半联轴器/抱箍", " 联轴器/抱箍").replace("端面齿", "")[:11])
out.append('<g class="sheet-legend-row" data-idx="%d" data-name="%s" data-cn="%s">'
'<rect class="legend-row-bg" x="%d" y="%.0f" width="102" height="12.5" fill="transparent" rx="2"/>'
'<text class="legend-row-idx" x="%d" y="%.0f" font-size="8.5">%d</text>'
'<text class="legend-row-name" x="%d" y="%.0f" font-size="8.5">%s</text>'
'</g>' % (p["idx"], esc(p.get("name","")), esc(p.get("cn","")),
tx + c * 106 - 3, y - 9.5,
tx + c * 106, y, p["idx"],
tx + c * 106 + 13, y, nm))
out.append('</g>')
if dims:
out.append('<g class="sheet-dims-group">')
for (a, b, lab, off) in dims:
pa, pb = T(proj(a, kind)), T(proj(b, kind))
y = (pa[1] + pb[1]) / 2 + off
out.append('<line class="sheet-dim-line" x1="%.0f" y1="%.0f" x2="%.0f" y2="%.0f" stroke="#0b57a4" stroke-width=".9"/>'
% (pa[0], y, pb[0], y))
for q in (pa, pb):
out.append('<line class="sheet-dim-ext" x1="%.0f" y1="%.0f" x2="%.0f" y2="%.0f" stroke="#0b57a4" '
'stroke-width=".5" stroke-dasharray="2 2"/>' % (q[0], q[1], q[0], y))
out.append('<text class="sheet-dim-text" x="%.0f" y="%.0f" font-size="10" fill="#0b57a4" text-anchor="middle">%s</text>'
% ((pa[0] + pb[0]) / 2, y - 3, lab))
out.append('</g>')
out.append('</svg>')
return "".join(out)
def joint_detail():
"""Sheet A-A: one joint drawn FROM THE MEASURED NUMBERS (not a mesh projection) with every
governing dimension called out - this is the sheet you actually build against."""
W, H = 1400, 780 # wide enough that the right-hand callouts stay on the sheet
s = 6.0
cy = 330
x0 = 250.0 # motor rear datum
x_cover = x0 + MT["cover_z"] * s # cover face = the joint's datum plane
x_hub = x_cover + CT["gap"] * s
out = frame(W, H, "A-A 回转副局部放大 — 全部尺寸为实测值(按数值绘制,非网格投影)",
"电机 → 端面齿半联轴器 A/B(12 齿,盘脸贴死为唯一轴向止动)→ 对开抱箍防脱 → "
"从动盘(复刻电机输出面)。蓝色为关节轴线。", "1/6(本图放大 6 倍)", "joint")
out.append('<line class="sheet-axis" x1="%d" y1="%d" x2="%d" y2="%d" stroke="#0b57a4" stroke-width="1.1" '
'stroke-dasharray="22 5 4 5"/>' % (x0 - 60, cy, x_hub + 190, cy))
g = ['<g class="joint-geom-group" stroke="#111" fill="none" stroke-width="1.5">']
# motor: housing Ø60 x 42, rear plate, cover Ø60.3 x 3.5
g.append('<rect class="joint-geom-part" data-part="电机机座" x="%.1f" y="%.1f" width="%.1f" height="%.1f"/>'
% (x0 + 5.5 * s, cy - 30 * s, 47 * s, 60 * s))
g.append('<rect class="joint-geom-part" data-part="尾部PCBA" x="%.1f" y="%.1f" width="%.1f" height="%.1f"/>'
% (x0, cy - MT["pcba_w"] / 2.0 * s, MT["pcba_t"] * s, MT["pcba_w"] * s))
g.append('<rect class="joint-geom-part" data-part="电机输出端盖" x="%.1f" y="%.1f" width="%.1f" height="%.1f"/>'
% (x_cover - 3.5 * s, cy - 30.15 * s, 3.5 * s, 60.3 * s))
# coupling halves (4.5 each) and the driven hub
g.append('<rect class="joint-geom-part" data-part="半联轴器A" x="%.1f" y="%.1f" width="%.1f" height="%.1f"/>' % (x_cover, cy - 27 * s, 4.5 * s, 54 * s))
g.append('<rect class="joint-geom-part" data-part="半联轴器B" x="%.1f" y="%.1f" width="%.1f" height="%.1f"/>' % (x_cover + 4.5 * s, cy - 27 * s, 4.5 * s, 54 * s))
g.append('<rect class="joint-geom-part" data-part="从动盘" x="%.1f" y="%.1f" width="%.1f" height="%.1f"/>' % (x_hub, cy - 41 * s, 24 * s, 82 * s))
# clamp band over the closed pair
g.append('<rect class="joint-geom-part" data-part="对开抱箍" x="%.1f" y="%.1f" width="%.1f" height="%.1f" stroke-dasharray="6 3"/>'
% (x_cover + 0.6 * s, cy - 30 * s, 8.4 * s, 60 * s))
g.append('</g>')
out.extend(g)
def dim(y, xa, xb, txt, col="#c00", lx=None):
out.append('<g class="sheet-dim"><line x1="%.1f" y1="%d" x2="%.1f" y2="%d" stroke="%s" stroke-width="1"/>' % (xa, y, xb, y, col))
for x in (xa, xb):
out.append('<line x1="%.1f" y1="%d" x2="%.1f" y2="%d" stroke="%s" stroke-width=".6"/>' % (x, y - 6, x, y + 6, col))
if lx is None:
out.append('<text x="%.1f" y="%d" font-size="11" fill="%s" text-anchor="middle">%s</text>'
% ((xa + xb) / 2, y - 8, col, txt))
else:
out.append('<text x="%.1f" y="%d" font-size="11" fill="%s" text-anchor="end">%s</text>'
% (lx, y - 8, col, txt))
out.append('<line x1="%.1f" y1="%d" x2="%.1f" y2="%d" stroke="%s" stroke-width=".6"/>'
% (lx + 4, y - 11, xa, y, col))
out.append('</g>')
dim(cy - 240, x0, x_cover, "整机长 %.1f" % MT["cover_z"])
dim(cy - 215, x_cover, x_hub, "两盖板面跨距 %.2f" % CT["gap"], lx=620)
dim(cy + 250, x0, x_hub, "电机尾基准 → 从动盘贴合面 = %.3f ← 全臂节距的唯一来源" % MS["HUB_FACE_Z"])
dim(cy + 215, x_cover + 0.6 * s, x_cover + 9.0 * s, "抱箍轴向 %.3f~%.3f" % tuple(MS["CLAMP_SPAN"]), lx=470)
def note(x, y, tx, ty, txt, anchor="start"):
out.append('<g class="sheet-note"><polyline points="%.1f,%.1f %.1f,%.1f %.1f,%.1f" fill="none" stroke="#555" stroke-width=".7"/>'
% (x, y, x, ty, tx, ty))
out.append('<text x="%.1f" y="%.1f" font-size="10.5" text-anchor="%s">%s</text></g>'
% (tx + (3 if anchor == "start" else -3), ty + 3.5, anchor, txt))
note(x0 + 28 * s, cy - 30 * s, 235, cy - 190, "机座 Ø%.0f x %.0f,滑配孔按 Ø%.1f"
% (MT["hous_d"], MT["hous_len"], 60.3), "end")
note(x0 + 4 * s, cy - 31.8 * s, 235, cy - 160, "尾部 PCBA 方 %.1f → 口袋 %.1f"
% (MT["pcba_w"], MT["pcba_w"] + 0.8), "end")
note(x_cover + 2.2 * s, cy - 27 * s, 900, cy - 200, "半联轴器 A(12 齿端面花键 R%.1f~%.1f,齿高 %.1f)"
% (CT["tooth_r0"], CT["tooth_r1"], CT["tooth_s"]))
note(x_cover + 6.7 * s, cy + 27 * s, 900, cy - 170, "半联轴器 B = A 绕局部 X 转 180° 后沿轴移 2xPART_Z")
note(x_hub + 12 * s, cy + 41 * s, 900, cy + 150, "从动盘:Ø%.0fx%.1f 颈孔 + Ø%.1fx5.0 中心孔 + 3xM2 @Ø%.0f"
% (MT["neck_d"], MT["neck_depth"], MT["centre_d"], MT["m2_bcd"]))
note(x_hub, cy + 30.5 * s, 900, cy + 178, "盘脸贴死 = 唯一轴向止动;齿只传扭,不定位")
note(x_hub + 0.5 * s, cy - 20 * s, 900, cy + 206,
"从动面卸荷:Ø54.6 x 0.35(B 半件背面实测越过贴合面 0.06)+ Ø36 x 1.70 导柱孔")
out.append('<text x="60" y="%d" font-size="10.5">M2 相位(时钟):电机侧 %s ;从动盘侧取镜像 %s</text>'
% (H - 62, "/".join("%.1f" % a for a in MT["m2_clock"]),
"/".join("%.1f" % a for a in [(360 - a) % 360 for a in MT["m2_clock"]])))
out.append('<text x="60" y="%d" font-size="10.5">抱箍:带宽 Ø%.0f,凸耳 R%.1f(位于关节局部 ±X,包壳在此处开口);M3 x2 @ R%.1f</text>'
% (H - 48, CT["clamp_od_r"] * 2, CT["lug_r1"], CT["bolt_r"]))
out.append('</svg>')
return "".join(out)
j2 = JOINTS["J2"]["rear"]
j4 = JOINTS["J4"]["rear"]
j5 = JOINTS["J5"]["rear"]
SH, EL, WR = (j2[1], j2[2]), (j4[1], j4[2]), (j5[1], j5[2])
DIMS_ARM = [((0, SH[0], SH[1]), (0, EL[0], EL[1]), "上臂 %.0f" % LAY["L1"], 74),
((0, EL[0], EL[1]), (0, WR[0], WR[1]), "前臂 %.0f" % LAY["L2"], 112),
((0, 0, 0), (0, WR[0], WR[1]), "腕轴离地 %.0f" % WR[1], -128)]
sheets = [
build_sheet("front", "总装图 - 正视(沿 -X 投影;肢体在纸面内,显示左右交替堆叠)", 1200, 800, 0.0,
"实线=打印件,虚线=电机/已有联轴器;点划线为关节轴线;蓝色为节距链。"
"俯仰电机一内一外交替,全部贴合面共用 Y=157 一个平面", True, DIMS_ARM, sheet_id="front"),
joint_detail(),
build_sheet("iso", "爆炸装配图(轴测)", 1200, 800, 1.0,
"爆炸量 100%:每个关节沿肢体方向逐级错开一节,电机与端面齿组件再沿各自轴线抽出 —— 自上而下即装配顺序", False, None, sheet_id="exploded"),
build_sheet("side", "总装图 - 侧视(沿 +Y 投影;俯仰轴垂直纸面)", 900, 800, 0.0,
"肩抬举(J1)轴沿 X 前后布置,与俯仰轴平行偏置 92 mm;"
"肩吊臂侧板在 |x| ≥ 64,肢体包络 |x| ≤ 60.5,故整周不碰", True, None, sheet_id="side"),
]
os.makedirs(DOCS, exist_ok=True)
for n, s in zip(("sheet_front", "sheet_joint", "sheet_exploded", "sheet_side"), sheets):
io.open(DOCS + "/" + n + ".svg", "w", encoding="utf-8").write(s)
io.open(WEB + "/sheets.jsdata", "w", encoding="utf-8").write(json.dumps(sheets, ensure_ascii=False))
print("sheets: %d (%d KB) -> docs/sheet_{front,joint,exploded,side}.svg" % (
len(sheets), sum(len(s) for s in sheets) // 1024))