10 Commits
Author SHA1 Message Date
cbb 1dfdd48792 refactor(viewer): 截图视图 107 砍到 31,docs/shots 改为纯生成落点并取消跟踪
根因:以前只删图没用,一次性机位还挂在 arm/viewer/shot.py 的 VIEWS 里,
关卡一跑就在 docs/ 里原地复活 104 张。

- 删 76 条历史排查视图:user_*(17)、boss_p04..p07、hollow_p0x、p03_light_*、
  p03_mount_*、bay_p0x_inside(含重复键)、tie_probe/after_*、j3_前端压盖_*、
  view_yz_*、svg_*、p04/p05/p06/p07_*_side、zoom_user、shot_p08_user 等
- 保留 31 条真正在验查看器本身的:13 个机位/爆炸/姿态 + 9 个工程图·向导·运动模式
  + 9 个线束(含 3 个 x-ray)
- docs/shots 从跟踪里摘掉并写进 .gitignore;docs/ 现在只剩 json/svg/md/html,没有位图
- 基线同步为 20 张逐字节 + 11 张容差,与视图集合一一对应(无孤立基线、无缺基线的视图)
- 修我自己在上一步改 shot.py 时误删的 os.makedirs(SHOTS, exist_ok=True)
  —— 目录不存在时 Edge 写不出 PNG,31 个视图全报 FAIL 0 字节

未做:完整关卡复核(截图两遍 + IK + 数据 md5)尚未跑,这次提交只保证名单自洽。
2026-10-15 12:26:08 +08:00
cbb 11205f81c7 chore: 编辑器残留与脚本自动备份取消跟踪,文件全部留在磁盘
- .bak/ 12 个 *bak20261005*/20261007 旧备份:.gitignore 第15行早就写了 .bak/,
  但跟踪优先于忽略,所以一直没生效——这次用 git rm --cached 摘掉才真正排除
- .trae/documents(2) / .workbuddy/memory(3) / .zcode/config.json / .zcodeignore:
  别的编辑器与助手的本地配置、会话记录,不是工程产物
- 定位图/ 整目录改为忽略:它只是"给用户看的核验图落点",一次性且会被反复清空,
  关卡比对的权威物在 tests/baselines/(md5 名单 + flaky 参考像素),不依赖这里
- .gitignore 里 "可用 src/ 下的脚本重新生成" 的注释同步改成 python -m arm.*

只动索引不动工作区:19 个文件仍在磁盘上(唯一不在的是已被外部删掉的屏幕录制 mp4,
现在取消跟踪后不再脏 status,历史里仍可取回)。
2026-10-15 12:10:27 +08:00
cbb 8cf830493e chore(docs): 清掉过期核验图与一次性排查图,两份手写 HTML 移入 docs/
判据=既不是 arm/viewer/shot.py 当前视图、也不在 tests/baselines/ 关卡比对里、
且不被 README/docs/webapp/arm/tests 任何文本引用。

- docs/shots 删 133 张 + docs/section_p01_seam.svg,共 16.47 MB;剩 105 张
  正好等于关卡覆盖面(92 稳定 + 13 容差),基线文件零缺失
- 定位图 删 63 张历史落点,共 9.12 MB;只保留用户录屏 mp4
- 线束走线与总线拓扑设计方案.html / 螺丝采购清单.html 从仓根移入 docs/(逐字节相同,按 rename 记录)

关卡:数据 10 项 md5 一致、IK exact 逐字节一致、92/92 逐字节 + 13/13 容差全过。
2026-10-15 12:07:50 +08:00
cbb e79cfd8e8f chore: 清掉本轮排查用的临时截图(关卡证据已在 tests/baselines/ 与 docs/shots/) 2026-10-15 05:21:21 +08:00
cbb f713355351 refactor(viewer): app.js 按职责切成 8 个普通脚本(纯切片,零语义改动)
state / harness / camera / pick / ui / sheets / render / urlparams。
经典脚本的顶层 const/let 落在同一个全局词法作用域,所以切片本身不改变任何行为——
这一步先让"文件边界"与"模块语义"两件事分离:翻 ESM 时若出问题,责任清楚在
import/export,而不是切片切错了行。

切片踩到并修掉的:urlparams.js 被切掉了 IIFE 的收尾 })();(我按 len-2 收边界),
node --check 当场报 Unexpected end of input。

截图基线分级也在这一步收敛:工程图/装配向导视图带 feGaussianBlur 发光滤镜,
同一构建两轮就有 ±1 抖动(先前扫描那两轮碰巧逐字节相同,才漏进"稳定"清单,
于是随机假 FAIL)。把 8 张工程图/向导视图连同 3 张线束 x-ray、boss_p07、
p03_mount_loose 一起转像素容差(存参考像素,阈值 mean<=0.02、单像素<=32,
约实测 20x 余量),其余 92 张仍要求逐字节相同。

关卡:92 张逐字节一致 + 13 张在噪声阈内;数据哈希与 IK exact 段一致。
2026-10-15 04:45:56 +08:00
cbb 531a293688 refactor(viewer): 单文件查看器拆成 webapp/ 多文件,走本地 http;Worker 换成真文件
前端不再是"一个 16MB HTML + 一个 2955 行模板"。现在:
  webapp/index.html      343 行 DOM 骨架,零内联数据
  webapp/styles/*.css    811 行 CSS 按段切成 8 个文件
  webapp/js/app.js       原内联主脚本逐字搬出(仍是普通脚本,S5 才改 ESM)
  webapp/js/ik-solver.js / ik-glue.js / vendor/three.min.js(原 web/ 三个文件)
  webapp/workers/ik.worker.js  真 Worker 文件
  webapp/generated/00_data.js  生成物(网格/工程图/行程/碰撞/螺钉)
  dist/机器人手臂3D查看器.html  分发用单文件,由 build_viewer 内联上面这些文件产出

入口:python -m arm.viewer.serve 后开 http://127.0.0.1:8123/
(serve 带 no-store,改完刷新即新构建,不再需要 Ctrl+F5;显式 MIME,
 .js 被 Windows 注册表判成 text/plain 会让模块静默不执行)

删掉 src/viewer_tpl.html 与 web/ 目录(旧产物移入 backups/pre_split_20261015/)。
工程图不再维护第二份 sheets.jsdata —— 直接读 draw_sheet 写出的 4 个真 SVG。

实测中修掉的问题:
- serve 的 / 原先"内部映射"到 webapp/index.html,导致文档基址是 /、相对路径的
  8 个 CSS 与 4 个 JS 全部 404(整页无样式无脚本)。改成 302 跳转。
- file:// 下 new Worker('workers/…') 构造阶段不抛错、错误以异步 onerror 到达,
  所以"构造失败才试 Blob 兜底"永远不会触发;改为按协议选路。两条路径都已实测
  backend=worker(http 走文件、file:// 走 Blob)。
- Worker 握手 3s 在 headless 并发下会误判"没起来"(截图里红徽标即此),放宽到 10s,
  并把后端状态暴露成 window.__IK_BACKEND / __IK_BACKEND_LOG 供测试直读。
- 截图竞态:4 并发 × 每页 14MB 数据时,虚拟时间预算会在页面加载完成前到期,
  拍到半初始化画面(且能"稳定地坏",旧基线里 m9_motion 就是这么混进来的)。
  并发降到 2、预算 40s、serve 加 HTTP/1.1+大缓冲+字节缓存,并用"同一构建两轮"
  重新界定稳定性:99 张逐字节一致、6 张属噪声改走像素容差(mean<=0.02、单像素<=32)。
- 关卡自身两处假通过已修:node 输出被 GBK 解码炸掉时不再算 PASS;
  线束干涉测试读不到通道源码时会 exit 2,而不是"零通道=零穿透=通过"。

关卡结果:python -m arm.build_model 退出码 0;10 项数据哈希与基线一致;
IK exact 段逐字节一致;99 张截图逐字节一致 + 6 张在噪声阈内;线束干涉测试 1800 点 0 穿透。
2026-10-15 04:31:31 +08:00
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
cbb b32d44efe0 test: 加 IK/数据/截图三重基线关卡,并刷新过期的截图基线
重构 2955 行的单文件查看器必须有"行为没变"的可判据,否则改到什么程度全靠感觉。
tests/ik_baseline.mjs 在 Node 里直接跑求解器(它本就是 UMD,带 module.exports 分支,
不需要浏览器),输出分两段:
  exact  —— 件数/顶点/三角、TCP 推断、限位、固定 FK/fkChain/orient、固定目标的
            solveDyn 解向量、selfTest/poseSelfTest 的误差与命中数。selfTest 内部走
            Math.random,这里锁成定 LCG,所以 exact 跨进程逐字节可复现。
  timing —— solveDyn 中位耗时(9 次去头 3 次预热)。本机进程间抖动实测约 ±28%,
            因此只按 ±10% 报 WARN,不当硬门。
关卡灵敏度已验:指向 scratch/arm-ik-solver_before.js 时报 5 处 exact 差异并 FAIL。

docs/shots/ 里 92 张是 10-09 生成的,早于 10-14 的重建,属过期基线;重跑 96 视图
全部 OK、0 FAIL。另发现既有非幂等问题:committed 的 mesh 是"端到端构建后再单跑一次
screw_mesh.py"的产物(螺钉 idx 118..214、ENV 件排在第 19 位),端到端构建则为
20..116、ENV 排在末尾,两者零件集合相同但顺序不同,导致 2 张线束透视图的透明排序
像素级不同。本提交以端到端构建为准。
2026-10-15 02:24:38 +08:00
cbb 7e040fc766 refactor(paths): 路径改由 __file__ 推导,不再写死 G:/Robot_v2
armcore.py 里 ROOT/WORKSPACE/OUT/WEB/DOCS 与三个外部 STEP 输入原本是硬编码
绝对路径,仓库换个盘符就全断。改成由本文件位置推出(REPO.parent 即原
G:/Robot_v2,行为逐字不变),并留 ARM_WORKSPACE / ARM_ROOT / ARM_MODEL_ROOT
三个覆盖入口。保持正斜杠输出,因为 strength_check.py:162 的
OUT.replace("/out", "/web") 依赖这个形状(该写法本身也在这次重构中清掉)。

实测关卡:连续两次 python src/arm_model.py 产物逐字节相同;
travel/collide/screws 三个 JSON 与基线 md5 一致。

同时加 pyproject.toml 声明元数据与运行依赖(不装包,入口用 python -m)。
2026-10-15 02:23:55 +08:00
cbb b67d3ce56e chore(repo): 停止跟踪 47MB 构建产物
单文件查看器不再是交付约束,就不必再把 14MB 网格与 3 份 16MB HTML
背在 git 里。取消跟踪(磁盘文件保留),重建产物不再脏 git status,
后续每步重构的关卡才能靠哈希比对而不是"看哪些文件又变了"。
2026-10-15 02:23:41 +08:00
398 changed files with 4807 additions and 129722 deletions
Binary file not shown.
-609
View File
@@ -1,609 +0,0 @@
# -*- coding: utf-8 -*-
"""Shared geometry / assembly / export library for the robot arm build.
No layout of its own: the joint frames and station positions live in
human_layout.py and arm_model.py, which import this module. What lives here is
the measured motor interface (docs/interfaces.md), the Joint class (motor slip
bore + PCBA pocket + pinch screws; driven hub mating plane at motor rear datum
+65.0 replicating the Ø36x1.0 neck / Ø6.5x4.0 centre / 3x M2 on Ø29 output so
the existing face-spline halves + split clamp bolt straight on), the part
registry, and the STEP/STL/mesh/BOM/collision export pipeline.
Run: python arm_model.py -> out/, web/mesh_arm.json, docs/
"""
import math
import os
import sys
import json
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cadquery as cq
from armcore import (M, C, V, trs, place, bb, bbox_str, cyl, ring, box, holes,
solids_of, mesh_of, MOTOR_TOL, OUT, WEB, DOCS, SRC_STEP, HALF_STEP, CLAMP_STEP)
from OCP.gp import gp_Trsf, gp_Vec, gp_Pnt, gp_Dir, gp_Ax1
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common
from OCP.GProp import GProp_GProps
from OCP.BRepGProp import BRepGProp
from OCP.STEPControl import STEPControl_Writer, STEPControl_Controller, STEPControl_StepModelType
from OCP.Interface import Interface_Static
STEPControl_Controller.Init_s()
D2R = math.pi / 180.0
VERSION = os.environ.get("ARMVER", "r1")
ITER = int(os.environ.get("ARMITER", "1"))
# ---------------------------------------------------------------- measured stations
FLANGE_Z = M["flange_z"] # 54.942
COVER_Z = M["cover_z"] # 56.000
HUB_FACE_Z = 65.0 # motor rear datum -> driven hub mating plane
CLAMP_SPAN = (FLANGE_Z + 1.66, FLANGE_Z + 9.46)
CLAMP_R_BAND = 31.0
CLAMP_R_LUG = 38.0
BORE_R = M["hous_d"] / 2.0 + 0.15 # 30.15 slip fit on the housing
CB_R = M["cover_d"] / 2.0 + 0.35 # 30.50 clearance for the cover
PCB_W = M["pcba_w"] + 0.8 # 64.40 square pocket
HUB_M2_CLOCK = [(360.0 - a) % 360.0 for a in M["m2_clock"]] # phase of the mirrored half
HUB_PILOT_D = M["neck_d"] - 0.4 # 35.6 -> the half's pilot lands in a Ø36 recess
PINCH_D = 2.6 # M3 clearance in printed material
# 承压面让位(完整背景见 Joint.pocket 的 docstring):
# REAR_LAND = 0.6 腔口直壁台肩高度,**保持原设计值不变**(让位不靠加高台肩)
# REAR_SEAT = 2.6 尾端面->承压面 = 0.6 原台肩 + 2.0 公差垫片位
# SEAT_R = 32.2 承压面段(z=-REAR_SEAT..0)的腔口半径 = PCB_W/2 = 64.4 方腔的
# **内切圆半径**。承压面下沉 2.6mm 后这一段必须让空,但绝不能连着
# 外壁 R35 一起挖掉(2026-10-04 教训,见 Joint.pocket docstring):
# 用 R35 圆腔会把 r=32.2..35 的连接壁整圈切断,P04 后端只剩
# 240~320° 方位还连着(实测),打印出来就是一道 3.6mm 宽的通槽 +
# 一片 0.5mm 厚的孤立薄片(用户截图:肩叉顶部"没连上")。
# 取 32.2 只需挖到方腔内切圆,r=32.2..35 全圈保留 ->
# 壁厚均匀的 2.8mm 环形连接壁(0/90/180/270 方向与上方方腔壁
# 齐平 seamlessly,45° 方向比方腔多留料,正好补回四角)。
# SEAT_TOP = 0.0 圆腔顶端 = 方腔起点,两者在此平接;再往上就是方腔的地盘。
# R35_SEAT = 35.0 后端板 R35 外壁(承压面外径,不可挖穿)
REAR_LAND = 0.6
REAR_SEAT = REAR_LAND + MOTOR_TOL # 2.6 尾端面->后端板承压面:公差垫片位
R35_SEAT = 35.0 # 后端板 R35 外壁(承压面外径)
SEAT_R = PCB_W / 2.0 # 32.2 承压面段腔口 = 方腔内切圆,保留外壁
SEAT_TOP = 0.0 # 圆腔顶端与方腔起点平接
PARTS = []
CENTS = {}
def add(name, cn, shape, color, printed=True, note="", qty=1):
assert shape is not None, name
lo, hi = bb(shape)
PARTS.append(dict(name=name, cn=cn, shape=shape, color=color, printed=printed,
note=note, qty=qty, bb=bbox_str(shape), vol=vol_of(shape)))
CENTS[name] = [(lo[i] + hi[i]) / 2.0 for i in range(3)]
def vol_of(sh):
p = GProp_GProps()
BRepGProp.VolumeProperties_s(sh.wrapped, p)
return p.Mass()
def common_vol(a, b):
try:
c = BRepAlgoAPI_Common(a.wrapped, b.wrapped)
c.Build()
if not c.IsDone() or c.Shape().IsNull():
return 0.0
return vol_of(cq.Shape(c.Shape()))
except Exception:
return -1.0
def T(dx=0.0, dy=0.0, dz=0.0):
t = gp_Trsf()
t.SetTranslation(gp_Vec(dx, dy, dz))
return t
def R(deg, ax, ay, az, px=0.0, py=0.0, pz=0.0):
t = gp_Trsf()
t.SetRotation(gp_Ax1(gp_Pnt(px, py, pz), gp_Dir(ax, ay, az)), deg * D2R)
return t
RX90 = R(90, 1, 0, 0)
def Ycyl(r, y0, y1, px=0.0, pz=0.0):
return place(cyl(r, -y1, -y0, px, pz), RX90)
def Ybox(x0, x1, y0, y1, z0, z1):
return place(box(x0, x1, z0, z1, -y1, -y0), RX90)
# ------------------------------------------------------- pitch-joint pivot cartridge ----
# ONE recipe for every pitch joint (J2 shoulder, J3 elbow, J5 wrist) - they are the same
# degree of freedom, so an implementation that fixes only one of them leaves the identical
# defect in the other two. See human_layout PV_* for why a cartridge is needed at all:
# measured P04 n P05 = 0.00 mm3, so J2's 38 N*m had no path into the housing except peeling
# the face-spline teeth off across the print layers.
def pivot_geom(y_face, side, h, w, gap):
"""(boss_y0, boss_y1, pocket_y0) for a cartridge against the link face at y_face.
The race pocket always opens AWAY from the link, leaving an (h-w) shoulder behind the
outer race; a printed cap ring closes the open end. The boss stops `gap` short of the
link face, or a printed face would carry the thrust and the rolling elements nothing."""
near = y_face - gap if side < 0 else y_face + gap
if side < 0:
return near - h, near, near - h
return near, near + h, near + h - w
def pivot_boss(shape, pz, y0, y1, pk0, w, px, boss_od, race_od, bore, orace_id, side):
"""Housing side: boss on the joint axis with the outer race sunk into it. The passage
beyond the pocket is reamed to the outer race bore, not to the shaft: a shaft-sized bore
would end inside the inner race and bind it, and a bound bearing carries nothing - it
just moves the failure back into the print layers."""
shape = shape.fuse(Ycyl(boss_od / 2.0, y0, y1, px, pz))
shape = shape.cut(Ycyl(race_od / 2.0, pk0 - .01, pk0 + w + .01, px, pz))
if side < 0:
shape = shape.cut(Ycyl(orace_id / 2.0 + 0.1, pk0 + w, y1 + .01, px, pz))
else:
shape = shape.cut(Ycyl(orace_id / 2.0 + 0.1, y0 - .01, pk0, px, pz))
shape = shape.cut(Ycyl(bore / 2.0 + 0.2, y0 - .01, y1 + .01, px, pz))
for a in (90.0, 210.0, 330.0): # 3x M3 cap screws, outside the outer race
u, v = 26.5 * math.cos(a * math.pi / 180.0), 26.5 * math.sin(a * math.pi / 180.0)
shape = shape.cut(Ycyl(1.25, y0 - .01, y0 + 12.0, px + u, pz + v))
return shape
def pivot_journal(shape, pz, y_link, y_tip, y_collar0, y_collar1, px, bore, collar):
"""Link side: the stub the inner race rides on, poking past the boss so the cap can bite
it, plus an integral collar the inner race bears against. The collar replaces a separate
spacer sleeve and lives in the boss's outer-race passage, so it must stay under that
diameter - the 0.1 mm it leaves is what lets the race, not the collar, take the thrust."""
a, b = sorted((y_link, y_tip))
shape = shape.fuse(Ycyl(bore / 2.0, a, b, px, pz))
c0, c1 = sorted((y_collar0, y_collar1))
return shape.fuse(Ycyl(collar / 2.0, c0, c1, px, pz))
def prism(pts2, z0, z1):
w2 = [(p[0], p[1]) for p in pts2]
return (cq.Workplane("XY", origin=(0, 0, z0))
.polyline(w2 + [w2[0]]).close().extrude(z1 - z0, combine=False)).val()
def Yplate(pts_xz, y0, y1):
return place(prism(pts_xz, -y1, -y0), RX90)
def dogbone(a, b, ra, rb, n=20):
"""Tangent hull of two discs. The outer-tangent offset angle is acos((ra-rb)/L);
using asin made the ra==rb case sweep a full circle around b and self-intersect."""
dx, dy = b[0] - a[0], b[1] - a[1]
L = math.hypot(dx, dy) or 1.0
th = math.atan2(dy, dx)
beta = math.acos(max(-1.0, min(1.0, (ra - rb) / L)))
sw_a = 2 * math.pi - 2 * beta
sw_b = 2 * beta
pts = [(a[0] + ra * math.cos(th + beta + sw_a * k / n), a[1] + ra * math.sin(th + beta + sw_a * k / n))
for k in range(n + 1)]
pts += [(b[0] + rb * math.cos(th - beta + sw_b * k / n), b[1] + rb * math.sin(th - beta + sw_b * k / n))
for k in range(1, n + 1)]
return pts
def sector(r_out, r_in, a0, a1, z0, z1, n=14):
"""Annular sector between two radii and two angles (deg) - a joint shroud segment.
Must NOT include the axis point, or this becomes a solid pie wedge that fills the joint."""
pts = [(r_out * math.cos((a0 + (a1 - a0) * k / n) * D2R),
r_out * math.sin((a0 + (a1 - a0) * k / n) * D2R)) for k in range(n + 1)]
pts += [(r_in * math.cos((a1 - (a1 - a0) * k / n) * D2R),
r_in * math.sin((a1 - (a1 - a0) * k / n) * D2R)) for k in range(n + 1)]
return prism(pts, z0, z1)
# ---------------------------------------------------------------- joint frames & interfaces
class Joint:
"""One revolute joint. `rear` = motor rear datum point, `d` = axis unit dir (motor -> hub)."""
def __init__(self, jid, cn, rear, d, xdir, lug_phase=0.0):
self.jid, self.cn = jid, cn
self.rear = rear
self.d = d
self.xdir = xdir
self.t = trs(rear, xdir, d) # motor-local -> global
self.lug_phase = lug_phase # rotation of the clamp about the axis
def L(self, shape_local):
return place(shape_local, self.t)
def at(self, s):
return (self.rear[0] + self.d[0] * s, self.rear[1] + self.d[1] * s, self.rear[2] + self.d[2] * s)
def pocket(self, shape, front_pad=0.6):
"""Cut the motor pocket out of a proximal link body.
REAR_SEAT = 电机尾端面(局部 z=0)到后端板承压面的距离。它不是定位面而是
**垫片位**:公差 MOTOR_TOL=2.0(armcore)要靠它消化 —— 电机比标称短的那一份,
就垫在尾端面与这个承压面之间(见 armcore.MOTOR_TOL 的说明)。原值 0.6 只能吃
0.6mm,短 1mm 以上的电机装不进、长 0.6mm 以上的电机会顶死后端板而压溃。
注意它同时是承压面的高度,所以让空 2.0mm 要求 t_plate >= 8
(见各零件 rear_mount_holes 的 t_plate)。
【2026-10-04 整改:承压面段改用 R35 圆腔,凸台彻底消失】
上一版把 64.4 **方**腔整体从 -0.6 加深到 -2.6(纯掏腔),方腔壁以外
z=-2.6..0 之间那圈 R35 料一动没动,于是相对新承压面整整凸起 2.6mm ——
这就是 P04~P08 后端那圈"悬空凸台"(用户截图红圈,3D 打印时该处是悬空
薄壁,既要支撑又公差难保证)。
为什么之前试"把凸台切掉"总失败(几何实测,别再重复):
- 64.4 方腔的**内切圆半径恰好 = 32.2**,所以方腔在 45° 方向已经吃穿到
r=45.5,R35 壁只剩**四条边中点附近**的料。实测 r=34 处材料沿 z 的
区间(承压面 -2.6):P06 的 0°/180° 是 -8.0..12.0(贯通,是臂身本体),
80°/100° 是 -8.0..-0.0(**只比承压面多 2.6mm,这段就是凸台**),
其余 20~160° 是 -8.0..-2.6(承压环本体,齐平)。
- 因此整段全截面切会把 0/90 方向那条**贯通到 z=12 的承力桥**一起切断
-> P05/P08 碎成 2 块(实测)。这不是"承压环是唯一连接",
而是我的刀具没区分"桥"和"悬空料"。
现在的做法(一刀,不做角度分区):
承压面所在的 z=-REAR_SEAT..0 段,腔口取 **PCB_W/2 = 32.2 的圆**
(= 64.4 方腔的内切圆半径,SEAT_R),而不是方腔、也不是 R35:
- 用方腔(64.4):方腔在 45° 方向已吃穿到 r=45.5,R35 壁只剩四条边中点
附近的料;实测那几段 r=34 处材料是 -8.0..-0.0,比承压面(-2.6)多出 2.6mm,
就是用户截图里那圈"悬空凸台"(打印要支撑、公差差)。
- 用 R35 圆腔(上一版,2026-10-04,**错**):把 r=32.2..35 的连接壁整圈切断。
实测 P04 在 z=-2.6..+1.0 只剩 240~320° 方位有料,其余 260° 全部掏空 ->
后端法兰与杯壁之间出现一道 3.6mm 宽的**通槽**,还在 225° 留下一片
0.5mm 厚的孤立薄片(用户截图:肩叉顶部"没连上")。且承压面整个悬空。
- 用 32.2 圆腔(当前):承压面段整圈挖到 -2.6,凸台消失;同时 r=32.2..35
这圈 2.8mm 厚的壁完整保留,把后端板与上方臂身连成一体。0/90/180/270
方向该壁与方腔壁齐平,45° 方向比方腔多留料把四角补实。
PCBA 方腔本体仍从 z=0 起(z<0 那段由 32.2 圆腔负责,两者在此平接)。
"""
half = PCB_W / 2.0
c = [cyl(BORE_R, 5.5, COVER_Z - 3.5), cyl(CB_R, COVER_Z - 3.5, COVER_Z + front_pad),
# 承压面段:SEAT_R(=32.2 方腔内切圆) 圆腔,从承压面挖到 z=SEAT_TOP(=0)
# 与方腔平接。挖到 32.2 而不是 35 —— 35 会把外壁整圈切断(见 docstring)
cyl(SEAT_R, -REAR_SEAT, SEAT_TOP),
# PCBA 方腔本体:从 z=0 起(承压面段那 2.6mm 由上面的圆腔负责)
box(-half, half, -half, half, 0.0, M["pcba_t"] + 0.4)]
return shape.cut(*[self.L(s) for s in c])
def rear_mount_holes(self, shape, t_plate=8.0, hole_d=2.8, cb_d=5.5, cb_depth=4.0):
"""Cut the 4x rear mounting bolt holes into a proximal link body.
Matches the 4x M3 tapped radial slots on the motor rear endplate
(电机.STEP 实测):BCD55、相位 39.9°+90k,槽宽 2.5、径向可调约 ±1.25mm。
旧版误用 35.1x40.9 矩形孔距:对角两颗偏出螺纹槽 ~4.7mm 无处咬合
(2026-10-02 用户指出"螺钉没有对齐",按 STEP 实测槽位修正)。
"""
cuts = []
for k in range(4):
a = math.radians(39.9 + 90.0 * k)
sx = 27.5 * math.cos(a)
sy = 27.5 * math.sin(a)
h = cyl(hole_d / 2.0, -t_plate - 1.0, 3.0, sx, sy)
cuts.append(self.L(h))
if cb_d > 0 and cb_depth > 0:
cb = cyl(cb_d / 2.0, -t_plate - 1.0, -t_plate + cb_depth, sx, sy)
cuts.append(self.L(cb))
return shape.cut(*cuts)
def front_collar_pocket(self, shape, r_bore=30.25, z0=50.0, z1=56.5):
"""Cut front locating collar pocket for the motor front rim."""
p = cyl(r_bore, z0, z1)
return shape.cut(self.L(p))
def pinch(self, shape, fracs=(0.26, 0.58, 0.86), r_out=48.0, n=2):
"""Radial M3 pinch screws through the boss wall onto the housing."""
cuts = []
for fr in fracs[:n]:
for a in (self.lug_phase, self.lug_phase + 180.0):
c = cyl(PINCH_D / 2.0, -1.0, r_out, 0, 0)
c = place(c, R(a, 0, 0, 1))
c = place(c, T(0, 0, fr * COVER_Z))
cuts.append(self.L(c))
return shape.cut(*cuts)
def hub_face(self):
return self.at(HUB_FACE_Z)
def cut_hub(self, shape, depth=24.0, r_clear=32.0):
"""Cut the motor-output replica into the driven hub. The mating plane sits at
motor-local z = HUB_FACE_Z, and the recesses open there and run into the driven
material (away from the motor), so that half B's pilot (0.60) and spigot (4.06)
land in them."""
H = HUB_FACE_Z
# measured on the delivered half B: past the mating plane it is Ø35.6 for 1.4 mm
# then a Ø6.3 spigot to 4.06 - the nominal neck_depth of 1.0 is 0.4 too shallow,
# which showed up as a ~160 mm3 interference at every driven hub
cuts = [cyl(M["neck_d"] / 2.0, H - 0.05, H + 1.70),
cyl(M["centre_d"] / 2.0, H - 0.05, H + 5.05),
# probed on the delivered half: its Ø54 back face sits ~0.06 mm PAST the
# nominal plane, so the driven face gets a 0.35 deep, Ø54.6 relief. That is
# also the printed-part tolerance land the joint wants.
cyl(27.30, H - 0.05, H + 0.35)]
for a in HUB_M2_CLOCK:
u = M["m2_bcd"] / 2.0 * math.cos(a * D2R)
v = M["m2_bcd"] / 2.0 * math.sin(a * D2R)
cuts.append(cyl(M["m2_tap_d"] / 2.0, H - 0.05, H + M["m2_depth"], u, v))
return shape.cut(*[self.L(s) for s in cuts])
def hub_island(self, shape, r=30.0, t=9.5):
"""Solid disc at the joint centre (the lightening dish must not break into the
interface recesses), then the output replica is cut into it. Starts exactly on the
mating plane: half B's Ø54 body ends there, so any overlap is a hard clash."""
shape = shape.fuse(self.L(cyl(r, HUB_FACE_Z, HUB_FACE_Z + t)))
shape = self.cut_hub(shape)
# two arc shrouds over the clamp band: the joint reads as a closed drum, and the
# sectors are placed so the M3 lug bolts on the joint's local +-X stay reachable
for (a0, a1) in ((34.0, 146.0), (214.0, 326.0)):
shape = shape.fuse(self.L(sector(38.0, 31.6, a0, a1, FLANGE_Z + 1.4, HUB_FACE_Z)))
return shape
def place_hardware(self, half, clamp, tag):
"""Place the existing coupling halves + split clamp for this joint.
The delivered half is authored with z=0 at the motor flange face and spans
z -3.00..8.06. B = flip 180 deg about the local X axis, then shift by
2*PART_Z, which puts its flat back face exactly on the hub mating plane
(motor-local 65.00) - verified against the measured feature bounds.
"""
tf = trs(self.at(FLANGE_Z), self.xdir, self.d)
hA = place(half, tf)
hb = place(half, R(180, 1, 0, 0))
hb = place(hb, T(0, 0, 2 * C["part_z"]))
hB = place(hb, tf)
cA = place(clamp, tf)
cB = place(place(clamp, R(180, 0, 0, 1)), tf) # the two C pieces are one ring split
# along a diametral plane: 180 deg about the JOINT axis, so both keep the same
# axial span 56.602..64.402 and sit on opposite sides of the band.
return [("%s_半联轴器A" % tag, hA), ("%s_半联轴器B" % tag, hB),
("%s_抱箍A" % tag, cA), ("%s_抱箍B" % tag, cB)]
# ---------------------------------------------------------------- printed part builders
def bx(x0, x1, y0, y1, z0, z1):
return box(min(x0, x1), max(x0, x1), min(y0, y1), max(y0, y1), min(z0, z1), max(z0, z1))
GRP = { # part -> the joint group that carries it (kinematic tree)
"P01_底座板": "root", "P02_立柱": "root", "M1_J1": "root",
"J1_半联轴器A": "root", "J1_抱箍A": "root", "J1_抱箍B": "root",
"P03_转塔": "J1", "J1_半联轴器B": "J1",
"P04_肩支架": "J1", "M2_J2": "J1", "J2_半联轴器A": "J1",
"J2_抱箍A": "J1", "J2_抱箍B": "J1",
"P05_大臂": "J2", "J2_半联轴器B": "J2", "M3_J3": "J2",
"J3_半联轴器A": "J2", "J3_抱箍A": "J2", "J3_抱箍B": "J2",
"P06_小臂": "J3", "J3_半联轴器B": "J3", "M4_J4": "J3",
"J4_半联轴器A": "J3", "J4_抱箍A": "J3", "J4_抱箍B": "J3",
"P07_腕板": "J4", "P11_爪壳": "J4", "J4_半联轴器B": "J4", "M5_J5": "J4",
"P08_指A": "J4", "P09_指B": "J4", "P10_曲柄": "J5",
}
JAW = {"P08_指A": 1.0, "P09_指B": -1.0} # sign of the jaw travel along the J5 local +X
def explode_vec(p, JS, cents, gmean):
"""Explosion offset: coupling hardware separates along its joint axis, everything
else moves radially out of the assembly centroid."""
nm = p["name"]
c = cents[nm]
for jid in ("J1", "J2", "J3", "J4"):
if nm.startswith(jid + "_半联轴器B"):
return [JS[jid].d[i] * 24.0 for i in range(3)]
if nm.startswith(jid + "_半联轴器A") or nm.startswith(jid + "_抱箍"):
k = -26.0 if "抱箍" in nm else -16.0
return [JS[jid].d[i] * k for i in range(3)]
v = [c[i] - gmean[i] for i in range(3)]
m = math.sqrt(sum(x * x for x in v)) or 1.0
return [v[i] / m * 34.0 for i in range(3)]
# ---------------------------------------------------------------- assembly
def write_step(items, path):
w = STEPControl_Writer()
for nm, sh in items:
Interface_Static.SetCVal_s('write.step.product.name', nm)
w.Transfer(sh.wrapped, STEPControl_StepModelType.STEPControl_AsIs)
w.Write(path.replace("/", "\\"))
return os.path.getsize(path)
import threading
import time
from concurrent.futures import ThreadPoolExecutor
def export(JS):
for d in (OUT, OUT + "/parts_step", OUT + "/print_stl", OUT + "/assembly", WEB, DOCS):
os.makedirs(d, exist_ok=True)
print("=" * 80, flush=True)
print("【第二阶段 2/5】并行导出 STEP / STL 制造模型 (out/parts_step, out/print_stl)...", flush=True)
print("=" * 80, flush=True)
# 1. 多线程并行导出 STEP 与 STL(复用已有标准电机 STEP,极速提速)
printed_parts = [p for p in PARTS if p["printed"]]
total_exp = len(printed_parts)
done_exp = 0
exp_lock = threading.Lock()
def export_one_part(p):
nonlocal done_exp
t0 = time.time()
fn = "%s/%s.step" % (OUT + "/parts_step", p["name"])
# 如果是非打印标准已有件且已存在,跳过冗余写盘
if not p["printed"] and os.path.exists(fn) and os.path.getsize(fn) > 1000:
pass
else:
write_step([(p["name"], p["shape"])], fn)
if p["printed"]:
stl = "%s/%s.stl" % (OUT + "/print_stl", p["name"])
cq.exporters.export(cq.Workplane(obj=p["shape"]), stl, exportType="STL")
with exp_lock:
done_exp += 1
dt = time.time() - t0
print(f" [文件导出 {done_exp}/{total_exp}] {p['name']}.step / .stl 导出完成 ({dt:.2f}s) ✓", flush=True)
return p["name"]
workers = 1
with ThreadPoolExecutor(max_workers=workers) as ex:
list(ex.map(export_one_part, PARTS))
print(f"-> 制造模型导出全部完成!已保存在 {OUT}/parts_step 与 {OUT}/print_stl\n", flush=True)
# 2. 多线程并行生成网页 3D Mesh
print("=" * 80, flush=True)
print("【第三阶段 3/5】多线程并行生成网页 3D 轻量化几何网格 (web/mesh_arm.json)...", flush=True)
print("=" * 80, flush=True)
done_mesh = 0
mesh_lock = threading.Lock()
total_parts = len(PARTS)
def mesh_one_part(p):
nonlocal done_mesh
t0 = time.time()
key = "motor" if p["name"].startswith("M") else ("coupl" if "联轴器" in p["name"] or "抱箍" in p["name"] else p["name"])
defl = 0.85 if p["name"].startswith("M") else (0.20 if key == "coupl" else 0.055)
v, t = mesh_of(p["shape"], defl)
with mesh_lock:
done_mesh += 1
n_v = len(v) // 3
n_t = len(t) // 3
dt = time.time() - t0
print(f" [网格细分 {done_mesh:2d}/{total_parts}] {p['name']:<14} 顶点: {n_v:5d} | 面片: {n_t:5d} ({dt:.2f}s) ✓", flush=True)
return (p, v, t)
with ThreadPoolExecutor(max_workers=workers) as ex:
mesh_results = list(ex.map(mesh_one_part, PARTS))
parts = []
for idx, (p, v, t) in enumerate(mesh_results):
parts.append(dict(name=p["name"], cn=p["cn"], v=v, t=t, color=p["color"],
printed=p["printed"], vol=round(p["vol"], 0), note=p["note"],
grp=GRP.get(p["name"], "root"), jaw=JAW.get(p["name"], 0.0),
ex=[round(x, 1) for x in explode_vec(p, JS, CENTS,
[sum(CENTS[k][i] for k in CENTS) / float(len(CENTS)) for i in range(3)])
],
idx=idx + 1))
joints = [dict(id=k, cn=JS[k].cn, rear=JS[k].rear, d=JS[k].d, xdir=JS[k].xdir, hub=JS[k].hub_face())
for k in JS]
d3 = lambda a, b: math.sqrt(sum((a[i] - b[i]) ** 2 for i in range(3)))
json.dump(dict(parts=parts, joints=joints,
layout=dict(ver=VERSION, it=ITER,
L1=round(d3(JS["J2"].rear, JS["J4"].rear), 1),
L2=round(d3(JS["J4"].rear, JS["J5"].rear), 1)),
measured=dict(M=M, C=C, HUB_FACE_Z=HUB_FACE_Z, CLAMP_SPAN=list(CLAMP_SPAN))),
open(WEB + "/mesh_arm.json", "w", encoding="utf-8"), ensure_ascii=False)
bom = [dict(件号=p["name"], 名称=p["cn"], 类型="3D打印" if p["printed"] else "已有/电机",
数量=1, 体积cm3=round(p["vol"] / 1000.0, 1),
质量g_PLA=round(p["vol"] * 1.24e-3, 1), 包络=p["bb"], 说明=p["note"]) for p in PARTS]
json.dump(bom, open(DOCS + "/bom.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1)
print(f"-> 3D网格与BOM清单已保存至 web/mesh_arm.json 与 docs/bom.json ✓\n", flush=True)
return bom
def collide():
ps = [p for p in PARTS if p["vol"] > 1.0]
pairs = []
MOUNT_PAIRS = {
("P03_肩吊臂_螺旋体35%_墙5底5_竖打", "M1_J1"), ("P04_肩叉_螺旋体30%_墙5底5_竖打", "M1_J1"),
("P04_肩叉_螺旋体30%_墙5底5_竖打", "M2_J2"), ("P05_大臂旋转座_螺旋体30%_墙4底5_竖打", "M2_J2"),
("P05_大臂旋转座_螺旋体30%_墙4底5_竖打", "M3_J3"), ("P06_大臂骨架_螺旋体25%_墙4底5_竖打", "M3_J3"),
("P06_大臂骨架_螺旋体25%_墙4底5_竖打", "M4_J4"), ("P07_小臂骨架_螺旋体30%_墙5底5_竖打", "M4_J4"),
("P07_小臂骨架_螺旋体30%_墙5底5_竖打", "M5_J5"), ("P08_腕偏转座_螺旋体25%_墙4底5_竖打", "M5_J5"),
("P08_腕偏转座_螺旋体25%_墙4底5_竖打", "M6_J6"), ("P09_工具法兰_螺旋体30%_墙4底5_竖打", "M6_J6"),
("P01_A_立柱下段_三角形30%_墙4底5_竖打", "P01_B_立柱中段_三角形30%_墙4底5_竖打"), ("P01_B_立柱中段_三角形30%_墙4底5_竖打", "P01_C_立柱上段_三角形30%_墙4底5_竖打"),
("P01_A_立柱下段_三角形30%_墙4底5_竖打", "P02_Center_底座中核心_螺旋体30%_墙5底6_平打"),
("P02_Center_底座中核心_螺旋体30%_墙5底6_平打", "P02_Front_前向防翻力臂_螺旋体30%_墙4底5_平打"),
("P02_Center_底座中核心_螺旋体30%_墙5底6_平打", "P02_Rear_后向防翻力臂_螺旋体30%_墙4底5_平打"),
("P01_C_立柱上段_三角形30%_墙4底5_竖打", "P03_肩吊臂_螺旋体35%_墙5底5_竖打"),
}
for i in range(len(ps)):
for j in range(i + 1, len(ps)):
a, b = ps[i], ps[j]
if a["name"].startswith("M") and b["name"].startswith("M"):
continue
if (a["name"], b["name"]) in MOUNT_PAIRS or (b["name"], a["name"]) in MOUNT_PAIRS:
continue
av, bv = bb(a["shape"]), bb(b["shape"])
if any(av[1][k] < bv[0][k] - 0.05 or bv[1][k] < av[0][k] + 0.05 for k in range(3)):
continue
pairs.append((a, b))
print("=" * 80, flush=True)
print(f"【第四阶段 4/5】多线程精确布尔碰撞检测(从 {len(ps)*(len(ps)-1)//2} 对中初筛出 {len(pairs)} 组潜在包络相交对)...", flush=True)
print("=" * 80, flush=True)
done_pair = 0
pair_lock = threading.Lock()
def check_pair(pair):
nonlocal done_pair
a, b = pair
t0 = time.time()
v = common_vol(a["shape"], b["shape"])
with pair_lock:
done_pair += 1
status = f"干涉 {v:.1f} mm³ ❌" if v > 2.0 else "零干涉 (0.0 mm³) ✓"
print(f" [干涉检测 {done_pair:2d}/{len(pairs)}] {a['name']:<14} x {b['name']:<14} : {status} ({time.time()-t0:.3f}s)", flush=True)
if v > 2.0:
try:
c = BRepAlgoAPI_Common(a["shape"].wrapped, b["shape"].wrapped)
c.SetRunParallel(True)
c.Build()
cb = cq.Shape(c.Shape())
lo, hi = bb(cb)
loc = "@[%.0f %.0f %.0f..%.0f %.0f %.0f]" % (
lo[0], lo[1], lo[2], hi[0], hi[1], hi[2])
except Exception:
loc = "?"
return (a["name"], b["name"], round(v, 1), loc)
return None
rows = []
for pair in pairs:
res = check_pair(pair)
if res is not None:
rows.append(res)
rows.sort(key=lambda r: -r[2])
json.dump([r[:3] for r in rows], open(DOCS + "/collision.json", "w", encoding="utf-8"),
ensure_ascii=False)
if len(rows) == 0:
print(f"\n-> 静态碰撞干涉检测通过: 0 对干涉!全部 {len(pairs)} 组接触面对零碰撞!\n", flush=True)
else:
print(f"\n-> 发现 {len(rows)} 对干涉!\n", flush=True)
return rows
def axial_extent(j, shape):
lo, hi = bb(shape)
vals = []
for i in (0, 1):
for k in range(3):
p = [lo[0], lo[1], lo[2]]
p[k] = hi[k] if i else lo[k]
vals.append(sum((p[q] - j.rear[q]) * j.d[q] for q in range(3)))
return min(vals), max(vals)
def verify_interfaces(half, clamp, JS):
"""Numeric self-check of the derived joint stack, before trusting the renders."""
rows = []
for k, j in JS.items():
if k == "J5":
continue
parts = dict(j.place_hardware(half, clamp, k))
a = axial_extent(j, parts["%s_半联轴器A" % k])
b = axial_extent(j, parts["%s_半联轴器B" % k])
c = axial_extent(j, parts["%s_抱箍A" % k])
hub = j.at(HUB_FACE_Z)
rows.append(dict(jid=k, halfA=[round(x, 3) for x in a], halfB=[round(x, 3) for x in b],
clamp=[round(x, 3) for x in c], hub_face=round(HUB_FACE_Z, 3),
spigot_proud=round(b[1] - HUB_FACE_Z, 3),
clamp_clear=round(c[0] - COVER_Z, 3)))
return rows
-627
View File
@@ -1,627 +0,0 @@
# -*- coding: utf-8 -*-
"""Shared geometry / assembly / export library for the robot arm build.
No layout of its own: the joint frames and station positions live in
human_layout.py and arm_model.py, which import this module. What lives here is
the measured motor interface (docs/interfaces.md), the Joint class (motor slip
bore + PCBA pocket + pinch screws; driven hub mating plane at motor rear datum
+65.0 replicating the Ø36x1.0 neck / Ø6.5x4.0 centre / 3x M2 on Ø29 output so
the existing face-spline halves + split clamp bolt straight on), the part
registry, and the STEP/STL/mesh/BOM/collision export pipeline.
Run: python arm_model.py -> out/, web/mesh_arm.json, docs/
"""
import math
import os
import sys
import json
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cadquery as cq
from armcore import (M, C, V, trs, place, bb, bbox_str, cyl, ring, box, holes,
solids_of, mesh_of, MOTOR_TOL, OUT, WEB, DOCS, SRC_STEP, HALF_STEP, CLAMP_STEP)
from OCP.gp import gp_Trsf, gp_Vec, gp_Pnt, gp_Dir, gp_Ax1
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common
from OCP.GProp import GProp_GProps
from OCP.BRepGProp import BRepGProp
from OCP.STEPControl import STEPControl_Writer, STEPControl_Controller, STEPControl_StepModelType
from OCP.Interface import Interface_Static
STEPControl_Controller.Init_s()
D2R = math.pi / 180.0
VERSION = os.environ.get("ARMVER", "r1")
ITER = int(os.environ.get("ARMITER", "1"))
# ---------------------------------------------------------------- measured stations
FLANGE_Z = M["flange_z"] # 54.942
COVER_Z = M["cover_z"] # 56.000
HUB_FACE_Z = 65.0 # motor rear datum -> driven hub mating plane
CLAMP_SPAN = (FLANGE_Z + 1.66, FLANGE_Z + 9.46)
CLAMP_R_BAND = 31.0
CLAMP_R_LUG = 38.0
BORE_R = M["hous_d"] / 2.0 + 0.15 # 30.15 slip fit on the housing
CB_R = M["cover_d"] / 2.0 + 0.35 # 30.50 clearance for the cover
PCB_W = M["pcba_w"] + 0.8 # 64.40 square pocket
HUB_M2_CLOCK = [(360.0 - a) % 360.0 for a in M["m2_clock"]] # phase of the mirrored half
HUB_PILOT_D = M["neck_d"] - 0.4 # 35.6 -> the half's pilot lands in a Ø36 recess
PINCH_D = 2.6 # M3 clearance in printed material
# 承压面让位(完整背景见 Joint.pocket 的 docstring):
# REAR_LAND = 0.6 腔口直壁台肩高度,**保持原设计值不变**(让位不靠加高台肩)
# REAR_SEAT = 2.6 尾端面->承压面 = 0.6 原台肩 + 2.0 公差垫片位
# R35_SEAT = 35.0 后端板 R35 外壁(承压面外径)
# SEAT_R = 35.0 承压面段(z=-REAR_SEAT..SEAT_TOP)的腔口半径 = **等于外壁 R35**。
# SEAT_TOP = 1.0 圆腔顶端越过电机尾端面(z=0) 1mm。
#
# 【2026-10-05 用户第三次指出:SEAT_R 必须是 35,"凸台+围挡"要一起弄平】
# 之前取 32.2(64.4 方腔的内切圆)以为这样能保住连接壁,实测**恰恰相反**:
# 32.2 与外壁 35 之间那圈 2.8mm 宽的环墙完整保留,而承压面只挖到 32.2,
# 于是这圈环墙相对承压面【凸起 2.6mm】,把电机尾端面整个围在里面 ——
# 这正是用户截图里的"一圈围挡"(P08 该圈实测 1536.9 mm3)。它不是残留,
# 而是 SEAT_R 取小值的必然产物:只要 SEAT_R < R35,环墙就一定存在。
# 用户要求:"把凸台、围挡一起弄平,做成和电机两头齐平一起的平面"。
# 故 SEAT_R 取满 R35:承压面段整圈挖到外壁,承压面变成**完整 R35 平圆盘**,
# 环墙(凸台)自然消失,电机尾端面与承压面之间再无任何凸起。
#
# 【为什么这次取 R35 不会重演2026-10-04 的"肩叉顶部没连上"】
# 那次我切的是 z=-2.6..0 且**只切到 z=0 就停**,导致环墙在 z=0..1.0 还残留一截
# 0.052cm3 的薄片(Y93.0~95.8 的半包开口处),那片薄片脱空成为独立碎块。
# 现在 SEAT_TOP=+1.0 把那一截一起吃掉:实测五件全部单实体、零碎片。
# 且承力桥改为由**臂身本体**(r>35)承担 —— 实测 band 段 r36..58 有料的方位
# P04 10/36、P05/P06/P07/P08 各 15/36,其余方位由 z=+1.0 以上的杯体连接,
# 不存在"整圈被切断"的通槽(那正是 2026-10-04 的错误形态)。
# 连带好处:承压环由「1.95mm 窄环」变「完整 R35 圆环 4.75mm」,受压面积 383->974mm²。
REAR_LAND = 0.6
REAR_SEAT = REAR_LAND + MOTOR_TOL # 2.6 尾端面->后端板承压面:公差垫片位
R35_SEAT = 35.0 # 后端板 R35 外壁(承压面外径)
SEAT_R = R35_SEAT # 35.0 承压面段腔口 = 外壁:凸台+围挡一并消除
SEAT_TOP = 1.0 # 圆腔顶端越过电机尾端面 1mm(吃掉开口处残片)
BORE_Z0 = 5.5 # 座孔起点:R35 圆腔挖到此处,与 R30.15 座孔相接
PARTS = []
CENTS = {}
def add(name, cn, shape, color, printed=True, note="", qty=1):
assert shape is not None, name
lo, hi = bb(shape)
PARTS.append(dict(name=name, cn=cn, shape=shape, color=color, printed=printed,
note=note, qty=qty, bb=bbox_str(shape), vol=vol_of(shape)))
CENTS[name] = [(lo[i] + hi[i]) / 2.0 for i in range(3)]
def vol_of(sh):
p = GProp_GProps()
BRepGProp.VolumeProperties_s(sh.wrapped, p)
return p.Mass()
def common_vol(a, b):
try:
c = BRepAlgoAPI_Common(a.wrapped, b.wrapped)
c.Build()
if not c.IsDone() or c.Shape().IsNull():
return 0.0
return vol_of(cq.Shape(c.Shape()))
except Exception:
return -1.0
def T(dx=0.0, dy=0.0, dz=0.0):
t = gp_Trsf()
t.SetTranslation(gp_Vec(dx, dy, dz))
return t
def R(deg, ax, ay, az, px=0.0, py=0.0, pz=0.0):
t = gp_Trsf()
t.SetRotation(gp_Ax1(gp_Pnt(px, py, pz), gp_Dir(ax, ay, az)), deg * D2R)
return t
RX90 = R(90, 1, 0, 0)
def Ycyl(r, y0, y1, px=0.0, pz=0.0):
return place(cyl(r, -y1, -y0, px, pz), RX90)
def Ybox(x0, x1, y0, y1, z0, z1):
return place(box(x0, x1, z0, z1, -y1, -y0), RX90)
# ------------------------------------------------------- pitch-joint pivot cartridge ----
# ONE recipe for every pitch joint (J2 shoulder, J3 elbow, J5 wrist) - they are the same
# degree of freedom, so an implementation that fixes only one of them leaves the identical
# defect in the other two. See human_layout PV_* for why a cartridge is needed at all:
# measured P04 n P05 = 0.00 mm3, so J2's 38 N*m had no path into the housing except peeling
# the face-spline teeth off across the print layers.
def pivot_geom(y_face, side, h, w, gap):
"""(boss_y0, boss_y1, pocket_y0) for a cartridge against the link face at y_face.
The race pocket always opens AWAY from the link, leaving an (h-w) shoulder behind the
outer race; a printed cap ring closes the open end. The boss stops `gap` short of the
link face, or a printed face would carry the thrust and the rolling elements nothing."""
near = y_face - gap if side < 0 else y_face + gap
if side < 0:
return near - h, near, near - h
return near, near + h, near + h - w
def pivot_boss(shape, pz, y0, y1, pk0, w, px, boss_od, race_od, bore, orace_id, side):
"""Housing side: boss on the joint axis with the outer race sunk into it. The passage
beyond the pocket is reamed to the outer race bore, not to the shaft: a shaft-sized bore
would end inside the inner race and bind it, and a bound bearing carries nothing - it
just moves the failure back into the print layers."""
shape = shape.fuse(Ycyl(boss_od / 2.0, y0, y1, px, pz))
shape = shape.cut(Ycyl(race_od / 2.0, pk0 - .01, pk0 + w + .01, px, pz))
if side < 0:
shape = shape.cut(Ycyl(orace_id / 2.0 + 0.1, pk0 + w, y1 + .01, px, pz))
else:
shape = shape.cut(Ycyl(orace_id / 2.0 + 0.1, y0 - .01, pk0, px, pz))
shape = shape.cut(Ycyl(bore / 2.0 + 0.2, y0 - .01, y1 + .01, px, pz))
for a in (90.0, 210.0, 330.0): # 3x M3 cap screws, outside the outer race
u, v = 26.5 * math.cos(a * math.pi / 180.0), 26.5 * math.sin(a * math.pi / 180.0)
shape = shape.cut(Ycyl(1.25, y0 - .01, y0 + 12.0, px + u, pz + v))
return shape
def pivot_journal(shape, pz, y_link, y_tip, y_collar0, y_collar1, px, bore, collar):
"""Link side: the stub the inner race rides on, poking past the boss so the cap can bite
it, plus an integral collar the inner race bears against. The collar replaces a separate
spacer sleeve and lives in the boss's outer-race passage, so it must stay under that
diameter - the 0.1 mm it leaves is what lets the race, not the collar, take the thrust."""
a, b = sorted((y_link, y_tip))
shape = shape.fuse(Ycyl(bore / 2.0, a, b, px, pz))
c0, c1 = sorted((y_collar0, y_collar1))
return shape.fuse(Ycyl(collar / 2.0, c0, c1, px, pz))
def prism(pts2, z0, z1):
w2 = [(p[0], p[1]) for p in pts2]
return (cq.Workplane("XY", origin=(0, 0, z0))
.polyline(w2 + [w2[0]]).close().extrude(z1 - z0, combine=False)).val()
def Yplate(pts_xz, y0, y1):
return place(prism(pts_xz, -y1, -y0), RX90)
def dogbone(a, b, ra, rb, n=20):
"""Tangent hull of two discs. The outer-tangent offset angle is acos((ra-rb)/L);
using asin made the ra==rb case sweep a full circle around b and self-intersect."""
dx, dy = b[0] - a[0], b[1] - a[1]
L = math.hypot(dx, dy) or 1.0
th = math.atan2(dy, dx)
beta = math.acos(max(-1.0, min(1.0, (ra - rb) / L)))
sw_a = 2 * math.pi - 2 * beta
sw_b = 2 * beta
pts = [(a[0] + ra * math.cos(th + beta + sw_a * k / n), a[1] + ra * math.sin(th + beta + sw_a * k / n))
for k in range(n + 1)]
pts += [(b[0] + rb * math.cos(th - beta + sw_b * k / n), b[1] + rb * math.sin(th - beta + sw_b * k / n))
for k in range(1, n + 1)]
return pts
def sector(r_out, r_in, a0, a1, z0, z1, n=14):
"""Annular sector between two radii and two angles (deg) - a joint shroud segment.
Must NOT include the axis point, or this becomes a solid pie wedge that fills the joint."""
pts = [(r_out * math.cos((a0 + (a1 - a0) * k / n) * D2R),
r_out * math.sin((a0 + (a1 - a0) * k / n) * D2R)) for k in range(n + 1)]
pts += [(r_in * math.cos((a1 - (a1 - a0) * k / n) * D2R),
r_in * math.sin((a1 - (a1 - a0) * k / n) * D2R)) for k in range(n + 1)]
return prism(pts, z0, z1)
# ---------------------------------------------------------------- joint frames & interfaces
class Joint:
"""One revolute joint. `rear` = motor rear datum point, `d` = axis unit dir (motor -> hub)."""
def __init__(self, jid, cn, rear, d, xdir, lug_phase=0.0):
self.jid, self.cn = jid, cn
self.rear = rear
self.d = d
self.xdir = xdir
self.t = trs(rear, xdir, d) # motor-local -> global
self.lug_phase = lug_phase # rotation of the clamp about the axis
def L(self, shape_local):
return place(shape_local, self.t)
def at(self, s):
return (self.rear[0] + self.d[0] * s, self.rear[1] + self.d[1] * s, self.rear[2] + self.d[2] * s)
def pocket(self, shape, front_pad=0.6):
"""Cut the motor pocket out of a proximal link body.
REAR_SEAT = 电机尾端面(局部 z=0)到后端板承压面的距离。它不是定位面而是
**垫片位**:公差 MOTOR_TOL=2.0(armcore)要靠它消化 —— 电机比标称短的那一份,
就垫在尾端面与这个承压面之间(见 armcore.MOTOR_TOL 的说明)。原值 0.6 只能吃
0.6mm,短 1mm 以上的电机装不进、长 0.6mm 以上的电机会顶死后端板而压溃。
注意它同时是承压面的高度,所以让空 2.0mm 要求 t_plate >= 8
(见各零件 rear_mount_holes 的 t_plate)。
【2026-10-04 整改:承压面段改用 R35 圆腔,凸台彻底消失】
上一版把 64.4 **方**腔整体从 -0.6 加深到 -2.6(纯掏腔),方腔壁以外
z=-2.6..0 之间那圈 R35 料一动没动,于是相对新承压面整整凸起 2.6mm ——
这就是 P04~P08 后端那圈"悬空凸台"(用户截图红圈,3D 打印时该处是悬空
薄壁,既要支撑又公差难保证)。
为什么之前试"把凸台切掉"总失败(几何实测,别再重复):
- 64.4 方腔的**内切圆半径恰好 = 32.2**,所以方腔在 45° 方向已经吃穿到
r=45.5,R35 壁只剩**四条边中点附近**的料。实测 r=34 处材料沿 z 的
区间(承压面 -2.6):P06 的 0°/180° 是 -8.0..12.0(贯通,是臂身本体),
80°/100° 是 -8.0..-0.0(**只比承压面多 2.6mm,这段就是凸台**),
其余 20~160° 是 -8.0..-2.6(承压环本体,齐平)。
- 因此整段全截面切会把 0/90 方向那条**贯通到 z=12 的承力桥**一起切断
-> P05/P08 碎成 2 块(实测)。这不是"承压环是唯一连接",
而是我的刀具没区分"桥"和"悬空料"。
现在的做法(一刀,不做角度分区):
承压面所在的 z=-REAR_SEAT..SEAT_TOP 段,腔口取 **SEAT_R = R35 = 外壁**
的圆,把整圈挖到外壁,承压面因此变成**完整 R35 平圆盘**。
- 关键:**SEAT_R 必须等于 R35**。只要 SEAT_R < R35,两者之间那圈环墙
就必然存在,且相对承压面凸起 (R35-SEAT_R) —— 那圈环墙就是用户说的
"围挡"(P08 实测 1536.9mm3),不是残留而是我的取值必然产物。
- SEAT_TOP 取 +1.0(越过电机尾端面 1mm):2026-10-04 那版取 0 时,
半包开口处(Y93.0~95.8)会残留一截 0.052cm3 薄片脱空成碎块;
抬到 +1.0 后五件全部单实体零碎片。
- 承力改由**臂身本体**承担:band 段内 r36..58 有料的方位 P04 10/36、
其余四件各 15/36,其余方位靠 z>+1.0 的杯体连接,不存在整圈切断的通槽。
(2026-10-04 那版"肩叉顶部没连上"就是通槽形态,务必不要重现。)
【2026-10-05b 去掉 64.4 方腔,电机腔内壁恢复完整圆弧面】
此前 z=0..pcba_t 还叠了一层 `box(±32.2)` 方腔,理由是"PCB 是方的"。
**该假设不成立**:实测电机 STEP 已把 PCB 体积含在本体轮廓内,不会超出
投影范围,所以方腔纯属多挖。它带来两个副作用(用户截图 P06/P07/P08 腔内观感):
1. 方腔半宽 32.2 < 外壁 R35,在四个方位留下一圈 2.8mm 环形台肩;
2. 方腔对角 45.5 > R35,四个角把外壁咬穿,形成四个凸角。
两者叠加使内壁呈"圆—方—圆"三段,与同族零件的整体圆弧语言不一致。
现改为**单一 R35 圆腔从承压面一直挖到座孔起点 BORE_Z0(=5.5)**,
与其上的 R30.15 座孔正交相接,内壁成为两段同心正圆(仅一处正常台阶),
承压面到外壁之间再无任何凸起或方角。P04 走自己那套刀具,本来就无方腔。
"""
c = [cyl(BORE_R, BORE_Z0, COVER_Z - 3.5), cyl(CB_R, COVER_Z - 3.5, COVER_Z + front_pad),
# 承压面段 + 尾部过渡段:SEAT_R(=R35 外壁) 圆腔,从承压面一路挖到座孔起点。
# 取满 R35 才能让承压面与外壁齐平成一个平面;取小于 R35 就会留下
# 一圈凸起的"围挡"(见 docstring)。
cyl(SEAT_R, -REAR_SEAT, BORE_Z0)]
return shape.cut(*[self.L(s) for s in c])
def rear_mount_holes(self, shape, t_plate=8.0, hole_d=2.8, cb_d=5.5, cb_depth=4.0):
"""Cut the 4x rear mounting bolt holes into a proximal link body.
Matches the 4x M3 tapped radial slots on the motor rear endplate
(电机.STEP 实测):BCD55、相位 39.9°+90k,槽宽 2.5、径向可调约 ±1.25mm。
旧版误用 35.1x40.9 矩形孔距:对角两颗偏出螺纹槽 ~4.7mm 无处咬合
(2026-10-02 用户指出"螺钉没有对齐",按 STEP 实测槽位修正)。
"""
cuts = []
for k in range(4):
a = math.radians(39.9 + 90.0 * k)
sx = 27.5 * math.cos(a)
sy = 27.5 * math.sin(a)
h = cyl(hole_d / 2.0, -t_plate - 1.0, 3.0, sx, sy)
cuts.append(self.L(h))
if cb_d > 0 and cb_depth > 0:
cb = cyl(cb_d / 2.0, -t_plate - 1.0, -t_plate + cb_depth, sx, sy)
cuts.append(self.L(cb))
return shape.cut(*cuts)
def front_collar_pocket(self, shape, r_bore=30.25, z0=50.0, z1=56.5):
"""Cut front locating collar pocket for the motor front rim."""
p = cyl(r_bore, z0, z1)
return shape.cut(self.L(p))
def pinch(self, shape, fracs=(0.26, 0.58, 0.86), r_out=48.0, n=2):
"""Radial M3 pinch screws through the boss wall onto the housing."""
cuts = []
for fr in fracs[:n]:
for a in (self.lug_phase, self.lug_phase + 180.0):
c = cyl(PINCH_D / 2.0, -1.0, r_out, 0, 0)
c = place(c, R(a, 0, 0, 1))
c = place(c, T(0, 0, fr * COVER_Z))
cuts.append(self.L(c))
return shape.cut(*cuts)
def hub_face(self):
return self.at(HUB_FACE_Z)
def cut_hub(self, shape, depth=24.0, r_clear=32.0):
"""Cut the motor-output replica into the driven hub. The mating plane sits at
motor-local z = HUB_FACE_Z, and the recesses open there and run into the driven
material (away from the motor), so that half B's pilot (0.60) and spigot (4.06)
land in them."""
H = HUB_FACE_Z
# measured on the delivered half B: past the mating plane it is Ø35.6 for 1.4 mm
# then a Ø6.3 spigot to 4.06 - the nominal neck_depth of 1.0 is 0.4 too shallow,
# which showed up as a ~160 mm3 interference at every driven hub
cuts = [cyl(M["neck_d"] / 2.0, H - 0.05, H + 1.70),
cyl(M["centre_d"] / 2.0, H - 0.05, H + 5.05),
# probed on the delivered half: its Ø54 back face sits ~0.06 mm PAST the
# nominal plane, so the driven face gets a 0.35 deep, Ø54.6 relief. That is
# also the printed-part tolerance land the joint wants.
cyl(27.30, H - 0.05, H + 0.35)]
for a in HUB_M2_CLOCK:
u = M["m2_bcd"] / 2.0 * math.cos(a * D2R)
v = M["m2_bcd"] / 2.0 * math.sin(a * D2R)
cuts.append(cyl(M["m2_tap_d"] / 2.0, H - 0.05, H + M["m2_depth"], u, v))
return shape.cut(*[self.L(s) for s in cuts])
def hub_island(self, shape, r=30.0, t=9.5):
"""Solid disc at the joint centre (the lightening dish must not break into the
interface recesses), then the output replica is cut into it. Starts exactly on the
mating plane: half B's Ø54 body ends there, so any overlap is a hard clash."""
shape = shape.fuse(self.L(cyl(r, HUB_FACE_Z, HUB_FACE_Z + t)))
shape = self.cut_hub(shape)
# two arc shrouds over the clamp band: the joint reads as a closed drum, and the
# sectors are placed so the M3 lug bolts on the joint's local +-X stay reachable
for (a0, a1) in ((34.0, 146.0), (214.0, 326.0)):
shape = shape.fuse(self.L(sector(38.0, 31.6, a0, a1, FLANGE_Z + 1.4, HUB_FACE_Z)))
return shape
def place_hardware(self, half, clamp, tag):
"""Place the existing coupling halves + split clamp for this joint.
The delivered half is authored with z=0 at the motor flange face and spans
z -3.00..8.06. B = flip 180 deg about the local X axis, then shift by
2*PART_Z, which puts its flat back face exactly on the hub mating plane
(motor-local 65.00) - verified against the measured feature bounds.
"""
tf = trs(self.at(FLANGE_Z), self.xdir, self.d)
hA = place(half, tf)
hb = place(half, R(180, 1, 0, 0))
hb = place(hb, T(0, 0, 2 * C["part_z"]))
hB = place(hb, tf)
cA = place(clamp, tf)
cB = place(place(clamp, R(180, 0, 0, 1)), tf) # the two C pieces are one ring split
# along a diametral plane: 180 deg about the JOINT axis, so both keep the same
# axial span 56.602..64.402 and sit on opposite sides of the band.
return [("%s_半联轴器A" % tag, hA), ("%s_半联轴器B" % tag, hB),
("%s_抱箍A" % tag, cA), ("%s_抱箍B" % tag, cB)]
# ---------------------------------------------------------------- printed part builders
def bx(x0, x1, y0, y1, z0, z1):
return box(min(x0, x1), max(x0, x1), min(y0, y1), max(y0, y1), min(z0, z1), max(z0, z1))
GRP = { # part -> the joint group that carries it (kinematic tree)
"P01_底座板": "root", "P02_立柱": "root", "M1_J1": "root",
"J1_半联轴器A": "root", "J1_抱箍A": "root", "J1_抱箍B": "root",
"P03_转塔": "J1", "J1_半联轴器B": "J1",
"P04_肩支架": "J1", "M2_J2": "J1", "J2_半联轴器A": "J1",
"J2_抱箍A": "J1", "J2_抱箍B": "J1",
"P05_大臂": "J2", "J2_半联轴器B": "J2", "M3_J3": "J2",
"J3_半联轴器A": "J2", "J3_抱箍A": "J2", "J3_抱箍B": "J2",
"P06_小臂": "J3", "J3_半联轴器B": "J3", "M4_J4": "J3",
"J4_半联轴器A": "J3", "J4_抱箍A": "J3", "J4_抱箍B": "J3",
"P07_腕板": "J4", "P11_爪壳": "J4", "J4_半联轴器B": "J4", "M5_J5": "J4",
"P08_指A": "J4", "P09_指B": "J4", "P10_曲柄": "J5",
}
JAW = {"P08_指A": 1.0, "P09_指B": -1.0} # sign of the jaw travel along the J5 local +X
def explode_vec(p, JS, cents, gmean):
"""Explosion offset: coupling hardware separates along its joint axis, everything
else moves radially out of the assembly centroid."""
nm = p["name"]
c = cents[nm]
for jid in ("J1", "J2", "J3", "J4"):
if nm.startswith(jid + "_半联轴器B"):
return [JS[jid].d[i] * 24.0 for i in range(3)]
if nm.startswith(jid + "_半联轴器A") or nm.startswith(jid + "_抱箍"):
k = -26.0 if "抱箍" in nm else -16.0
return [JS[jid].d[i] * k for i in range(3)]
v = [c[i] - gmean[i] for i in range(3)]
m = math.sqrt(sum(x * x for x in v)) or 1.0
return [v[i] / m * 34.0 for i in range(3)]
# ---------------------------------------------------------------- assembly
def write_step(items, path):
w = STEPControl_Writer()
for nm, sh in items:
Interface_Static.SetCVal_s('write.step.product.name', nm)
w.Transfer(sh.wrapped, STEPControl_StepModelType.STEPControl_AsIs)
w.Write(path.replace("/", "\\"))
return os.path.getsize(path)
import threading
import time
from concurrent.futures import ThreadPoolExecutor
def export(JS):
for d in (OUT, OUT + "/parts_step", OUT + "/print_stl", OUT + "/assembly", WEB, DOCS):
os.makedirs(d, exist_ok=True)
print("=" * 80, flush=True)
print("【第二阶段 2/5】并行导出 STEP / STL 制造模型 (out/parts_step, out/print_stl)...", flush=True)
print("=" * 80, flush=True)
# 1. 多线程并行导出 STEP 与 STL(复用已有标准电机 STEP,极速提速)
printed_parts = [p for p in PARTS if p["printed"]]
total_exp = len(printed_parts)
done_exp = 0
exp_lock = threading.Lock()
def export_one_part(p):
nonlocal done_exp
t0 = time.time()
fn = "%s/%s.step" % (OUT + "/parts_step", p["name"])
# 如果是非打印标准已有件且已存在,跳过冗余写盘
if not p["printed"] and os.path.exists(fn) and os.path.getsize(fn) > 1000:
pass
else:
write_step([(p["name"], p["shape"])], fn)
if p["printed"]:
stl = "%s/%s.stl" % (OUT + "/print_stl", p["name"])
cq.exporters.export(cq.Workplane(obj=p["shape"]), stl, exportType="STL")
with exp_lock:
done_exp += 1
dt = time.time() - t0
print(f" [文件导出 {done_exp}/{total_exp}] {p['name']}.step / .stl 导出完成 ({dt:.2f}s) ✓", flush=True)
return p["name"]
workers = 1
with ThreadPoolExecutor(max_workers=workers) as ex:
list(ex.map(export_one_part, PARTS))
print(f"-> 制造模型导出全部完成!已保存在 {OUT}/parts_step 与 {OUT}/print_stl\n", flush=True)
# 2. 多线程并行生成网页 3D Mesh
print("=" * 80, flush=True)
print("【第三阶段 3/5】多线程并行生成网页 3D 轻量化几何网格 (web/mesh_arm.json)...", flush=True)
print("=" * 80, flush=True)
done_mesh = 0
mesh_lock = threading.Lock()
total_parts = len(PARTS)
def mesh_one_part(p):
nonlocal done_mesh
t0 = time.time()
key = "motor" if p["name"].startswith("M") else ("coupl" if "联轴器" in p["name"] or "抱箍" in p["name"] else p["name"])
defl = 0.85 if p["name"].startswith("M") else (0.20 if key == "coupl" else 0.055)
v, t = mesh_of(p["shape"], defl)
with mesh_lock:
done_mesh += 1
n_v = len(v) // 3
n_t = len(t) // 3
dt = time.time() - t0
print(f" [网格细分 {done_mesh:2d}/{total_parts}] {p['name']:<14} 顶点: {n_v:5d} | 面片: {n_t:5d} ({dt:.2f}s) ✓", flush=True)
return (p, v, t)
with ThreadPoolExecutor(max_workers=workers) as ex:
mesh_results = list(ex.map(mesh_one_part, PARTS))
parts = []
for idx, (p, v, t) in enumerate(mesh_results):
parts.append(dict(name=p["name"], cn=p["cn"], v=v, t=t, color=p["color"],
printed=p["printed"], vol=round(p["vol"], 0), note=p["note"],
grp=GRP.get(p["name"], "root"), jaw=JAW.get(p["name"], 0.0),
ex=[round(x, 1) for x in explode_vec(p, JS, CENTS,
[sum(CENTS[k][i] for k in CENTS) / float(len(CENTS)) for i in range(3)])
],
idx=idx + 1))
joints = [dict(id=k, cn=JS[k].cn, rear=JS[k].rear, d=JS[k].d, xdir=JS[k].xdir, hub=JS[k].hub_face())
for k in JS]
d3 = lambda a, b: math.sqrt(sum((a[i] - b[i]) ** 2 for i in range(3)))
json.dump(dict(parts=parts, joints=joints,
layout=dict(ver=VERSION, it=ITER,
L1=round(d3(JS["J2"].rear, JS["J4"].rear), 1),
L2=round(d3(JS["J4"].rear, JS["J5"].rear), 1)),
measured=dict(M=M, C=C, HUB_FACE_Z=HUB_FACE_Z, CLAMP_SPAN=list(CLAMP_SPAN))),
open(WEB + "/mesh_arm.json", "w", encoding="utf-8"), ensure_ascii=False)
bom = [dict(件号=p["name"], 名称=p["cn"], 类型="3D打印" if p["printed"] else "已有/电机",
数量=1, 体积cm3=round(p["vol"] / 1000.0, 1),
质量g_PLA=round(p["vol"] * 1.24e-3, 1), 包络=p["bb"], 说明=p["note"]) for p in PARTS]
json.dump(bom, open(DOCS + "/bom.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1)
print(f"-> 3D网格与BOM清单已保存至 web/mesh_arm.json 与 docs/bom.json ✓\n", flush=True)
return bom
def collide():
ps = [p for p in PARTS if p["vol"] > 1.0]
pairs = []
MOUNT_PAIRS = {
("P03_肩吊臂_螺旋体35%_墙5底5_竖打", "M1_J1"), ("P04_肩叉_螺旋体30%_墙5底5_竖打", "M1_J1"),
("P04_肩叉_螺旋体30%_墙5底5_竖打", "M2_J2"), ("P05_大臂旋转座_螺旋体30%_墙4底5_竖打", "M2_J2"),
("P05_大臂旋转座_螺旋体30%_墙4底5_竖打", "M3_J3"), ("P06_大臂骨架_螺旋体25%_墙4底5_竖打", "M3_J3"),
("P06_大臂骨架_螺旋体25%_墙4底5_竖打", "M4_J4"), ("P07_小臂骨架_螺旋体30%_墙5底5_竖打", "M4_J4"),
("P07_小臂骨架_螺旋体30%_墙5底5_竖打", "M5_J5"), ("P08_腕偏转座_螺旋体25%_墙4底5_竖打", "M5_J5"),
("P08_腕偏转座_螺旋体25%_墙4底5_竖打", "M6_J6"), ("P09_工具法兰_螺旋体30%_墙4底5_竖打", "M6_J6"),
("P01_A_立柱下段_三角形30%_墙4底5_竖打", "P01_B_立柱中段_三角形30%_墙4底5_竖打"), ("P01_B_立柱中段_三角形30%_墙4底5_竖打", "P01_C_立柱上段_三角形30%_墙4底5_竖打"),
("P01_A_立柱下段_三角形30%_墙4底5_竖打", "P02_Center_底座中核心_螺旋体30%_墙5底6_平打"),
("P02_Center_底座中核心_螺旋体30%_墙5底6_平打", "P02_Front_前向防翻力臂_螺旋体30%_墙4底5_平打"),
("P02_Center_底座中核心_螺旋体30%_墙5底6_平打", "P02_Rear_后向防翻力臂_螺旋体30%_墙4底5_平打"),
("P01_C_立柱上段_三角形30%_墙4底5_竖打", "P03_肩吊臂_螺旋体35%_墙5底5_竖打"),
}
for i in range(len(ps)):
for j in range(i + 1, len(ps)):
a, b = ps[i], ps[j]
if a["name"].startswith("M") and b["name"].startswith("M"):
continue
if (a["name"], b["name"]) in MOUNT_PAIRS or (b["name"], a["name"]) in MOUNT_PAIRS:
continue
av, bv = bb(a["shape"]), bb(b["shape"])
if any(av[1][k] < bv[0][k] - 0.05 or bv[1][k] < av[0][k] + 0.05 for k in range(3)):
continue
pairs.append((a, b))
print("=" * 80, flush=True)
print(f"【第四阶段 4/5】多线程精确布尔碰撞检测(从 {len(ps)*(len(ps)-1)//2} 对中初筛出 {len(pairs)} 组潜在包络相交对)...", flush=True)
print("=" * 80, flush=True)
done_pair = 0
pair_lock = threading.Lock()
def check_pair(pair):
nonlocal done_pair
a, b = pair
t0 = time.time()
v = common_vol(a["shape"], b["shape"])
with pair_lock:
done_pair += 1
status = f"干涉 {v:.1f} mm³ ❌" if v > 2.0 else "零干涉 (0.0 mm³) ✓"
print(f" [干涉检测 {done_pair:2d}/{len(pairs)}] {a['name']:<14} x {b['name']:<14} : {status} ({time.time()-t0:.3f}s)", flush=True)
if v > 2.0:
try:
c = BRepAlgoAPI_Common(a["shape"].wrapped, b["shape"].wrapped)
c.SetRunParallel(True)
c.Build()
cb = cq.Shape(c.Shape())
lo, hi = bb(cb)
loc = "@[%.0f %.0f %.0f..%.0f %.0f %.0f]" % (
lo[0], lo[1], lo[2], hi[0], hi[1], hi[2])
except Exception:
loc = "?"
return (a["name"], b["name"], round(v, 1), loc)
return None
rows = []
for pair in pairs:
res = check_pair(pair)
if res is not None:
rows.append(res)
rows.sort(key=lambda r: -r[2])
json.dump([r[:3] for r in rows], open(DOCS + "/collision.json", "w", encoding="utf-8"),
ensure_ascii=False)
if len(rows) == 0:
print(f"\n-> 静态碰撞干涉检测通过: 0 对干涉!全部 {len(pairs)} 组接触面对零碰撞!\n", flush=True)
else:
print(f"\n-> 发现 {len(rows)} 对干涉!\n", flush=True)
return rows
def axial_extent(j, shape):
lo, hi = bb(shape)
vals = []
for i in (0, 1):
for k in range(3):
p = [lo[0], lo[1], lo[2]]
p[k] = hi[k] if i else lo[k]
vals.append(sum((p[q] - j.rear[q]) * j.d[q] for q in range(3)))
return min(vals), max(vals)
def verify_interfaces(half, clamp, JS):
"""Numeric self-check of the derived joint stack, before trusting the renders."""
rows = []
for k, j in JS.items():
if k == "J5":
continue
parts = dict(j.place_hardware(half, clamp, k))
a = axial_extent(j, parts["%s_半联轴器A" % k])
b = axial_extent(j, parts["%s_半联轴器B" % k])
c = axial_extent(j, parts["%s_抱箍A" % k])
hub = j.at(HUB_FACE_Z)
rows.append(dict(jid=k, halfA=[round(x, 3) for x in a], halfB=[round(x, 3) for x in b],
clamp=[round(x, 3) for x in c], hub_face=round(HUB_FACE_Z, 3),
spigot_proud=round(b[1] - HUB_FACE_Z, 3),
clamp_clear=round(c[0] - COVER_Z, 3)))
return rows
-627
View File
@@ -1,627 +0,0 @@
# -*- coding: utf-8 -*-
"""Shared geometry / assembly / export library for the robot arm build.
No layout of its own: the joint frames and station positions live in
human_layout.py and arm_model.py, which import this module. What lives here is
the measured motor interface (docs/interfaces.md), the Joint class (motor slip
bore + PCBA pocket + pinch screws; driven hub mating plane at motor rear datum
+65.0 replicating the Ø36x1.0 neck / Ø6.5x4.0 centre / 3x M2 on Ø29 output so
the existing face-spline halves + split clamp bolt straight on), the part
registry, and the STEP/STL/mesh/BOM/collision export pipeline.
Run: python arm_model.py -> out/, web/mesh_arm.json, docs/
"""
import math
import os
import sys
import json
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cadquery as cq
from armcore import (M, C, V, trs, place, bb, bbox_str, cyl, ring, box, holes,
solids_of, mesh_of, MOTOR_TOL, OUT, WEB, DOCS, SRC_STEP, HALF_STEP, CLAMP_STEP)
from OCP.gp import gp_Trsf, gp_Vec, gp_Pnt, gp_Dir, gp_Ax1
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common
from OCP.GProp import GProp_GProps
from OCP.BRepGProp import BRepGProp
from OCP.STEPControl import STEPControl_Writer, STEPControl_Controller, STEPControl_StepModelType
from OCP.Interface import Interface_Static
STEPControl_Controller.Init_s()
D2R = math.pi / 180.0
VERSION = os.environ.get("ARMVER", "r1")
ITER = int(os.environ.get("ARMITER", "1"))
# ---------------------------------------------------------------- measured stations
FLANGE_Z = M["flange_z"] # 54.942
COVER_Z = M["cover_z"] # 56.000
HUB_FACE_Z = 65.0 # motor rear datum -> driven hub mating plane
CLAMP_SPAN = (FLANGE_Z + 1.66, FLANGE_Z + 9.46)
CLAMP_R_BAND = 31.0
CLAMP_R_LUG = 38.0
BORE_R = M["hous_d"] / 2.0 + 0.15 # 30.15 slip fit on the housing
CB_R = M["cover_d"] / 2.0 + 0.35 # 30.50 clearance for the cover
PCB_W = M["pcba_w"] + 0.8 # 64.40 square pocket
HUB_M2_CLOCK = [(360.0 - a) % 360.0 for a in M["m2_clock"]] # phase of the mirrored half
HUB_PILOT_D = M["neck_d"] - 0.4 # 35.6 -> the half's pilot lands in a Ø36 recess
PINCH_D = 2.6 # M3 clearance in printed material
# 承压面让位(完整背景见 Joint.pocket 的 docstring):
# REAR_LAND = 0.6 腔口直壁台肩高度,**保持原设计值不变**(让位不靠加高台肩)
# REAR_SEAT = 2.6 尾端面->承压面 = 0.6 原台肩 + 2.0 公差垫片位
# R35_SEAT = 35.0 后端板 R35 外壁(承压面外径)
# SEAT_R = 35.0 承压面段(z=-REAR_SEAT..SEAT_TOP)的腔口半径 = **等于外壁 R35**。
# SEAT_TOP = 1.0 圆腔顶端越过电机尾端面(z=0) 1mm。
#
# 【2026-10-05 用户第三次指出:SEAT_R 必须是 35,"凸台+围挡"要一起弄平】
# 之前取 32.2(64.4 方腔的内切圆)以为这样能保住连接壁,实测**恰恰相反**:
# 32.2 与外壁 35 之间那圈 2.8mm 宽的环墙完整保留,而承压面只挖到 32.2,
# 于是这圈环墙相对承压面【凸起 2.6mm】,把电机尾端面整个围在里面 ——
# 这正是用户截图里的"一圈围挡"(P08 该圈实测 1536.9 mm3)。它不是残留,
# 而是 SEAT_R 取小值的必然产物:只要 SEAT_R < R35,环墙就一定存在。
# 用户要求:"把凸台、围挡一起弄平,做成和电机两头齐平一起的平面"。
# 故 SEAT_R 取满 R35:承压面段整圈挖到外壁,承压面变成**完整 R35 平圆盘**,
# 环墙(凸台)自然消失,电机尾端面与承压面之间再无任何凸起。
#
# 【为什么这次取 R35 不会重演2026-10-04 的"肩叉顶部没连上"】
# 那次我切的是 z=-2.6..0 且**只切到 z=0 就停**,导致环墙在 z=0..1.0 还残留一截
# 0.052cm3 的薄片(Y93.0~95.8 的半包开口处),那片薄片脱空成为独立碎块。
# 现在 SEAT_TOP=+1.0 把那一截一起吃掉:实测五件全部单实体、零碎片。
# 且承力桥改为由**臂身本体**(r>35)承担 —— 实测 band 段 r36..58 有料的方位
# P04 10/36、P05/P06/P07/P08 各 15/36,其余方位由 z=+1.0 以上的杯体连接,
# 不存在"整圈被切断"的通槽(那正是 2026-10-04 的错误形态)。
# 连带好处:承压环由「1.95mm 窄环」变「完整 R35 圆环 4.75mm」,受压面积 383->974mm²。
REAR_LAND = 0.6
REAR_SEAT = REAR_LAND + MOTOR_TOL # 2.6 尾端面->后端板承压面:公差垫片位
R35_SEAT = 35.0 # 后端板 R35 外壁(承压面外径)
SEAT_R = R35_SEAT # 35.0 承压面段腔口 = 外壁:凸台+围挡一并消除
SEAT_TOP = 1.0 # 圆腔顶端越过电机尾端面 1mm(吃掉开口处残片)
BORE_Z0 = 5.5 # 座孔起点:R35 圆腔挖到此处,与 R30.15 座孔相接
PARTS = []
CENTS = {}
def add(name, cn, shape, color, printed=True, note="", qty=1):
assert shape is not None, name
lo, hi = bb(shape)
PARTS.append(dict(name=name, cn=cn, shape=shape, color=color, printed=printed,
note=note, qty=qty, bb=bbox_str(shape), vol=vol_of(shape)))
CENTS[name] = [(lo[i] + hi[i]) / 2.0 for i in range(3)]
def vol_of(sh):
p = GProp_GProps()
BRepGProp.VolumeProperties_s(sh.wrapped, p)
return p.Mass()
def common_vol(a, b):
try:
c = BRepAlgoAPI_Common(a.wrapped, b.wrapped)
c.Build()
if not c.IsDone() or c.Shape().IsNull():
return 0.0
return vol_of(cq.Shape(c.Shape()))
except Exception:
return -1.0
def T(dx=0.0, dy=0.0, dz=0.0):
t = gp_Trsf()
t.SetTranslation(gp_Vec(dx, dy, dz))
return t
def R(deg, ax, ay, az, px=0.0, py=0.0, pz=0.0):
t = gp_Trsf()
t.SetRotation(gp_Ax1(gp_Pnt(px, py, pz), gp_Dir(ax, ay, az)), deg * D2R)
return t
RX90 = R(90, 1, 0, 0)
def Ycyl(r, y0, y1, px=0.0, pz=0.0):
return place(cyl(r, -y1, -y0, px, pz), RX90)
def Ybox(x0, x1, y0, y1, z0, z1):
return place(box(x0, x1, z0, z1, -y1, -y0), RX90)
# ------------------------------------------------------- pitch-joint pivot cartridge ----
# ONE recipe for every pitch joint (J2 shoulder, J3 elbow, J5 wrist) - they are the same
# degree of freedom, so an implementation that fixes only one of them leaves the identical
# defect in the other two. See human_layout PV_* for why a cartridge is needed at all:
# measured P04 n P05 = 0.00 mm3, so J2's 38 N*m had no path into the housing except peeling
# the face-spline teeth off across the print layers.
def pivot_geom(y_face, side, h, w, gap):
"""(boss_y0, boss_y1, pocket_y0) for a cartridge against the link face at y_face.
The race pocket always opens AWAY from the link, leaving an (h-w) shoulder behind the
outer race; a printed cap ring closes the open end. The boss stops `gap` short of the
link face, or a printed face would carry the thrust and the rolling elements nothing."""
near = y_face - gap if side < 0 else y_face + gap
if side < 0:
return near - h, near, near - h
return near, near + h, near + h - w
def pivot_boss(shape, pz, y0, y1, pk0, w, px, boss_od, race_od, bore, orace_id, side):
"""Housing side: boss on the joint axis with the outer race sunk into it. The passage
beyond the pocket is reamed to the outer race bore, not to the shaft: a shaft-sized bore
would end inside the inner race and bind it, and a bound bearing carries nothing - it
just moves the failure back into the print layers."""
shape = shape.fuse(Ycyl(boss_od / 2.0, y0, y1, px, pz))
shape = shape.cut(Ycyl(race_od / 2.0, pk0 - .01, pk0 + w + .01, px, pz))
if side < 0:
shape = shape.cut(Ycyl(orace_id / 2.0 + 0.1, pk0 + w, y1 + .01, px, pz))
else:
shape = shape.cut(Ycyl(orace_id / 2.0 + 0.1, y0 - .01, pk0, px, pz))
shape = shape.cut(Ycyl(bore / 2.0 + 0.2, y0 - .01, y1 + .01, px, pz))
for a in (90.0, 210.0, 330.0): # 3x M3 cap screws, outside the outer race
u, v = 26.5 * math.cos(a * math.pi / 180.0), 26.5 * math.sin(a * math.pi / 180.0)
shape = shape.cut(Ycyl(1.25, y0 - .01, y0 + 12.0, px + u, pz + v))
return shape
def pivot_journal(shape, pz, y_link, y_tip, y_collar0, y_collar1, px, bore, collar):
"""Link side: the stub the inner race rides on, poking past the boss so the cap can bite
it, plus an integral collar the inner race bears against. The collar replaces a separate
spacer sleeve and lives in the boss's outer-race passage, so it must stay under that
diameter - the 0.1 mm it leaves is what lets the race, not the collar, take the thrust."""
a, b = sorted((y_link, y_tip))
shape = shape.fuse(Ycyl(bore / 2.0, a, b, px, pz))
c0, c1 = sorted((y_collar0, y_collar1))
return shape.fuse(Ycyl(collar / 2.0, c0, c1, px, pz))
def prism(pts2, z0, z1):
w2 = [(p[0], p[1]) for p in pts2]
return (cq.Workplane("XY", origin=(0, 0, z0))
.polyline(w2 + [w2[0]]).close().extrude(z1 - z0, combine=False)).val()
def Yplate(pts_xz, y0, y1):
return place(prism(pts_xz, -y1, -y0), RX90)
def dogbone(a, b, ra, rb, n=20):
"""Tangent hull of two discs. The outer-tangent offset angle is acos((ra-rb)/L);
using asin made the ra==rb case sweep a full circle around b and self-intersect."""
dx, dy = b[0] - a[0], b[1] - a[1]
L = math.hypot(dx, dy) or 1.0
th = math.atan2(dy, dx)
beta = math.acos(max(-1.0, min(1.0, (ra - rb) / L)))
sw_a = 2 * math.pi - 2 * beta
sw_b = 2 * beta
pts = [(a[0] + ra * math.cos(th + beta + sw_a * k / n), a[1] + ra * math.sin(th + beta + sw_a * k / n))
for k in range(n + 1)]
pts += [(b[0] + rb * math.cos(th - beta + sw_b * k / n), b[1] + rb * math.sin(th - beta + sw_b * k / n))
for k in range(1, n + 1)]
return pts
def sector(r_out, r_in, a0, a1, z0, z1, n=14):
"""Annular sector between two radii and two angles (deg) - a joint shroud segment.
Must NOT include the axis point, or this becomes a solid pie wedge that fills the joint."""
pts = [(r_out * math.cos((a0 + (a1 - a0) * k / n) * D2R),
r_out * math.sin((a0 + (a1 - a0) * k / n) * D2R)) for k in range(n + 1)]
pts += [(r_in * math.cos((a1 - (a1 - a0) * k / n) * D2R),
r_in * math.sin((a1 - (a1 - a0) * k / n) * D2R)) for k in range(n + 1)]
return prism(pts, z0, z1)
# ---------------------------------------------------------------- joint frames & interfaces
class Joint:
"""One revolute joint. `rear` = motor rear datum point, `d` = axis unit dir (motor -> hub)."""
def __init__(self, jid, cn, rear, d, xdir, lug_phase=0.0):
self.jid, self.cn = jid, cn
self.rear = rear
self.d = d
self.xdir = xdir
self.t = trs(rear, xdir, d) # motor-local -> global
self.lug_phase = lug_phase # rotation of the clamp about the axis
def L(self, shape_local):
return place(shape_local, self.t)
def at(self, s):
return (self.rear[0] + self.d[0] * s, self.rear[1] + self.d[1] * s, self.rear[2] + self.d[2] * s)
def pocket(self, shape, front_pad=0.6):
"""Cut the motor pocket out of a proximal link body.
REAR_SEAT = 电机尾端面(局部 z=0)到后端板承压面的距离。它不是定位面而是
**垫片位**:公差 MOTOR_TOL=2.0(armcore)要靠它消化 —— 电机比标称短的那一份,
就垫在尾端面与这个承压面之间(见 armcore.MOTOR_TOL 的说明)。原值 0.6 只能吃
0.6mm,短 1mm 以上的电机装不进、长 0.6mm 以上的电机会顶死后端板而压溃。
注意它同时是承压面的高度,所以让空 2.0mm 要求 t_plate >= 8
(见各零件 rear_mount_holes 的 t_plate)。
【2026-10-04 整改:承压面段改用 R35 圆腔,凸台彻底消失】
上一版把 64.4 **方**腔整体从 -0.6 加深到 -2.6(纯掏腔),方腔壁以外
z=-2.6..0 之间那圈 R35 料一动没动,于是相对新承压面整整凸起 2.6mm ——
这就是 P04~P08 后端那圈"悬空凸台"(用户截图红圈,3D 打印时该处是悬空
薄壁,既要支撑又公差难保证)。
为什么之前试"把凸台切掉"总失败(几何实测,别再重复):
- 64.4 方腔的**内切圆半径恰好 = 32.2**,所以方腔在 45° 方向已经吃穿到
r=45.5,R35 壁只剩**四条边中点附近**的料。实测 r=34 处材料沿 z 的
区间(承压面 -2.6):P06 的 0°/180° 是 -8.0..12.0(贯通,是臂身本体),
80°/100° 是 -8.0..-0.0(**只比承压面多 2.6mm,这段就是凸台**),
其余 20~160° 是 -8.0..-2.6(承压环本体,齐平)。
- 因此整段全截面切会把 0/90 方向那条**贯通到 z=12 的承力桥**一起切断
-> P05/P08 碎成 2 块(实测)。这不是"承压环是唯一连接",
而是我的刀具没区分"桥"和"悬空料"。
现在的做法(一刀,不做角度分区):
承压面所在的 z=-REAR_SEAT..SEAT_TOP 段,腔口取 **SEAT_R = R35 = 外壁**
的圆,把整圈挖到外壁,承压面因此变成**完整 R35 平圆盘**。
- 关键:**SEAT_R 必须等于 R35**。只要 SEAT_R < R35,两者之间那圈环墙
就必然存在,且相对承压面凸起 (R35-SEAT_R) —— 那圈环墙就是用户说的
"围挡"(P08 实测 1536.9mm3),不是残留而是我的取值必然产物。
- SEAT_TOP 取 +1.0(越过电机尾端面 1mm):2026-10-04 那版取 0 时,
半包开口处(Y93.0~95.8)会残留一截 0.052cm3 薄片脱空成碎块;
抬到 +1.0 后五件全部单实体零碎片。
- 承力改由**臂身本体**承担:band 段内 r36..58 有料的方位 P04 10/36、
其余四件各 15/36,其余方位靠 z>+1.0 的杯体连接,不存在整圈切断的通槽。
(2026-10-04 那版"肩叉顶部没连上"就是通槽形态,务必不要重现。)
【2026-10-05b 去掉 64.4 方腔,电机腔内壁恢复完整圆弧面】
此前 z=0..pcba_t 还叠了一层 `box(±32.2)` 方腔,理由是"PCB 是方的"。
**该假设不成立**:实测电机 STEP 已把 PCB 体积含在本体轮廓内,不会超出
投影范围,所以方腔纯属多挖。它带来两个副作用(用户截图 P06/P07/P08 腔内观感):
1. 方腔半宽 32.2 < 外壁 R35,在四个方位留下一圈 2.8mm 环形台肩;
2. 方腔对角 45.5 > R35,四个角把外壁咬穿,形成四个凸角。
两者叠加使内壁呈"圆—方—圆"三段,与同族零件的整体圆弧语言不一致。
现改为**单一 R35 圆腔从承压面一直挖到座孔起点 BORE_Z0(=5.5)**,
与其上的 R30.15 座孔正交相接,内壁成为两段同心正圆(仅一处正常台阶),
承压面到外壁之间再无任何凸起或方角。P04 走自己那套刀具,本来就无方腔。
"""
c = [cyl(BORE_R, BORE_Z0, COVER_Z - 3.5), cyl(CB_R, COVER_Z - 3.5, COVER_Z + front_pad),
# 承压面段 + 尾部过渡段:SEAT_R(=R35 外壁) 圆腔,从承压面一路挖到座孔起点。
# 取满 R35 才能让承压面与外壁齐平成一个平面;取小于 R35 就会留下
# 一圈凸起的"围挡"(见 docstring)。
cyl(SEAT_R, -REAR_SEAT, BORE_Z0)]
return shape.cut(*[self.L(s) for s in c])
def rear_mount_holes(self, shape, t_plate=8.0, hole_d=2.8, cb_d=5.5, cb_depth=4.0):
"""Cut the 4x rear mounting bolt holes into a proximal link body.
Matches the 4x M3 tapped radial slots on the motor rear endplate
(电机.STEP 实测):BCD55、相位 39.9°+90k,槽宽 2.5、径向可调约 ±1.25mm。
旧版误用 35.1x40.9 矩形孔距:对角两颗偏出螺纹槽 ~4.7mm 无处咬合
(2026-10-02 用户指出"螺钉没有对齐",按 STEP 实测槽位修正)。
"""
cuts = []
for k in range(4):
a = math.radians(39.9 + 90.0 * k)
sx = 27.5 * math.cos(a)
sy = 27.5 * math.sin(a)
h = cyl(hole_d / 2.0, -t_plate - 1.0, 3.0, sx, sy)
cuts.append(self.L(h))
if cb_d > 0 and cb_depth > 0:
cb = cyl(cb_d / 2.0, -t_plate - 1.0, -t_plate + cb_depth, sx, sy)
cuts.append(self.L(cb))
return shape.cut(*cuts)
def front_collar_pocket(self, shape, r_bore=30.25, z0=50.0, z1=56.5):
"""Cut front locating collar pocket for the motor front rim."""
p = cyl(r_bore, z0, z1)
return shape.cut(self.L(p))
def pinch(self, shape, fracs=(0.26, 0.58, 0.86), r_out=48.0, n=2):
"""Radial M3 pinch screws through the boss wall onto the housing."""
cuts = []
for fr in fracs[:n]:
for a in (self.lug_phase, self.lug_phase + 180.0):
c = cyl(PINCH_D / 2.0, -1.0, r_out, 0, 0)
c = place(c, R(a, 0, 0, 1))
c = place(c, T(0, 0, fr * COVER_Z))
cuts.append(self.L(c))
return shape.cut(*cuts)
def hub_face(self):
return self.at(HUB_FACE_Z)
def cut_hub(self, shape, depth=24.0, r_clear=32.0):
"""Cut the motor-output replica into the driven hub. The mating plane sits at
motor-local z = HUB_FACE_Z, and the recesses open there and run into the driven
material (away from the motor), so that half B's pilot (0.60) and spigot (4.06)
land in them."""
H = HUB_FACE_Z
# measured on the delivered half B: past the mating plane it is Ø35.6 for 1.4 mm
# then a Ø6.3 spigot to 4.06 - the nominal neck_depth of 1.0 is 0.4 too shallow,
# which showed up as a ~160 mm3 interference at every driven hub
cuts = [cyl(M["neck_d"] / 2.0, H - 0.05, H + 1.70),
cyl(M["centre_d"] / 2.0, H - 0.05, H + 5.05),
# probed on the delivered half: its Ø54 back face sits ~0.06 mm PAST the
# nominal plane, so the driven face gets a 0.35 deep, Ø54.6 relief. That is
# also the printed-part tolerance land the joint wants.
cyl(27.30, H - 0.05, H + 0.35)]
for a in HUB_M2_CLOCK:
u = M["m2_bcd"] / 2.0 * math.cos(a * D2R)
v = M["m2_bcd"] / 2.0 * math.sin(a * D2R)
cuts.append(cyl(M["m2_tap_d"] / 2.0, H - 0.05, H + M["m2_depth"], u, v))
return shape.cut(*[self.L(s) for s in cuts])
def hub_island(self, shape, r=30.0, t=9.5):
"""Solid disc at the joint centre (the lightening dish must not break into the
interface recesses), then the output replica is cut into it. Starts exactly on the
mating plane: half B's Ø54 body ends there, so any overlap is a hard clash."""
shape = shape.fuse(self.L(cyl(r, HUB_FACE_Z, HUB_FACE_Z + t)))
shape = self.cut_hub(shape)
# two arc shrouds over the clamp band: the joint reads as a closed drum, and the
# sectors are placed so the M3 lug bolts on the joint's local +-X stay reachable
for (a0, a1) in ((34.0, 146.0), (214.0, 326.0)):
shape = shape.fuse(self.L(sector(38.0, 31.6, a0, a1, FLANGE_Z + 1.4, HUB_FACE_Z)))
return shape
def place_hardware(self, half, clamp, tag):
"""Place the existing coupling halves + split clamp for this joint.
The delivered half is authored with z=0 at the motor flange face and spans
z -3.00..8.06. B = flip 180 deg about the local X axis, then shift by
2*PART_Z, which puts its flat back face exactly on the hub mating plane
(motor-local 65.00) - verified against the measured feature bounds.
"""
tf = trs(self.at(FLANGE_Z), self.xdir, self.d)
hA = place(half, tf)
hb = place(half, R(180, 1, 0, 0))
hb = place(hb, T(0, 0, 2 * C["part_z"]))
hB = place(hb, tf)
cA = place(clamp, tf)
cB = place(place(clamp, R(180, 0, 0, 1)), tf) # the two C pieces are one ring split
# along a diametral plane: 180 deg about the JOINT axis, so both keep the same
# axial span 56.602..64.402 and sit on opposite sides of the band.
return [("%s_半联轴器A" % tag, hA), ("%s_半联轴器B" % tag, hB),
("%s_抱箍A" % tag, cA), ("%s_抱箍B" % tag, cB)]
# ---------------------------------------------------------------- printed part builders
def bx(x0, x1, y0, y1, z0, z1):
return box(min(x0, x1), max(x0, x1), min(y0, y1), max(y0, y1), min(z0, z1), max(z0, z1))
GRP = { # part -> the joint group that carries it (kinematic tree)
"P01_底座板": "root", "P02_立柱": "root", "M1_J1": "root",
"J1_半联轴器A": "root", "J1_抱箍A": "root", "J1_抱箍B": "root",
"P03_转塔": "J1", "J1_半联轴器B": "J1",
"P04_肩支架": "J1", "M2_J2": "J1", "J2_半联轴器A": "J1",
"J2_抱箍A": "J1", "J2_抱箍B": "J1",
"P05_大臂": "J2", "J2_半联轴器B": "J2", "M3_J3": "J2",
"J3_半联轴器A": "J2", "J3_抱箍A": "J2", "J3_抱箍B": "J2",
"P06_小臂": "J3", "J3_半联轴器B": "J3", "M4_J4": "J3",
"J4_半联轴器A": "J3", "J4_抱箍A": "J3", "J4_抱箍B": "J3",
"P07_腕板": "J4", "P11_爪壳": "J4", "J4_半联轴器B": "J4", "M5_J5": "J4",
"P08_指A": "J4", "P09_指B": "J4", "P10_曲柄": "J5",
}
JAW = {"P08_指A": 1.0, "P09_指B": -1.0} # sign of the jaw travel along the J5 local +X
def explode_vec(p, JS, cents, gmean):
"""Explosion offset: coupling hardware separates along its joint axis, everything
else moves radially out of the assembly centroid."""
nm = p["name"]
c = cents[nm]
for jid in ("J1", "J2", "J3", "J4"):
if nm.startswith(jid + "_半联轴器B"):
return [JS[jid].d[i] * 24.0 for i in range(3)]
if nm.startswith(jid + "_半联轴器A") or nm.startswith(jid + "_抱箍"):
k = -26.0 if "抱箍" in nm else -16.0
return [JS[jid].d[i] * k for i in range(3)]
v = [c[i] - gmean[i] for i in range(3)]
m = math.sqrt(sum(x * x for x in v)) or 1.0
return [v[i] / m * 34.0 for i in range(3)]
# ---------------------------------------------------------------- assembly
def write_step(items, path):
w = STEPControl_Writer()
for nm, sh in items:
Interface_Static.SetCVal_s('write.step.product.name', nm)
w.Transfer(sh.wrapped, STEPControl_StepModelType.STEPControl_AsIs)
w.Write(path.replace("/", "\\"))
return os.path.getsize(path)
import threading
import time
from concurrent.futures import ThreadPoolExecutor
def export(JS):
for d in (OUT, OUT + "/parts_step", OUT + "/print_stl", OUT + "/assembly", WEB, DOCS):
os.makedirs(d, exist_ok=True)
print("=" * 80, flush=True)
print("【第二阶段 2/5】并行导出 STEP / STL 制造模型 (out/parts_step, out/print_stl)...", flush=True)
print("=" * 80, flush=True)
# 1. 多线程并行导出 STEP 与 STL(复用已有标准电机 STEP,极速提速)
printed_parts = [p for p in PARTS if p["printed"]]
total_exp = len(printed_parts)
done_exp = 0
exp_lock = threading.Lock()
def export_one_part(p):
nonlocal done_exp
t0 = time.time()
fn = "%s/%s.step" % (OUT + "/parts_step", p["name"])
# 如果是非打印标准已有件且已存在,跳过冗余写盘
if not p["printed"] and os.path.exists(fn) and os.path.getsize(fn) > 1000:
pass
else:
write_step([(p["name"], p["shape"])], fn)
if p["printed"]:
stl = "%s/%s.stl" % (OUT + "/print_stl", p["name"])
cq.exporters.export(cq.Workplane(obj=p["shape"]), stl, exportType="STL")
with exp_lock:
done_exp += 1
dt = time.time() - t0
print(f" [文件导出 {done_exp}/{total_exp}] {p['name']}.step / .stl 导出完成 ({dt:.2f}s) ✓", flush=True)
return p["name"]
workers = 1
with ThreadPoolExecutor(max_workers=workers) as ex:
list(ex.map(export_one_part, PARTS))
print(f"-> 制造模型导出全部完成!已保存在 {OUT}/parts_step 与 {OUT}/print_stl\n", flush=True)
# 2. 多线程并行生成网页 3D Mesh
print("=" * 80, flush=True)
print("【第三阶段 3/5】多线程并行生成网页 3D 轻量化几何网格 (web/mesh_arm.json)...", flush=True)
print("=" * 80, flush=True)
done_mesh = 0
mesh_lock = threading.Lock()
total_parts = len(PARTS)
def mesh_one_part(p):
nonlocal done_mesh
t0 = time.time()
key = "motor" if p["name"].startswith("M") else ("coupl" if "联轴器" in p["name"] or "抱箍" in p["name"] else p["name"])
defl = 0.85 if p["name"].startswith("M") else (0.20 if key == "coupl" else 0.055)
v, t = mesh_of(p["shape"], defl)
with mesh_lock:
done_mesh += 1
n_v = len(v) // 3
n_t = len(t) // 3
dt = time.time() - t0
print(f" [网格细分 {done_mesh:2d}/{total_parts}] {p['name']:<14} 顶点: {n_v:5d} | 面片: {n_t:5d} ({dt:.2f}s) ✓", flush=True)
return (p, v, t)
with ThreadPoolExecutor(max_workers=workers) as ex:
mesh_results = list(ex.map(mesh_one_part, PARTS))
parts = []
for idx, (p, v, t) in enumerate(mesh_results):
parts.append(dict(name=p["name"], cn=p["cn"], v=v, t=t, color=p["color"],
printed=p["printed"], vol=round(p["vol"], 0), note=p["note"],
grp=GRP.get(p["name"], "root"), jaw=JAW.get(p["name"], 0.0),
ex=[round(x, 1) for x in explode_vec(p, JS, CENTS,
[sum(CENTS[k][i] for k in CENTS) / float(len(CENTS)) for i in range(3)])
],
idx=idx + 1))
joints = [dict(id=k, cn=JS[k].cn, rear=JS[k].rear, d=JS[k].d, xdir=JS[k].xdir, hub=JS[k].hub_face())
for k in JS]
d3 = lambda a, b: math.sqrt(sum((a[i] - b[i]) ** 2 for i in range(3)))
json.dump(dict(parts=parts, joints=joints,
layout=dict(ver=VERSION, it=ITER,
L1=round(d3(JS["J2"].rear, JS["J4"].rear), 1),
L2=round(d3(JS["J4"].rear, JS["J5"].rear), 1)),
measured=dict(M=M, C=C, HUB_FACE_Z=HUB_FACE_Z, CLAMP_SPAN=list(CLAMP_SPAN))),
open(WEB + "/mesh_arm.json", "w", encoding="utf-8"), ensure_ascii=False)
bom = [dict(件号=p["name"], 名称=p["cn"], 类型="3D打印" if p["printed"] else "已有/电机",
数量=1, 体积cm3=round(p["vol"] / 1000.0, 1),
质量g_PLA=round(p["vol"] * 1.24e-3, 1), 包络=p["bb"], 说明=p["note"]) for p in PARTS]
json.dump(bom, open(DOCS + "/bom.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1)
print(f"-> 3D网格与BOM清单已保存至 web/mesh_arm.json 与 docs/bom.json ✓\n", flush=True)
return bom
def collide():
ps = [p for p in PARTS if p["vol"] > 1.0]
pairs = []
MOUNT_PAIRS = {
("P03_肩吊臂_螺旋体35%_墙5底5_竖打", "M1_J1"), ("P04_肩叉_螺旋体30%_墙5底5_竖打", "M1_J1"),
("P04_肩叉_螺旋体30%_墙5底5_竖打", "M2_J2"), ("P05_大臂旋转座_螺旋体30%_墙4底5_竖打", "M2_J2"),
("P05_大臂旋转座_螺旋体30%_墙4底5_竖打", "M3_J3"), ("P06_大臂骨架_螺旋体25%_墙4底5_竖打", "M3_J3"),
("P06_大臂骨架_螺旋体25%_墙4底5_竖打", "M4_J4"), ("P07_小臂骨架_螺旋体30%_墙5底5_竖打", "M4_J4"),
("P07_小臂骨架_螺旋体30%_墙5底5_竖打", "M5_J5"), ("P08_腕偏转座_螺旋体25%_墙4底5_竖打", "M5_J5"),
("P08_腕偏转座_螺旋体25%_墙4底5_竖打", "M6_J6"), ("P09_工具法兰_螺旋体30%_墙4底5_竖打", "M6_J6"),
("P01_A_立柱下段_三角形30%_墙4底5_竖打", "P01_B_立柱中段_三角形30%_墙4底5_竖打"), ("P01_B_立柱中段_三角形30%_墙4底5_竖打", "P01_C_立柱上段_三角形30%_墙4底5_竖打"),
("P01_A_立柱下段_三角形30%_墙4底5_竖打", "P02_Center_底座中核心_螺旋体30%_墙5底6_平打"),
("P02_Center_底座中核心_螺旋体30%_墙5底6_平打", "P02_Front_前向防翻力臂_螺旋体30%_墙4底5_平打"),
("P02_Center_底座中核心_螺旋体30%_墙5底6_平打", "P02_Rear_后向防翻力臂_螺旋体30%_墙4底5_平打"),
("P01_C_立柱上段_三角形30%_墙4底5_竖打", "P03_肩吊臂_螺旋体35%_墙5底5_竖打"),
}
for i in range(len(ps)):
for j in range(i + 1, len(ps)):
a, b = ps[i], ps[j]
if a["name"].startswith("M") and b["name"].startswith("M"):
continue
if (a["name"], b["name"]) in MOUNT_PAIRS or (b["name"], a["name"]) in MOUNT_PAIRS:
continue
av, bv = bb(a["shape"]), bb(b["shape"])
if any(av[1][k] < bv[0][k] - 0.05 or bv[1][k] < av[0][k] + 0.05 for k in range(3)):
continue
pairs.append((a, b))
print("=" * 80, flush=True)
print(f"【第四阶段 4/5】多线程精确布尔碰撞检测(从 {len(ps)*(len(ps)-1)//2} 对中初筛出 {len(pairs)} 组潜在包络相交对)...", flush=True)
print("=" * 80, flush=True)
done_pair = 0
pair_lock = threading.Lock()
def check_pair(pair):
nonlocal done_pair
a, b = pair
t0 = time.time()
v = common_vol(a["shape"], b["shape"])
with pair_lock:
done_pair += 1
status = f"干涉 {v:.1f} mm³ ❌" if v > 2.0 else "零干涉 (0.0 mm³) ✓"
print(f" [干涉检测 {done_pair:2d}/{len(pairs)}] {a['name']:<14} x {b['name']:<14} : {status} ({time.time()-t0:.3f}s)", flush=True)
if v > 2.0:
try:
c = BRepAlgoAPI_Common(a["shape"].wrapped, b["shape"].wrapped)
c.SetRunParallel(True)
c.Build()
cb = cq.Shape(c.Shape())
lo, hi = bb(cb)
loc = "@[%.0f %.0f %.0f..%.0f %.0f %.0f]" % (
lo[0], lo[1], lo[2], hi[0], hi[1], hi[2])
except Exception:
loc = "?"
return (a["name"], b["name"], round(v, 1), loc)
return None
rows = []
for pair in pairs:
res = check_pair(pair)
if res is not None:
rows.append(res)
rows.sort(key=lambda r: -r[2])
json.dump([r[:3] for r in rows], open(DOCS + "/collision.json", "w", encoding="utf-8"),
ensure_ascii=False)
if len(rows) == 0:
print(f"\n-> 静态碰撞干涉检测通过: 0 对干涉!全部 {len(pairs)} 组接触面对零碰撞!\n", flush=True)
else:
print(f"\n-> 发现 {len(rows)} 对干涉!\n", flush=True)
return rows
def axial_extent(j, shape):
lo, hi = bb(shape)
vals = []
for i in (0, 1):
for k in range(3):
p = [lo[0], lo[1], lo[2]]
p[k] = hi[k] if i else lo[k]
vals.append(sum((p[q] - j.rear[q]) * j.d[q] for q in range(3)))
return min(vals), max(vals)
def verify_interfaces(half, clamp, JS):
"""Numeric self-check of the derived joint stack, before trusting the renders."""
rows = []
for k, j in JS.items():
if k == "J5":
continue
parts = dict(j.place_hardware(half, clamp, k))
a = axial_extent(j, parts["%s_半联轴器A" % k])
b = axial_extent(j, parts["%s_半联轴器B" % k])
c = axial_extent(j, parts["%s_抱箍A" % k])
hub = j.at(HUB_FACE_Z)
rows.append(dict(jid=k, halfA=[round(x, 3) for x in a], halfB=[round(x, 3) for x in b],
clamp=[round(x, 3) for x in c], hub_face=round(HUB_FACE_Z, 3),
spigot_proud=round(b[1] - HUB_FACE_Z, 3),
clamp_clear=round(c[0] - COVER_Z, 3)))
return rows
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-50
View File
@@ -1,50 +0,0 @@
[
{
"axis": "J1",
"lo": -180.0,
"hi": 180.0,
"continuous": true,
"zero": 0.0,
"note": "整周无干涉,可 360° 连续旋转(全对全动态扫描验证)"
},
{
"axis": "J2",
"lo": -105.0,
"hi": 9.0,
"continuous": false,
"zero": 0.0,
"note": "正向 +9° / 反向 -105°,总行程 114°(全对全动态扫描验证,含相邻件与电机)"
},
{
"axis": "J3",
"lo": -180.0,
"hi": 180.0,
"continuous": true,
"zero": 0.0,
"note": "整周无干涉,可 360° 连续旋转(全对全动态扫描验证)"
},
{
"axis": "J4",
"lo": -111.0,
"hi": 16.0,
"continuous": false,
"zero": 0.0,
"note": "正向 +16° / 反向 -111°,总行程 127°(全对全动态扫描验证,含相邻件与电机)"
},
{
"axis": "J5",
"lo": -93.0,
"hi": 34.0,
"continuous": false,
"zero": 0.0,
"note": "正向 +34° / 反向 -93°,总行程 127°(全对全动态扫描验证,含相邻件与电机)"
},
{
"axis": "J6",
"lo": -180.0,
"hi": 180.0,
"continuous": true,
"zero": 0.0,
"note": "整周无干涉,可 360° 连续旋转(全对全动态扫描验证)"
}
]
-50
View File
@@ -1,50 +0,0 @@
[
{
"axis": "J1",
"lo": -180.0,
"hi": 180.0,
"continuous": true,
"zero": 0.0,
"note": "整周无干涉,可 360° 连续旋转(全对全动态扫描验证)"
},
{
"axis": "J2",
"lo": -105.0,
"hi": 9.0,
"continuous": false,
"zero": 0.0,
"note": "正向 +9° / 反向 -105°,总行程 114°(全对全动态扫描验证,含相邻件与电机)"
},
{
"axis": "J3",
"lo": -180.0,
"hi": 180.0,
"continuous": true,
"zero": 0.0,
"note": "整周无干涉,可 360° 连续旋转(全对全动态扫描验证)"
},
{
"axis": "J4",
"lo": -111.0,
"hi": 16.0,
"continuous": false,
"zero": 0.0,
"note": "正向 +16° / 反向 -111°,总行程 127°(全对全动态扫描验证,含相邻件与电机)"
},
{
"axis": "J5",
"lo": -93.0,
"hi": 34.0,
"continuous": false,
"zero": 0.0,
"note": "正向 +34° / 反向 -93°,总行程 127°(全对全动态扫描验证,含相邻件与电机)"
},
{
"axis": "J6",
"lo": -180.0,
"hi": 180.0,
"continuous": true,
"zero": 0.0,
"note": "整周无干涉,可 360° 连续旋转(全对全动态扫描验证)"
}
]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+27 -1
View File
@@ -2,7 +2,7 @@
__pycache__/
*.pyc
# 构建产物(可用 src/ 下的脚本重新生成)
# 构建产物(可用 python -m arm.* 重新生成)
out/
# 临时调试脚本与截图
@@ -18,3 +18,29 @@ backups/
# 编辑器 / 系统文件
Thumbs.db
Desktop.ini
# 构建产物:由 python -m arm.* 重新生成,体积大(mesh 14MB + 单文件查看器 16MB×3),
# 过去被跟踪导致仓库背着 47MB 生成物;取消跟踪后重建不再脏 git status。
# 旧 web/ 单文件产物目录(已删,源码在 webapp/)
web/
/机器人手臂3D查看器.html
webapp/data/
webapp/generated/
dist/
data/cache/
# 关卡截图落点 = 纯生成物:每次 `python -m tests.verify --shots` 由 shot.py 重写
# (其中有 13 张在同一份构建下也不逐字节复现,抗锯齿/量化噪声)。
# 比对的权威物在 tests/baselines/ —— shots_stable.md5 是 92 张的 md5 名单,
# flaky/ 是那 13 张的参考像素 + thresholds.json 阈值;PNG 本身不需要入库。
docs/shots/
# 其它编辑器的本地配置/会话残留(不是工程产物,也不该跟着版本走)
.trae/
.workbuddy/
.zcode/
.zcodeignore
# 定位图/ 只是"给用户看的核验图落点",一次性、会反复清空;
# 关卡比对的权威物是 tests/baselines/(md5 名单 + flaky 参考像素),不在这里。
定位图/
@@ -1,204 +0,0 @@
# 姿态估计算法评估与优化 —— 实施计划
> 交付物(用户已确认):**仅一份中文评估报告**,不改动求解器代码。
> 报告落盘位置:`docs/姿态估计算法评估报告.md`
---
## 1. 摘要(Summary)
本任务对机械臂的「姿态估计算法」——即 [arm-ik-solver.js](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js) 的 6 轴逆运动学(IK)姿态求解器及其查看器集成 [ik-glue.js](file:///g:/Robot_v2/机器人手臂/web/ik-glue.js)(用户已确认该文件即「姿态估计算法」)——做一次全面评估。
需要解决的核心问题:**在多个关键位姿(近工作空间边界、折叠/奇异构型、贴近关节限位处)无法达到预定姿态目标;且当目标姿态确实不可达时,返回的「最近位姿」明显劣于肉眼可见的更优姿态。**
评估已在本阶段(Plan 模式,只读)实测复现并定位到 4 类缺陷(见 §2.3),并额外回答了两个关键设计问题:**现状是「先点位、后角度」的位置优先架构**(§2.4),以及**位置与姿态在远目标上真正不可兼得、必须提供可切换优先级**(§2.4 实测取舍表)。本计划定义报告的**结构、数据来源、可复现实验命令、优化策略与验证口径**。执行阶段只产出一份报告 md,不新增任何脚本文件(实验以 `node -e` 内联执行,命令内嵌报告附录)。
---
## 2. 现状分析(Current State Analysis)
### 2.1 代码与数据资产
| 角色 | 文件 | 说明 |
| :--- | :--- | :--- |
| 姿态求解器(主) | [arm-ik-solver.js](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js) | UMD,`make(cfg)` 返回 `solve/solveTo/solveWarm/solveDyn/closest/closest6/branch/fk/orient/costOf` 等([导出清单 L1355-1360](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L1355-L1360)) |
| 查看器集成 | [ik-glue.js](file:///g:/Robot_v2/机器人手臂/web/ik-glue.js) | 姿态 UI、目标拖动、`solveTo` 调用([L379](file:///g:/Robot_v2/机器人手臂/web/ik-glue.js#L379)、[L399](file:///g:/Robot_v2/机器人手臂/web/ik-glue.js#L399))、结果渲染与徽标判定([renderResult L318-342](file:///g:/Robot_v2/机器人手臂/web/ik-glue.js#L318-L342)) |
| 关节表(几何真值) | [mesh_arm.json](file:///g:/Robot_v2/机器人手臂/web/mesh_arm.json) | `joints[] = {id, rear, d}`,FK/IK 运动学常量来源 |
| 关节限位 | [travel.json](file:///g:/Robot_v2/机器人手臂/docs/travel.json) | 本报告测量口径:J1/J3/J6 连续(±180),J2∈[-106,9],J4∈[-111,16],J5∈[-95,34](2026-09-29 起该表退为零位扫描参考:滑块改电机行程 ±360°,可行域改相邻件实测区间) |
| 既有自检 | [pose_ik_check.js](file:///g:/Robot_v2/机器人手臂/scratch/pose_ik_check.js) | 7 组 node 自检(往返、位置回归、姿态重构、交互口径、兜底、耗时) |
| 既有性能基准 | [bench_ik_split.js](file:///g:/Robot_v2/机器人手臂/scratch/bench_ik_split.js) / [bench_ik_split.json](file:///g:/Robot_v2/机器人手臂/scratch/bench_ik_split.json) | 单次求解耗时与「位置/姿态」占比拆分 |
| 历史优化实验 | `scratch/ikopt/`(`solver_scanbase.js` 等) | 根枚举/窗口/同族门等过往迭代产物,报告需说明其结论 |
### 2.2 现有算法骨架(报告需准确描述)
1. **位置闭式 + 二维冗余扫描**:以 (θ4,θ5) 为冗余参数全域扫描,θ1..θ3 由 [branch() L429-463](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L429-L463) 闭式给出(最多 4 支:θ3=±σ、±(π−σ)),位置天然精确到 1e-13 mm。
2. **按行裁剪**:[rowSpans L644-654](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L644-L654) 用探测步 `hp=max(h,2)°` 找每 θ4 行的可行 θ5 段,段内再按 h 精扫。
3. **姿态任务**:工具轴指向 = 目标轴,是 (θ4,θ5) 上的 2 方程 2 未知量,解为**孤立点**;[poseTools L486-545](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L486-L545) 提供残差/牛顿原语;[§4.1b 根枚举 L697-761](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L697-L761) 逐行做变号括号 + 牛顿收口;滚转由 [rollClose L549-555](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L549-L555) 闭式补 θ6。
4. **接受门**:`POSETOL=2.0°`([L581](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L581))——`scoreBranch` 对 `oerr>POSETOL` 的候选**直接返回 null 丢弃**([L596-598](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L596-L598))。
5. **不可达兜底**:[closest6 L1054-1169](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L1054-L1169) 为 6 维阻尼最小二乘(DLS),160 次确定性拟随机重启 × 160 迭代;[solveTo L1172-1192](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L1172-L1192) 在 `solve()` 失败时调用它。
6. **实时快路**:[solveWarm L1200-1265](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L1200-L1265),沿上一帧解同支续解,失败回落全量。
### 2.3 已实测缺陷(本阶段只读复现,数据可直接写入报告)
**缺陷 A — 覆盖漏洞:目标姿态精确可达,求解器却静默返回 ~1–2° 残差**
- 全空间随机可达位形,`solve(step=0.25)` 返回 `oerr>0.05°` 的比例:**2.5%(3/120)**,量级 `oerr∈[1.24, 1.95]°`(真值应为 0)。
- 其中可明确归因于「覆盖漏洞」(细扫 step=0.05 能到 ≤0.05°、即确有大步长漏解)的部分,小样本估计 **0.3%–1.3%**(300 组样本:1/300;80 组样本:1/80);其余为真·近奇异构型(细扫同样到不了 ≤0.05°,属物理不可达的刀尖构型)。**执行阶段用更大样本(D1/D4)收敛该比例。**
- 典型案例 `T=[329.3,253.4,750.0]`:真解 (θ4,θ5)=(-16.45,-51.97),`branch()` 在该点给出 **位置误差 5.7e-14 mm、轴误差 0.000°** 的精确解;但 `solve(step=0.25)` 返回 oerr=1.909°。
- **步长敏感(非分辨率问题)**:同一目标 `oerr` 随 `step` 非单调跳变——`0.25°→1.60`、`0.15°→0.045`、`0.10°→1.14`、`0.05°→0.013`;用 `solveTo({warm:真解})` 则得到 **oerr=0.0000**。证明确切解存在,是**根枚举/牛顿收口的完备性缺口**。
- 工作区实测:前伸工作区 (20,210,300)±90mm 失败率 **1.7%(1/60)**;查看器默认区 (150,128,260)±60mm 为 **0%(0/60)**——缺口集中在工作空间外围/极端构型。
**缺陷 B — 不可达兜底 closest6 严重次优(「肉眼可见更优姿态」的直接来源)**
以「位置精确解中姿态误差的全局最小值」为真最优(对 (θ4,θ5) 网格 0.05° 枚举 `branch()` 全部在限位内的支),目标姿态在可达姿态基础上外推 20°:
| 案例 | 目标 TCP | 兜底返回 | 真最优姿态差 | 劣化 |
| :--- | :--- | ---: | ---: | ---: |
| 1 | (40,455,216) | 位置 0.4mm / 姿态 **74.0°** | **11.04°** | **63.0°** |
| 2 | (196,122,136) | 位置 0.5mm / 姿态 **130.0°** | **17.26°** | **112.8°** |
| 3 | (-141,-18,1030) | 位置 0.0mm / 姿态 **167.2°** | **89.09°** | **78.1°** |
**缺陷 C — 姿态不可达时把「位置精确解候选池」整体丢弃,导致兜底被便宜解严格支配**
- `T=[20,400,300]`、`R=poseMat(25,95,10)`:位置完全可达(无姿态 `solve` 返回 err=2.5e-14,0.5° 格上有 **11920** 个位置精确解),但姿态目标不可达时 `scoreBranch` 因 `oerr>POSETOL` 把全部候选置 null,`solve()` 返回 `ok=false`、候选池为空,被迫走 [closest6](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L1054-L1169) 的 6 维随机 DLS。
- 同例对照(实测):位置精确解集内最小姿态差 = **121.05°**;`closest6` 返回 **136.7° / 位置 0.0mm**。即 `closest6` 在**位置与姿态两个指标上同时劣于**一个用现有闭式扫描就能廉价得到的解 —— **被严格支配(Pareto-dominated)**。
- 后果:`位置精确 + 姿态尽力` 这条最自然的路径没有实现;兜底既不最优、又更慢(159–224ms vs 扫描 ~100ms)。
**缺陷 D — 性能超出既定预算**
- [bench_ik_split.json](file:///g:/Robot_v2/机器人手臂/scratch/bench_ik_split.json) 摘要:开姿态单次 **中位 107ms / p95 242ms / 最大 299ms**(位置基座中位 73.6ms,姿态增量中位 37.2ms 占 33%,escalating 触发率 2.5%)。
- 本阶段在工作区实测均耗时 112–157ms;不可达兜底 159–224ms。
- 均高于用户设定的 **≤100ms** 目标,且兜底路径(random-restart DLS)是主要浪费点。
### 2.4 求解范式评估:位置优先 vs 末端姿态反推(用户提出,报告中作为备选简要说明)
**用户提问**:能否用已指定的末端角度,从末端逐级反推回各关节?
**几何事实(以 [mesh_arm.json](file:///g:/Robot_v2/机器人手臂/web/mesh_arm.json) 为准)**:
| 关节 | rear | d | 类型 |
| :--- | :--- | :--- | :--- |
| J1 | (-51,128,610) | (1,0,0) | X 抬举 |
| J2 | (18.5,100,490) | (0,1,0) | Y 俯仰 |
| J3 | (18.5,128,445) | (0,0,-1) | Z 自转 |
| J4 | (18.5,100,332) | (0,**1**,0) | Y 俯仰(肘) |
| J5 | (18.5,100,222) | (0,**1**,0) | Y 俯仰(腕) |
| J6 | (18.5,128,176) | (0,0,**-1**) | Z 工具自转 |
- **J4 ∥ J5**(均 +Y,相距 110mm)⇒ 平行轴永不相交 ⇒ **非球形腕**,经典"腕心解耦 + θ6→θ5→θ4 逐级闭式反推"不成立。(J5⊥J6 在 (18.5,128,222) 相交,仅构成 2 轴腕。)
- 但存在可用的**降阶结构**:因 J4∥J5,`R4·R5 = R_y(θ4+θ5)`(代码 [L406-411](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L406-L411) 已用 `SAMEAX` 利用该性质),故
```
R = Rx(θ1)·Ry(θ2)·Rz(−θ3)·Ry(θ4+θ5)·Rz(−θ6)
```
⇒ **工具轴只由 (θ1,θ2,θ3,θ4+θ5) 决定,θ6 是纯工具滚转**;"用末端姿态驱动求解"可行,但需按此降阶链求解(3 方程 / 5 参数 → 2 参数族),而非逐级反推。
**位置与姿态在远目标上"真的不可兼得"(实测,报告须收录)**:
| 目标 | 优先级 | 位置误差 | 姿态差 |
| :--- | :--- | ---: | ---: |
| T=(20,400,300), R=poseMat(25,95,10) | 位置优先(保点) | **0.0 mm** | 121.05° |
| 同上 | 姿态优先(保角度) | 127.6 mm | **0.22°** |
| T=(196,122,136), R=poseMat(25,95,10) | 位置优先 | **0.0 mm** | 116.39° |
| 同上 | 姿态优先 | 233.6 mm | **0.45°** |
⇒ 结论:二者是**真实的取舍**,不存在"都能兼顾"的解;算法必须提供**可切换的优先级**。
### 2.5 误差来源定位(报告核心结论)
- **R1 根枚举不完备**:姿态精确解在 (θ4,θ5) 平面是一条一维曲线,只在少数孤立点穿过 `POSETOL` 窄带;当该曲线经过 θ3≈±90° 的**折叠点**(`branch()` 的 4 支两两重合、位置雅可比奇异)、或贴着关节限位/可行段边缘时,变号括号检测失效、`match()` 的「θ1..θ3 最近」同支判据在折叠处换支,牛顿收口失败。
- **R2 接受门把「不可达」与「漏解」混为一谈**:`oerr>POSETOL` 一律丢弃 ⇒ 无法区分「确实不可达」与「可达但没找到」,导致 A 类静默近失 + C 类候选池清空。
- **R3 兜底目标函数与真实目标不一致**:`closest6` 用固定的 20mm/rad 把姿态折进位置残差做联合 DLS,既不保证「位置优先、姿态最小」,也依赖随机重启初值,落点常是劣质局部极值。
- **R4 行裁剪探测步过粗**:`hp=max(h,2)°` 且按单一连续段假设(注释称仅在 5 个目标上验证过),窄于 2° 的可行段会被整段漏掉 ⇒ 整行/整区无候选。
---
## 3. 交付物与报告大纲(Proposed Changes)
**唯一变更**:新建 `docs/姿态估计算法评估报告.md`(中文)。
报告章节(每章明确数据来源,均为本阶段或执行阶段 `node -e` 实测可得):
1. **概述与结论摘要**(TL;DR 表格:缺陷 / 影响 / 根因 / 建议 / 预期收益)
2. **算法现状描述**——§2.2 的骨架,附关键函数行号链接与数据流图
3. **求解范式评估(位置优先 vs 末端姿态反推)**——§2.4:
- 确认现状为「先点位、后角度」(位置被强制精确、姿态是带 2° 门的软约束)
- 关节轴几何表 + `J4∥J5 ⇒ 非球形腕` 的判定;说明「逐级闭式反推」不成立的**原因**(备选简要说明,约半页,含降阶链 `Rx·Ry·Rz·Ry(θ4+θ5)·Rz` 的结论式)
- 位置/姿态**不可兼得**的实测取舍表(§2.4),给出「必须提供可切换优先级」的结论
4. **评估方法**——指标定义:
- 姿态残差 `oerr`(`rotErrDeg`,度)
- 位置残差 `err`(mm)
- 覆盖漏洞率 = 「真解 oerr≤0.05° 而解算 oerr>0.05°」占比
- 步长敏感度 = 同目标在 step∈{0.05,0.1,0.15,0.25,0.5} 的 oerr 波动
- 兜底劣化 = `closest6` 姿态差 −「位置精确解集内最优姿态差」
- 单次耗时中位/p95(开/关姿态、可达/兜底)
5. **性能分析**——引用 bench_ik_split.json 的耗时构成 + 工作区实测;给出「位置/姿态/兜底」三段占比
6. **误差来源定位**——§2.5 的 R1–R4,逐条给出**可复现证据**(目标坐标、真解、实测值)
7. **优化策略(设计方案,不在本轮落地)**——按收益/成本排序给出可实施建议:
- **S0(架构)双优先级求解范式**:仿 [ik-glue.js](file:///g:/Robot_v2/机器人手臂/web/ik-glue.js) 现有姿态模式的 UI 开关,新增「优先级」选择——**位置优先 / 姿态优先 / 折中**;三种模式下共用同一闭式支枚举结果,只切换目标函数与选解规则。给出各模式的数学表述:
- 位置优先:`min oerr s.t. ‖fk(q)−T‖=0`(在位置精确解集上最小化姿态误差)
- 姿态优先:`min ‖fk(q)−T‖ s.t. oerr=0`(在姿态精确解集上最小化位置误差;利用降阶链参数化)
- 折中:`min ‖fk(q)−T‖² + k·oerr²`,k 可调
- 预期:位置优先模式把 B 类劣化从 63–113° 压到接近真最优;姿态优先模式提供 oerr≤0.05° 的「保角度」解(代价是位置误差,如实上报)
- **S1(根因修复)覆盖率完备化**:把 (θ4,θ5) 上的姿态方程看作一维解曲线,用**预测-校正 continuation 跟踪**(以解析/数值切向推进、跨越折叠点不换支),或等价地做**分支索引对齐的多起点牛顿 + 折叠点/限位角种子**;预期把 A 类覆盖漏洞降到 <0.2%,且不加粗扫密度 ⇒ 耗时基本不变(≤100ms)。
- **S2(关键收益)姿态不可达时不再清空候选池**:去掉 `POSETOL` 硬门对候选池的**清空**语义,改为保留并按 `oerr` 排序入池(门只用于「达标」判定,不用于「丢弃」);预期消除 C 类 Pareto 支配,且兜底不再需要。
- **S3 兜底加速**:以 S0/S2 的闭式候选为种子替代 160 次随机重启,仅在位置也越界时才退回受限 DLS;预期兜底耗时从 ~160–220ms 降到 ≤100ms。
- **S4 可观测性**:UI 明确区分「已到达 / 位置到达·姿态最近 X° / 姿态到达·位置最近 Y mm / 均不可达」四种语义,避免把漏解或取舍结果当成功。
- 每条策略给出:改动点(函数+行号)、原理、预期指标、代价与风险、与 ≤100ms 预算的关系。
8. **验证方案与验收口径**——见 §5。
9. **附录**:可复现实验命令(`node -e` 全文内联,不落脚本文件)+ 基准数据表 + 术语表。
> 说明:报告只做「评估 + 策略设计」,不含代码修改。若后续要落地 S0–S3,需另起一次实施任务。
---
## 4. 假设与决策(Assumptions & Decisions)
| 项 | 决策 | 依据 |
| :--- | :--- | :--- |
| 交付物 | 仅评估报告 md,**不改代码** | 用户选择「仅评估报告」 |
| 「姿态估计算法」定义 | = `web/arm-ik-solver.js` 的 IK 姿态求解 + `ik-glue.js` 集成 | 全仓检索无 IMU/卡尔曼/姿态估计类算法;仓库内「姿态」相关实现仅此一处 |
| 性能预算 | 以 **≤100ms** 为优化目标约束(报告据此评估各策略) | 用户选择「保持 ≤100ms」 |
| 失败场景 | A(可达却漏解)与 B/C(不可达最近位姿)**都覆盖** | 用户选择「两者都做」 |
| 实验脚本 | 不新增文件,全部 `node -e` 内联并在报告附录给出原文 | 遵守「仅评估报告」与「不新建文件」 |
| **求解优先级** | 报告须设计**双优先级机制(位置优先 / 姿态优先 / 折中),界面可切换**;不可兼得时两种解都给出 | 用户选择「两者都做、界面可切换」 |
| **末端姿态反推** | 报告仅作**备选简要说明**(说明非球形腕故不能逐级闭式反推 + 降阶链结论),不展开公式级设计 | 用户选择「只作备选简要说明」 |
| 关节表/限位 | 以 `web/mesh_arm.json` + `docs/travel.json` 为准,tcp=[18.5,128,93] | 与 `pose_ik_check.js`、`bench_ik_split.js` 一致 |
| 真最优基准 | 「位置精确解集内姿态误差最小」的网格枚举(0.05° 分辨率,θ6 另行闭式最优) | 独立于被测求解器,避免自证 |
| 随机性 | 固定 LCG 种子的确定性采样,报告数据可复现 | 与求解器内部自检策略一致 |
---
## 5. 验证方案(报告第 8 章内容)
**基准数据集**(执行阶段固定种子生成,规模与命令写入附录):
- D1 全空间随机可达位形 ≥200 组(覆盖极端/边界构型)
- D2 前伸工作区 (20,210,300)±90mm ≥60 组、查看器默认区 (150,128,260)±60mm ≥60 组
- D3 姿态外推集(外推 3/8/20°)≥30 组 → 测兜底劣化
- D4 退化构型集(θ3≈±90°、临近限位)≥30 组 → 测覆盖漏洞
- D5 越界目标集 → 测兜底耗时与质量
- D6 **双优先级取舍集**:位置与姿态不可兼得的远目标 ≥20 组 → 测「位置优先」与「姿态优先」两解各自的误差与真实性
**指标与验收口径(现状基线 → 目标)**:
| 指标 | 现状实测 | 目标 |
| :--- | :--- | :--- |
| D1/D4 覆盖漏洞率(真解≤0.05° 却返回>0.05°) | 2.5% | ≤0.2% |
| D1 最大静默姿态残差 | 1.95° | ≤0.05° |
| 步长敏感度(同目标 oerr 波动) | 0→1.86° | ≤0.05° |
| D3/D5 兜底相对真最优劣化(姿态优先口径) | 63–113° | ≤2° |
| D6 位置优先解:位置误差 / 姿态差 | 0.0mm / 121.05° | 位置=0;姿态=真最优(劣化 ≤0.5°) |
| D6 姿态优先解:姿态差 / 位置误差 | 0.22° / 127.6mm | 姿态 ≤0.05°;位置如实上报 |
| 开姿态单次耗时 p95 | 242ms | ≤100ms |
| 兜底耗时 | 159–224ms | ≤100ms |
| 姿态残差 P95(可达集) | ≤2.05°(接受限) | ≤0.05° |
**沿用既有自检**:`node scratch/pose_ik_check.js` 的 7 项(含往返一致性、位置回归、姿态重构、roll→θ6 口径、6D 兜底、耗时对比)作为回归门,报告中给出其现状通过情况与目标。
**报告自检**:报告内每张数据表须与附录命令重跑结果一致;所有结论必须能由附录命令复现。
---
## 6. 执行步骤(待批准后按序执行)
1. 复跑并固化基线数据(D1–D6 + bench + 自检),生成报告用数据表。
2. 撰写 `docs/姿态估计算法评估报告.md` 正文(§3 大纲 1–9 章)。
3. 内嵌可复现 `node -e` 命令与原始输出,形成附录。
4. 复核报告数据与结论一致性(重跑附录命令抽查),交付。
@@ -1,258 +0,0 @@
# 机器人手臂3D查看器 · 性能优化方案
目标文件:`G:\Robot_v2\机器人手臂\机器人手臂3D查看器.html`(15.31 MB / 35986 行)
用户要求:先做完整备份(带版本标识)→ 优化 → 验证功能完好 → 记录优化项与性能数据。
---
## 一、现状分析(实测,非猜测)
### 1.1 文件结构
| 位置 | 内容 |
| --- | --- |
| L1~L420 | HTML + CSS + 界面骨架(左栏/IK 控制条/舞台/页脚/BOM 抽屉) |
| L421~L428 | `<script>` three.js r159 内联(652 KB,UMD build,无 CDN) |
| L429~L33251 | `<script>`:`DATA`(13.99 MB 网格 JSON)+ `SHEETS`(52 KB SVG)+ `TRAVEL` + `COLLIDE` + 查看器主逻辑(`const TL = {}` 起 738 行) |
| L33252~L35022 | `<script>`:`arm-ik-solver.js` 逆运动学求解器(1770 行,自带 `window.ArmIK`) |
| L35023~L35984 | `<script>`:`ik-glue.js` 界面胶水层(961 行,Worker 化求解、预置自检、姿态拖拽) |
四个内联脚本的**索引被 `chkSig()` 指纹逻辑依赖**(`document.scripts[2]` = 求解器源码),因此本次优化**不得增删 `<script>` 标签、不得改动求解器脚本块**,否则预置自检指纹失效(会误报"预置结果已失效")。
### 1.2 数据规模(node 实测)
- 零件 92 件(打印件 9 + 电机 6 + 螺钉 72 + 包络 1 + 经纬线 1)
- 顶点 354,697 / 三角面 340,178 / 线段 10,296
- **单件最大顶点 47,483(< 65536 → 索引可全部用 Uint16)**
- `JSON.parse` 69 ms;按查看器原样建几何(含 `computeVertexNormals` / 包围盒球)37 ms
- 全场景射线拾取(92 件、34 万面):**1.3 ms/次**(60 次均值),200 次连测 2.1 ms/次
- `scene.updateMatrixWorld(true)` 全量强制刷新:**0.004 ms/次**(≈110 个对象)
### 1.3 已定位的性能问题
| # | 问题 | 证据 | 影响 |
| --- | --- | --- | --- |
| **P1** | **rAF 循环无条件每帧 `resize() + updCam() + rend.render()`**;`needRender` 被 `requestRender()` 置位后**全文件没有任何读取点**(`Grep needRender` 仅 3 处:声明、置位、注释) | L33207~L33215 | 空闲时仍 60 fps 满帧渲染 34 万面 ×92 draw call;`preserveDrawingBuffer:true` 使每帧多一次缓冲拷贝;持续占用 GPU/功耗 |
| **P2** | `resize()` 每帧读 `stage.clientWidth/clientHeight` | 同上 | 每帧强制布局(forced reflow),与 P1 同源 |
| **P3** | `pick()` 每次 pointermove 先 `meshes.filter(m => m.visible)` 再全量射线求交 | L32668~L32673、L32642~L32652 | 每帧≥1 次 1.3~2.1 ms 求交 + 每帧一个 92 元素临时数组,鼠标扫过模型时持续吃 CPU |
| **P4** | `applyJoint()` 每次新建 2 个 `Vector3` + 3 个 `Matrix4`,并线性查 `JO.find` | L32506~L32518 | 每写一个关节角 = 6 次调用 = 30 个临时对象;动画/实时追踪每帧一批 |
| **P5** | 几何索引一律 `Uint32Array` | L32531、L32546 | 索引显存 340,178×3×4 B = 4.08 MB(Uint16 只需 2.04 MB) |
| **P6** | 点选/ESC 时 `for (const tr of tb.children) tr.style.background = ''` 全表刷 style | L32727、L32736、L32745 | 每次点击写 ~92 行样式 |
| **P7** | Worker 的 Blob URL 不回收(`URL.createObjectURL` 后无 `revokeObjectURL`) | L35510 附近 | 常驻一份 ~86 KB Blob;构造失败路径同样泄漏 |
### 1.4 经实测**否决**的优化(不做,写进报告避免后人重复劳动)
- **全量矩阵刷新**:0.004 ms/次 → 改"6 级关节链更新"只有 13× 相对提速但绝对量 4 μs,风险不值。
- **`updXform()`(92 件)/ `updStat()`(92 件 reduce+filter)**:量级为微秒,爆炸滑块拖动时也远低于 1 ms。
- **材质合并 / 几何合并**:只有 92 个 draw call,合并会破坏"零件树勾选显隐 + 点选 + 单独高亮"这些核心功能。
- **分片/延迟建场景**:初始化总计约 106 ms(69 解析 + 37 建几何),分片只会增加复杂度,不降低总时间。
- **实时追踪时的结果面板 DOM 节流**:`renderResult()` 含"场景图残差复算"与"备选构型"两块**验证语义**内容(用户此前专门建立了指纹/预置自检体系),节流会削弱验证可观测性,保持原样。
### 1.5 关键发现:源文件与成品已漂移(影响改动范围)
`src/build_viewer.py` 由 `src/viewer_tpl.html` + `web/ik-glue.js` + `web/arm-ik-solver.js` + `web/mesh_arm.json` 拼装成品,但:
- 成品 L35023~L35984 的 glue 区域(961 行)**比 `web/ik-glue.js`(776 行)新**:Worker 化求解、`PRECOMPUTED_CHK` 预置自检、`refreshJointLabelsLazy` 节流、`±360°` 滑块等**只存在于成品内**。
- 成品主逻辑区与 `src/viewer_tpl.html` 的差异只有:① `±360°` 滑块范围(成品新)② `/*__SCREWS__*/` 占位符展开(预期)。
**已与用户确认的处理方式**:本次**直接改成品 HTML**;同时把"能对得上"的性能补丁**同步回 `src/viewer_tpl.html`**(源文件落后部分保持原状)。在源文件完成反向同步之前**不要重跑 `build_viewer.py`**(会把成品回退到旧功能)——此警告写入报告。
---
## 二、改动清单
### 改动 0:备份(先做,且必须先验证备份完整)
1. 新建目录 `backups/`,复制原件为
`backups/机器人手臂3D查看器.v1_2026-09-29_2151_优化前.html`
(v1 = 优化前版本号;2151 取自原件最后写入时间 2026-09-29 21:51)
2. 记录 `SHA-256` 与字节数(`Get-FileHash`),校验备份与原件一致后再动原件。
3. `.gitignore` 追加 `backups/`,避免 15 MB 备份污染 git 状态。
### 改动 1(P1+P2,**核心**):按需渲染
文件:成品 `loop()`(L33207~L33215)与 `src/viewer_tpl.html` 对应段。
```js
/* 改动前 */
function loop() {
resize();
if (cv.style.display !== 'none') { updCam(); rend.render(scene, cam); }
requestAnimationFrame(loop);
}
```
```js
/* 改动后 */
/* 按需渲染:needRender 由 requestRender() 置位(所有交互/状态变化都已调用它);
旧版置位后无人读取,故恒以 60 fps 空转。装配图页(画布隐藏)不清标志,
切回三维页由 tab 处理器 resize()+requestRender() 补渲染。 */
function loop() {
if (needRender && cv.style.display !== 'none') {
needRender = false;
resize();
updCam();
rend.render(scene, cam);
}
requestAnimationFrame(loop);
}
```
要点:
- **保留 `preserveDrawingBuffer:true`**:按需渲染后画布不再重绘,必须靠它保住最后一帧图像。
- **保留 rAF 循环**:`animTo()` 动画、实时追踪、拖拽都依赖后续 rAF 里的渲染。
- 已核对全部渲染状态变更点均有 `requestRender()`(主逻辑 36 处;glue 的 `refreshTarget()`/`setJoints()`/`highlightSpec()`/`setView()` 内部均调用),无遗漏。
- `resize()` 仍在渲染前调用一次(进入渲染路径时同步画布尺寸),窗口尺寸变化由 `ResizeObserver → resize() → requestRender()` 覆盖。
### 改动 2(P3):拾取零分配 + 悬浮限流
文件:成品 L32642~L32682 与 `src/viewer_tpl.html` 对应段。
- `pick()`:`meshes.filter(...)` → 复用模块级数组
```js
const pickList = [], ndcPick = new THREE.Vector2();
function pick(e) {
const r = cv.getBoundingClientRect();
ndcPick.set(((e.clientX-r.left)/r.width)*2-1, -((e.clientY-r.top)/r.height)*2+1);
ray.setFromCamera(ndcPick, cam);
pickList.length = 0;
for (const m of meshes) if (m.visible) pickList.push(m); // 语义与 filter 完全一致
const hit = ray.intersectObjects(pickList, false)[0];
return hit ? hit.object : null;
}
```
(three r159 的 Raycaster **不检查 `object.visible`**,已在源码中确认,故可见性过滤必须保留。)
- `schedHover()`:rAF 节流 → **60 ms 时间配额 + 位置去重**(实测 1.3 ms/次求交,60 次/s → ~16 次/s):
```js
let hoverPending = false, lastHoverEv = null, hoverT = -1e9, hoverX = -1, hoverY = -1;
const HOVER_MS = 60;
function schedHover(e) {
if (e.clientX === hoverX && e.clientY === hoverY) return; // 未移动不重算
hoverX = e.clientX; hoverY = e.clientY; lastHoverEv = e;
if (hoverPending) return;
hoverPending = true;
setTimeout(() => {
hoverPending = false;
if (!lastHoverEv || drag) return;
hoverT = performance.now(); hover(lastHoverEv);
}, Math.max(0, HOVER_MS - (performance.now() - hoverT)));
}
```
尾部仍会补一次(指针停住后取最新坐标),tooltip 行为不变;点击/右键拾取仍**立即**执行,不受限流影响。
### 改动 3(P4):`applyJoint()` 去分配
```js
const JP = {}, JD = {};
for (const j of JO) { JP[j.id] = new V3(...VC(j.rear)); JD[j.id] = new V3(...VC(j.d)).normalize(); }
const _mT = new THREE.Matrix4(), _mR = new THREE.Matrix4(), _mTi = new THREE.Matrix4();
function applyJoint(id) {
const P = JP[id], D = JD[id]; if (!P) return;
const a = (ang[id] || 0) * Math.PI / 180;
JG[id].matrix.copy(_mT.makeTranslation(P.x, P.y, P.z)
.multiply(_mR.makeRotationAxis(D, a))
.multiply(_mTi.makeTranslation(-P.x, -P.y, -P.z)));
JG[id].matrixWorldNeedsUpdate = true;
requestRender();
}
```
数学与原式逐位相同(`T(P)·R(D,θ)·T(−P)`),仅把每次新建的 5 个对象换成预计算 + 3 个共享矩阵。
### 改动 4(P5):索引降位(Uint16)
```js
const sIdx = p.v.length > 65535 ? new Uint32Array(p.s) : new Uint16Array(p.s); // line 分支
const idx = p.v.length > 65535 ? new Uint32Array(p.t.length*3) : new Uint16Array(p.t.length*3);
```
全部 92 件顶点数 ≤ 47,483(实测),Uint16 合法;索引内存 4.08 MB → 2.04 MB,上传带宽减半。保留 >65535 分支作安全兜底。
### 改动 5(P6):BOM 高亮行只记一行
新增 `let bomHlRow = null; function bomHlClear() { if (bomHlRow) { bomHlRow.style.background = ''; bomHlRow = null; } }`,替换 L32727、L32736、L32745 三处全表 `tr.style.background = ''` 循环;设置高亮处同步记录 `bomHlRow = tr`。可见结果完全一致。
### 改动 6(P7):Worker Blob URL 回收
```js
const u = URL.createObjectURL(new Blob([src + boot], { type: 'application/javascript' }));
let w; try { w = new Worker(u); } finally { URL.revokeObjectURL(u); }
```
(URL 只在 Worker 构造时被读取,构造后即可回收;失败路径也不再泄漏。此改动**仅成品有**——`web/ik-glue.js` 仍是旧同步求解版,无 Worker。)
### 改动 7:源文件同步(用户已确认)
- `src/viewer_tpl.html`:逐条应用改动 1~5(该文件对应代码段存在,锚点为唯一代码串;`±360°` 差异行不与改动区重叠)。
- `web/ik-glue.js`:核对后逐条应用(预计仅"渲染相关"部分不适用——拾取/循环/几何都在模板里;Worker 相关不存在)。
- 同步后**不重跑 `build_viewer.py`**(会回退旧功能),源文件只作为"补丁已留存"。
### 改动 8:文档
新建 `docs/查看器性能优化报告.md`(与 `docs/姿态估计算法评估报告.md` 同目录同风格,中文):优化项逐条说明(问题/证据/改法/收益)、前后实测数据表、功能验证清单与结论、已否决项、备份路径 + SHA-256、**"源文件漂移未修,勿重跑构建"**警告。
---
## 三、假定与决策
1. 只改代码区,**不动** `DATA` / `SHEETS` / `TRAVEL` / `COLLIDE` 数据行与求解器脚本(指纹依赖)。
2. 不新增/不拆分 `<script>` 标签(`document.scripts[2]` 索引依赖)。
3. 不改 `antialias` / `setPixelRatio(min(dpr,1.5))` / 灯光 / 材质参数——属画质取舍,且实测 draw call 与面数不构成瓶颈。
4. 测量用的"插桩副本"只放在 `scratch/perf/`(已被 `.gitignore` 忽略),成品文件保持干净。
5. 备份放项目内 `backups/`(用户已选),并写入 `.gitignore`。
---
## 四、验证方案
### 4.1 静态校验
1. `backups/` 内备份与原件 `SHA-256` 一致(改前)。
2. 抽取改后文件的 4 个 `<script>` 块到 `scratch/perf/`,逐个 `node --check` 通过。
3. 求解器脚本块 + `TRAVEL` / `COLLIDE` 数据行改前/改后**字节一致**(保证指纹)。
4. 全文 diff 只落在预期的 8 处锚点内。
### 4.2 功能验证(浏览器,改前/改后各跑一遍对照)
用 `scratch/perf/serve.py`(`SimpleHTTPRequestHandler`,把 `/v.html` 映射到项目根的真实成品)起 `http://localhost:8123/v.html`,用浏览器自动化逐项核对并截图:
| 项 | 判据 |
| --- | --- |
| 首屏加载 | 无 JS 报错;三维模型正常显示 |
| 关节滑块 | 拖动后手臂实时跟随、角度标签同步 |
| 爆炸滑块 / 装配视图按钮 | 模型散开与复位正确 |
| 线框 / 电机半透 / 螺钉 / 可达包络 / 复位 | 逐项开关生效 |
| 装配图页 ↔ 三维页切换 | SVG 显示、切回后三维画布重新渲染(验证"隐藏分支不清标志") |
| 运动模拟页 | 进页即求解并动画到位;IK 表格/徽标/备选构型正常 |
| 姿态控制 | 开关、取当前、垂直向下、箭头拖拽可用 |
| 自检面板 | 显示"指纹 … 匹配";FK/IK/场景图残差仍为 1e-13 mm 量级(验证改动的数学等价性) |
| BOM 螺丝行点击 | 高亮 + 相机对焦 + HUD 文案(昨天的功能) |
| 悬浮 tooltip / 右键隐藏 / H 键 / ESC | 行为一致 |
| 相机预设 / 拖拽旋转 / 滚轮缩放 | 行为一致 |
| URL 参数(`?view=motion&tgt=…&j1=…&hide=…`) | 脚本化视图仍生效 |
### 4.3 性能前后对比(插桩副本,同一浏览器同一机器)
生成 `scratch/perf/measure_before.html`(备份 + 尾部追加插桩脚本)与 `measure_after.html`(改后 + 同一插桩脚本),插桩内容:包装 `rend.render` 计次与计 CPU 时长、`rend.info`、`performance.memory`、导航时序、交互探针(滑块 input、`IK.setView('motion')`+`IK.run()` 动画、20 次 pointermove 拾取计时),结果输出到页面固定 div + `document.title` 以便截图/读取。
预期指标:
| 指标 | 改前(预期) | 改后(预期) |
| --- | --- | --- |
| 空闲 5 s 渲染次数 | ≈300 | ≈0~1 |
| 单次 render CPU 时长 / draw call / 三角面 | 基线 | 相同(画质不变) |
| 20 次 pointermove 拾取总耗时 | ≈26~42 ms | ≈7~11 ms(限流 + 零分配) |
| 索引显存 | 4.08 MB | 2.04 MB |
| 加载(navigation→首次渲染、DOMContentLoaded→load) | 基线 | 持平或略优 |
| 交互后渲染次数(滑块/动画) | — | 每次变化 ≥1 次,无丢帧 |
### 4.4 回滚
`Copy-Item backups/机器人手臂3D查看器.v1_2026-09-29_2151_优化前.html 机器人手臂3D查看器.html`(并同步回源文件的 `git checkout`)。
---
## 五、执行顺序
1. 备份 + 校验 + `.gitignore`(改动 0)
2. 起静态服务器 + 跑改前功能对照与改前性能插桩(拿到基线数据)
3. 改成品 HTML(改动 1→6,逐处 Edit,锚点唯一串)
4. 静态校验(4.1)
5. 改后功能对照 + 改后性能插桩(4.2/4.3)
6. 同步源文件补丁(改动 7)
7. 写报告(改动 8)
-374
View File
@@ -1,374 +0,0 @@
# 2026-10-03 工作日志
## P06 大臂骨架减重窗取消(视觉整改)
**问题**:用户实机视点反馈,J4 关节正上方 P06 大臂骨架(青绿色)顶部的"帐篷形减重窗"
正面看是一个贯通的矩形黑洞,位于整机最显眼的落脚点,非常不雅观。
该窗是 2026-10-02 减重改造时开的(XZ 轮廓 X3~37 沿 Y 切穿,Z362~374)。
**处理**:在 `src/arm_model.py` 加开关 `P06_HOLLOW_WIN = False`,`p_upper_bone()` 5c 处
改为条件 cut,该段恢复实心。保留 `P06_WIN_XZ` 轮廓与全部设计注释,改开关即可复原。
**结果**:
- P06 体积 161.1 -> **183.3 cm3**(与减重前的 183 一致),+22cm3 / 约 +27g PLA
- 碰撞检测 8 组接触面全部零干涉
- 强度校核 0 处不合格;z=365 剖面 SF=101.23(该段本就不缺强度,减重收益纯锦上添花)
- 已同步更新 BOM 说明、`src/strength_check.py` 剖面名("大臂骨架减重窗 @z=365"
-> "大臂骨架过渡块 @z=365",保留为该高度的安全裕度监控点)
- 重新导出 STEP/STL,重跑 `scratch/make_3mf.py` 打包 3MF,查看器已刷新
**备份**:`backups/20261003_p06_fill/arm_model.py.bak`
**结论/偏好**:减重窗不是越多越好。P04 立柱槽与 P07 顶板槽是朝上敞开的(不破坏外观)
故保留;P06 这个沿 Y 贯通、直接开在外观面上的窗属于"减重减出负面观感",已回退。
以后开减重腔要先问一句:会不会在外露面上形成可见的洞。
## J3(M3) 电机前端压盖 —— 补齐 roll 关节的"电机两端固定"
**定位**:用户截图里红圈标的电机裸露端 = J3 大臂自转电机 M3 的前端盖。
红圈周围两个零件:P05 大臂旋转座(绿 0x059669,抱紧 M3 的**holder**)与
P06 大臂骨架(青 0x0d9488,被 M3 驱动的 driven)。M3 前端盖外端面在 Z=389,
原来只有 P05 后端板 4xM3x10(S02)单面锁死 —— 与 2026-10-02 已做完的
J2/J4/J5(M2/M4/M5)三处相比是唯一还缺前端夹紧的 pitch/roll 关节。
**统一配方(复用,不重复实现)**:`arm_model.motor_front_clamp` 改成轴向无关 —
`axis="Y"`(J2/J4/J5 旧路径,几何逐位不变)/ `axis="Z"`(J3 roll,轴向 -Z)。
新增 `CLAMP_CLOCK_LOCAL`(= 72.2°+90k 的**关节局部相位**):trs() 里 y = z×x,
两个轴向换算到全局都是"全局相位 = −局部相位",所以 Y 轴仍是原来的 17.8°+90k,
Z 轴同一组数落在全局 (x,y) 平面 —— 全机 BCD55 孔位约定不变,改一处必须同步另一处。
**代价(关键)**:J3 的从动件 P06 毂面原本在 Z=388.5,与 M3 前端盖只差 0.5mm,
**放不下 5mm 压盖**。按 Y 轴三处的老办法让位:`z_top` 388.5→383.0、直锁凸台
59.5→65.0(+CLAMP_BACKOFF=5.5 = 压盖 5.0 + 动间隙 1.0 − 原基准 0.5)。
凸台同步加长后 3xS01 的头面台阶(s=58)/杆长/咬合量与 J3 直锁关系一律不变。
P05 侧 `roll_clamp=True` 开关在 `make_pitch_to_roll_bracket`(P05/P08 共用),
P08 传 False —— J6/M6 的前端压盖**还没做**,要从动件 P09 工具法兰让位(改直锁凸台)。
**结果**:13/13 单实体 ✓;P05 155.0 cm³(Z 384..525)、P06 166.5 cm³;
碰撞 8 组全零干涉(P05×P06 = 0.00 mm³);4xM3x8 与压盖零干涉(各自 0.00 mm³);
螺钉 S12 12→16 颗(docs/screws.json + screw_mesh.py 同步,实例数自洽断言通过);
3MF 重打包(P05 切片高 136→141mm)。改前改后对比图 `docs/shots/cmp_j3_before_after.png`。
**教训**:J3/J6 这类 roll 关节的"电机前端盖外侧"就是从动件的毂面,
加压盖前必须先量从动件到前端盖的轴向净空(Y 轴是 1.0mm,Z 轴只有 0.5mm)。
## 电机轴向厚度公差 2mm —— 全机 5 个夹紧关节的承压面让空
**用户提问**:"类似这种安装电机的地方是否考虑了 2mm 的电机厚度公差"。
**审计结论:完全没考虑,而且比"没留量"更糟 —— +2mm 的电机根本装不进去。**
轴向链是「后端板 4×M3x10 顶住电机尾端面 + 前端 R35 压盖 4×M3x8 压住前端盖」两个刚性面。
螺钉头一旦坐在沉孔台阶上就不能再进给(螺钉只能进不能退),所以两端都是硬约束,长度必须
对上。实测窗口:承压面在 s=-0.6 时,电机只能 L ≤ 56.6(长边 +0.6mm);L=58 时前端盖越位
1.4mm 直接撞压盖本体。
**关键设计判断:公差不能靠"拧紧"消化,只能靠垫片位。** 曾评估过三条路:
- 加长压盖/让关节长 2mm → 从动毂面让位要再动 5 个零件的轮毂间距,且关节轴向全链 +2mm;
- 一端改径向夹紧 → 要重做后端接口;
- **后端承压面让空成垫片位(选中)** —— 几何只改一处数,关节长度不变,全机统一。
**实现(单一事实源)**:
- `armcore.MOTOR_TOL = 2.0`(现场口径),`arm_build.REAR_SEAT = 0.6 + MOTOR_TOL = 2.6`;
- `Joint.pocket()` 的 PCB 腔底(电机尾端面承压面)从 -0.6 让到 -2.6 → 覆盖 J3/J4/J5/J6;
- P04 走自带刀具 `c_m2`(没调 `Joint.pocket`),同步改成 `-AB.REAR_SEAT` → 覆盖 J2;
- roll 关节 `rear_mount_holes(t_plate)` 7.0→8.0:让空 2mm 后承压面到螺钉沉孔台阶只剩
`t_plate - cb_depth - REAR_SEAT`,给到 8 才保住 1.4mm 膜厚(给 7 会退化成 0.4mm);
连带 `z_plate = z_r + 7` → `z_r + 8`(P05 杯顶 452→453,J2 让位圆柱底在 454.5,仍有余量)。
- 装配配方写进 README 与 `docs/screws.json` S02:T = 58.6 − L,量程 0~2.6mm,M3 平垫组合。
**验证**:`scratch/motor_tol_window.py`(窗口换算,改前 +0.6 → 改后 +2.6);
`scratch/motor_tol_check.py`(真实电机包络 R30/R30.15 布尔)5 个夹紧关节 × ΔL∈{-2,0,+2}
**全部 0.0mm³ 干涉且前端盖面压间隙 0.0**;13/13 单实体;碰撞 8 组全零;3MF 重打包。
**教训(比结论更重要)**:两个刚性夹持面 = 固定长度,零公差。想吃公差必须有一个面变成
"可垫可让"的软面,否则再大的公差也换不来行程 —— 这条同样适用于以后任何"双面夹紧"改法。
另:J1/M1 天然免疫(P03 是 Ø60.7 直通长孔,比电机长 3mm),所以公差只需要改 J2~J6。
## 承压面让位 2mm 的"残留凸台"—— 排查结论:消不掉,是承压面下沉的必然结果
**用户反馈**:P05/P06/P07/P08 后端都有凸台,是之前给电机预留 2mm 时"削掉了一些"
造成的;认为应该"通过减薄面"而不是"掏掉",没掏干净所以残留了凸台。
**实测确认现象**(`scratch` 里用双 Common 相减法量,band z=-2.6..0 内、64.4 方柱之外):
P05 759 / P06 1089 / P07 3139 / P08 721 mm3。成因确认无误:REAR_SEAT 从 0.6 抬到
2.6 时只把 64.4 方形腔加深(纯掏腔),腔壁以外 z=-2.6..0 的 R35 料没动,
相对新承压面(-2.6)凸起 2.6mm。
**两条消除路线都实测失败**(重要,别再重复试):
- A. 整段全截面切 z=-2.6..0 -> **P05/P08 碎成 2~3 块**。这圈 R35 料连同承压环
是"电机尾端安装板"与"臂身"之间**唯一**的轴向连接,切掉就断。
- B. 沿腔口做让位斜面(方形放样 taper_rect 与圆锥 taper_round 都试了)
-> 余料只降约 20%,且 P05/P08 仍碎 2 块。因为这圈料在 z>0 是杯体本体
(继续往上长),斜面一刀切下去等于削臂身。
**结论**:这不是"没掏干净"。承压面下沉 2mm 与"腔壁保持原 0.6mm 直壁"在几何上
互斥 —— 台阶是下沉的必然产物。它不顶到电机(电机尾端面在腔内、四周已让空),
只是外观上的一道台阶。要彻底消掉必须改承压面方案本身(承压环整体下沉 / R35 座
改短),那属于关节轴向长度调整,不在"预留 2mm 公差"这件事的范围内。
**本次改动**:`Joint.pocket` 与 P04 `c_m2` 的**几何未动**(体积与改前逐件一致,
13/13 单实体),只把上述实测结论写进 `Joint.pocket` 的 docstring 与
`arm_build.py` 常量区注释;`armcore.py` 新增 `REAR_LAND` / `R35_SEAT` 两个具名
常量把"0.6 原台肩"和"R35 外壁"显式化,避免以后再靠注释里的裸数字。
**教训(方法论,比结论更重要)**:
1. 这次我在**错误的坐标系**上浪费了大量时间 —— `map_pt(jp.t, ...)` 必须用
**被抱电机所属关节**的变换(P06 用 J4,不是驱动它的 J3),用错关节算出来的
footprint 全是错的 U 形,还误导我把余料误判成"Loft 方棱"。
2. 布尔测量不要用 `BRepAlgoAPI_Common(shape, slab.cut(sq))` 这种嵌套 cut ——
结果不可靠(我因此得到过 bbox 跨 86mm 的荒谬值)。用**两个独立 Common 相减**:
`vol(∩slab) - vol(∩sq)`。
3. 结论前先确认工具本身对不对:中途 `HALF, SEAT = AB.PCB_W/2.0, AB.REAR_SEAT`
之后又被覆盖,导致打印出 `SEAT=32.2` 这种自相矛盾的值,我却继续解读了好几轮。
## 承压面悬空凸台 —— 已彻底解决(用户第二次指出,我第一次判断错了)
**用户纠正**(重要):上一轮我结论是"凸台消不掉,是承压面下沉的必然产物",**错了**。
用户明确指出:① 不是去掉整个地方,只消掉一部分凸台;② 凸台本身不起任何作用;
③ 正确做法是把上面的厚度改成下面的厚度,或用更大半径的电机做布尔把面弄平;
④ 现状不好 3D 打印(需更多支撑、公差打不好)。
**真正的根因(这次用正确坐标系统一量就清楚了)**:
`Joint.pocket` 原来用 64.4 **方**腔从 -0.6 加深到 -2.6 掏出垫片位。64.4 方腔的
**内切圆半径恰好 = 32.2**,所以方腔在 45° 方向已吃穿到 r=45.5,R35 壁只剩
**四条边中点附近**的料 —— 定量实测 r=34 处材料沿 z 的区间(承压面 -2.6):
- 0°/180°/260°/280°:-8.0..12.0(贯通,属臂身本体 = 承力桥)
- 80°/100°:-8.0..-0.0(**只比承压面多 2.6mm,这段就是凸台**)
- 其余 20~160°:-8.0..-2.6(承压环本体,齐平)
**我上一轮失败的原因**:一直以为"整圈 R35 料是承压板与臂身之间唯一连接",
所以任何整段切都试过。实际上 0/90 方向那条桥在 z>0 是**臂身本体**(r=34 贯通到 z=12),
不是承压环的一部分。是我没量清楚才下了错误结论。
**修法(一刀,不做角度分区)**:承压面所在的 z=-REAR_SEAT..+SEAT_TOP 段,
腔口从 64.4 **方**改成 R35 **圆**(新增常量 `arm_build.SEAT_R=35.0` / `SEAT_TOP=1.0`);
z=0 以上的 PCBA 腔仍保持 64.4 方(PCB 是方的必须方腔)。整圈一次挖到承压面高度,
凸台零残留。P04 走自己的 `c_m2` 圆柱刀具,同步加 `cyl(AB.SEAT_R, -AB.REAR_SEAT, AB.SEAT_TOP)`。
`SEAT_TOP=1.0` 顺带清掉 roll 关节半包薄壁(R32.2..35 的 0.05cm3 残片)。
**连带好处**:承压环从"方腔内切 32.2 − 沉孔外缘 30.25 = 1.95mm 窄环"
变成"完整 R35 圆环 4.75mm",**受压面积 383 → 974 mm²(2.5 倍)**。
**验证**:13/13 单实体 ✓;静态干涉 8 组全零 ✓;`motor_tol_check.py` 5 关节 ×
±2mm 共 15 组全部 0.0mm³ 且面压间隙 0.0 ✓(±2mm 公差未受影响);
4xS02 沉孔残余 0.0(未被 R35 圆腔侵占)✓。
**方法论教训(比结论重要)**:
1. 上一轮我因为在**错误坐标系**里反复兜圈(`map_pt` 用了驱动关节而非电机所属关节),
得出了"消不掉"的错误结论。**遇到"改不动"的几何,先确认坐标系对不对**,
再考虑方案 —— 坐标系错了会把可解的问题判成无解。
2. **区分"桥"和"悬空料"要靠轴向区间实测**(固定半径 r=34 扫 z),
而不是靠体积或目测。体积法(P05 759 / P07 3139 mm3)会让人误以为整圈都是残留。
3. `solids_of` 会把 <0.01cm3 的布尔退化薄片也算成独立实体,判单实体时要设阈值,
否则会误报"碎块"。
## 修复:R35 圆腔切断连接壁 —— 肩叉顶部"没连上"(我 10-04 的改法有错)
**用户反馈**:最上面的蓝色肩叉(P04)顶部有一道缝,没连接上,要求连上。
**根因(几何实测)**:上一轮为了消"悬空凸台",我把承压面段(z=-2.6..0)的让位腔
从 64.4 方腔改成 **R35 圆腔**(`arm_build.SEAT_R=35.0`, `SEAT_TOP=1.0`)。这是错的 ——
R35 恰好等于后端板外壁半径,等于把 r=32.2..35 那圈 **2.8mm 厚的连接壁整圈挖穿**。
实测(`scratch/_rad.py` 逐方位径向扫描,P04 电机局部系):
| z | 0°/45°/90°/135°/180° | 225° | 270° |
|---|---|---|---|
| -3.0(承压面下) | 28.0-35.0 有料 | 有料 | 有料 |
| -2.4 .. +0.8 | **全空** | 35.0-35.5(0.5mm 薄片) | 35.0-56.0 |
即:后端法兰(R35 圆盘,z=-8..-2.6)与杯壁(z>=+1.0)之间被切出一条 **3.6mm 宽、
360° 全通的环槽**,只剩 240~320° 方位靠臂身本体相连,225° 还留了一片 0.5mm 厚的
孤立薄片 —— 打印出来就是你截图里那道缝。**单实体校验查不出来**(13/13 仍通过,
因为臂身本体兜住了),只有逐方位测轴向材料区间才看得见。
**修法(一处数值)**:`SEAT_R: 35.0 -> PCB_W/2.0 = 32.2`、`SEAT_TOP: 1.0 -> 0.0`。
32.2 = 64.4 方腔的内切圆半径,正好只挖掉悬空凸台、保留 r=32.2..35 整圈连接壁,
且在0/90/180/270 方向与上方方腔壁齐平、45° 方向比方腔多留料把四角补实。
同步改 P04 的 `c_m2`(走自己的圆柱刀具)+ 5 处 BOM 说明 + `screws.json` S02 备注。
**验证**:
- 改前/改后对照(`scratch` 布尔复现两版):改前连接壁 **36/36 方位全断**,
改后 **360° 整圈连续**
- 5/5 件(P04~P08)连接壁整圈连续、单实体、4xS02 沉孔残余 0.00mm³
- 13/13 单实体 ✓;静态干涉 8 组全零 ✓
- `motor_tol_check.py` 5 关节 × ±2mm 共 15 组全部 0.0mm³、面压间隙 0.0 ✓(公差未受影响)
- 体积回归:P04 280.1 -> 282.1 cm³(补回了被误挖的料)
**教训(补上一轮的方法论空白)**:
1. **单实体 + 零干涉不代表结构对**。这次13/13 全过、8 组零干涉,但零件内部被切出一条
360° 通槽 —— 因为臂身本体在另一侧兜住了连接。"是否连通"必须单独验证:
固定半径(如 r=33.6)逐方位扫 z,找材料轴向区间是否连续。
2. **"把面弄平"不等于"把料全挖到外壁"**。用户说"用更大半径的电机做布尔把面弄平",
正确解读是"挖到能让表面齐平的那个半径",即方腔内切圆 32.2,而不是外壁 R35。
选刀半径前必须先确认**哪一圈料是承力连接**,不能只看凸台本身。
3. 改完要**回头验上一轮修过的所有件**,不能只验当前这件。P05~P08 是共享 `pocket()`
自动受益,但 P04 走自己的 `c_m2`,是最容易漏的一处 —— 本次两处都改了。
## 压盖"拼接痕"视觉整改:四处前端压盖外缘倒 R4(新增 blend_clamp_seam)
**用户反馈**:给了两张 P04/M2 压盖的截图 —— 第一张红圈里能看到一圈**硬棱**("圆盘贴在
臂身上"的接缝),第二张是圆润连续的过渡。要求"两面的都变成第二张一样圆润的"。
**定位(几何实测,不是目测)**:
1. 先怀疑是压盖 R35 圆柱面本身的棱 -> 枚举同心 R35 圆边,发现 Y157 以上压盖外表面
**完全暴露**(四周 r>35.2 无料),R35 圆柱面上根本没有任何棱边。
2. 又怀疑压盖圆盘被叉板切出缺口 -> 扫 Y=161.0 截面每个方位的 Rmax,**全周都是
35.00,完整圆盘无缺口**。
3. 最后按"轴向坐标 ≈ 压盖前端面 且 到轴距离 34~36.5"筛棱边,命中**唯一一条
弧长 219.9mm 的样条边**(Y=161.0 平面、r 恒 35.0)—— 这就是拼接缝:
压盖是 `s.fuse(fp)` 直接熔接的圆盘,前缘落在臂身曲面上,交角处留硬棱。
4. 沿 Y 的 Rmax 剖面佐证:Y<=156.5 时 R@90=60(叉板圆柱),Y>=157.5 时 R@90=35
—— 台阶高 25mm,圆角正是要把它化开。
**修法**:新增 `blend_clamp_seam(shape, px, py, pz, s_face, axis, r, tag)`,
按"轴向坐标 ≈ 前端面 且 半径 ≈ R35"定位缝边并倒圆;倒圆失败只打印提示、**退回原形状**
(一个装饰性圆角绝不能毁掉整件)。`CLAMP_SEAM_FILLET = 4.0`(实测 R4 可行、R5 报
BRep_API 失败)。四处统一调用:P04/M2、P05/M3(roll-Z)、P06/M4、P07/M5。
**必须放在各自零件的最后一步布尔之后** —— 缝边是压盖与臂身相交才产生的,中途任何
一刀都可能改变它。
**踩的坑**:`near_face()` 里我把 Y 轴关节的面坐标写成了 `p.z`(应为 `p.y`),
导致函数一条边都找不到、静默"保持原样"(幸好有打印,否则会以为改好了)。
**教训:几何选择器写错时的默认行为是"什么都不做",必须让它打印命中数量**,
否则"没报错"会被误读成"已生效"。调试手段:先用独立脚本按同样条件验证选择器
能命中 1 条,再进源码。
**验证**:4/4 命中并倒圆;13/13 单实体 ✓;静态干涉 8 组全零 ✓;
`motor_tol_check.py` 5 关节 × ±2mm 共 15 组仍 0.0mm³ ✓(沉孔在 BCD27.5,
离 R35 外缘还有 7.5mm,倒圆完全不涉及螺钉孔)。
**方法论**:定位"视觉棱线"要**按几何条件筛边**(轴向坐标 + 半径区间),
不要靠渲染图目测猜是哪条棱。我这次先猜了两次圆柱面、一次缺口,三次都错,
第四次用条件筛选才一次命中。
## 撤回:压盖"拼接痕"R4 圆角(我误判,用户纠正)
**用户反馈**(两次纠正):
1. 我把圆角加到了 J2/J3/J4/J5 **四处**压盖 -> 用户:"我说的是**同个零件的两边**,
只改我发你图片的部分"。已把 P05/P06/P07 的调用撤回。
2. 我又改回只做 P04 一处 -> 用户:"不是圆角,而是你要把**拼接的痕迹去掉**,
现在另外一边是中间有个面,两边会凸出出来的"。
**已全部撤回**:删掉 `blend_clamp_seam()` 函数、`CLAMP_SEAM_FILLET` 常量、
P04 的调用、P04 BOM 里的说明、`screws.json` S12 的圆角备注。
`grep blend_clamp_seam|CLAMP_SEAM_FILLET src/arm_model.py` = 0。
重跑构建:13/13 单实体、8 组零干涉,代码回到加圆角之前的状态。
**为什么撤回(我的诊断是错的)**:
用户描述的目标是"**去掉拼接的痕迹**"(几何问题),我却理解成"**在缝上加圆角**"
(装饰问题)。这两件事完全不同:加圆角不改变"这里是两个零件熔接出来的"这个事实,
只是把锐边磨圆;用户要的是让它们**本来就长成一个连续曲面**。
**我实测到的(可能有用,但结论未定)**:
- 压盖本体在 Y=158 沿径向的最大材料半径**全周恒为 35.00**,是完整 R35 圆筒、
没有任何切削或缺口 -> 第一张图里那条"竖直直棱 + 右侧平面"**不是压盖本体**。
- 那个"中间有个面"实测是 P04 上Z=526..646.5 / X=2.8..46.1 / Y=155.5..164.5 的
**4048mm² 平面**(法向 (0,1,-0.07),带 4° 斜度),位于**电机上方 36mm 处**,
由 `p_fork` 第 5 步 `Xplate(yz_pts, -20.0, 60.0)` 的轮廓线段与 XZ loft 求交切出。
- 两侧"凸出的弧"最可能是 X=45.0 平面(5697mm²,法向 +X)与 R=164 圆柱
@(209,128,545) 的交接,以及立柱减重槽的两片腹板(X=23.0 / X=35.0 两面)。
- 减重槽参数 `P04_CH = x0=23, x1=35, y0=102, y1=154, z_floor=525`。
**教训(比这次的错更重要)**:
1. **"视觉缺陷"先分辨是几何问题还是表面质量问题**。用户的措辞
("去掉痕迹""两边凸出来")指向**形状本身不对**;我却直接跳到"加个圆角_smooth 一下"
—— 这是用装饰手段掩盖几何问题,既没解决需求,也白改了四个零件。
2. **"两边"是拓扑位置,不是"多处"**。用户说"同个零件的两边"指同一个零件上
对称的两个侧面,我却推广成了"全机四处"。**改动范围要严格贴着用户指的地方**,
有歧义就问(我第一次就该问,而不是先改了四处再撤回)。
3. 定位不准时**该早点问**。我换了 6~7 种测量方式(面枚举、边枚举、截面扫、
径向扫、剪影投影)都没能确定那个面,就该停下来问,而不是继续猜下去。
## 视觉整改(成功):P04 电机上方 4048mm2 大平面 -> Hermite 弧面
**用户反馈**(第三次纠正后终于对题):给了标注红线的截图 —— "在电机上方的地方,
是一整个非常大的面,需要做成弧形面",并确认"平顶面需要匹配两边突出的弧度"。
**定位(面枚举法,一次命中)**:枚举 P04 所有平面中「法向 +Y、Z>=520」且面积最大的面:
```
面积 4048mm² 法向 (0.00, 1.00, -0.07) X 2.8..46.1 Y 155.5..164.5 Z 526.0..646.5
```
就是它。成因是 `p_fork` 第 5 步 `yz_pts` 里这一段**直线**:
```
(Y_AXIS+36.5, Z_RL+36.5)=(164.5,646.5)→ (Y_COVER-0.5, Z_SH+36.0)=(155.5,526)
```
沿 X 拉成平板,法向 (0,1,-0.07) 的 4° 斜度就是这么来的。两侧"鼓"的是
`xz_wire` 的 R18 圆弧(`(28,646.5)→(44,630)` 那段 radiusArc(18.0))。
**为什么不能用单段圆弧**:起点 A=(155.5,526) 处切线须沿 Y(接 Y=155.5 竖直段),
终点 B=(164.5,646.5) 处切线须沿 Z(接 Z=646.5 顶段)—— 解圆心得 R=9 与 R=120.5
**互相矛盾**,单个圆弧无法同时满足。故用**三次 Hermite**(与 P05/P06 的 Loft 过渡同款):
m0 = -0.5·d·0.35(沿 -Y),m1 = 0.5·d·0.5(沿 +Z),12 段采样。
中部向 +Y 外凸约 3mm,弧度观感与两侧 R18 协调。
**踩的坑(重要)**:第一次实现把曲线采样成 `y_a → y_b`,而原列表是 `y_b → y_a`
—— **顶点绕序反了**,多边形自交,布尔后零件 **碎成 3 块、体积从 282 掉到 48.7cm³**。
改回原绕序立刻恢复单实体、体积 274.27cm³。
**教训:改动多边形顶点序列时必须保持原有绕序**,否则自交会让布尔结果彻底失真,
而症状(体积暴跌 + 碎块)看起来像"刀切掉了大量料",极易误判方向。
这次是体积对不上基准(差 233cm³)才立刻回头查绕序。
**验证**:13/13 单实体 ✓;静态干涉 8 组全零 ✓;
`motor_tol_check.py` 5 关节 × ±2mm 共 15 组仍 0.0mm³ ✓;
面枚举确认 4048mm2 平面已消失,原位置变成 **R=18 圆柱面 1447mm²**(Z 630..646.5);
体积 282.07 → 274.27 cm³(弧面替代平面削掉 7.8cm³,符合预期)。
**方法论(这次做对了的地方)**:不再靠截图目测猜,而是**按几何条件筛面**
(法向 +Y + Z 下界 + 面积最大),第一轮就锁定了目标面及其成因代码行。
前两轮失败(改圆角、改范围)都是因为没先定位清楚就开始动手。
## 承压面"围挡"消除:SEAT_R 必须是 R35(用户第三次指出,我前两次都错在"取值方向"上)
**用户反馈**(第三次纠正):"之前削平面的时候还是不对,凸台还是在的,你加了一圈围挡,
这不是正确做法,需要把凸台、围挡一起弄平,做成和电机两头齐平一起的平面。"
截图是 P08(#d97706 橙色)M6 电机后端,能看到 R35 端面上有一圈凸起的环。
**根因(一行公式,之前两次都没想透)**:
```
只要 SEAT_R < R35,外壁与刀半径之间那圈环墙就必然存在,
且相对承压面凸起 (R35 − SEAT_R),把电机尾端面围在中间。
```
- 我第一次取 64.4 **方**腔 -> 四角被吃穿,残留 4 段弧 = "凸台"
- 我第二次取 32.2 = 方腔**内切圆**(想"保住连接壁")-> 32.2 与 35 之间那圈
**2.8mm 宽环墙完整保留**,承压面只挖到 32.2,于是环墙凸起 2.6mm = "围挡"
(P08 实测该圈**1536.9 mm³**)
两次都不是"残留",而是**取值的必然产物**。用户第三次要求"一起弄平"就是指这个。
**正确改法(一处数值)**:`SEAT_R: 32.2 -> 35.0`(= 外壁 R35)、`SEAT_TOP: 0.0 -> 1.0`。
承压面段整圈挖到外壁 -> 承压面变成**完整 R35 平圆盘**,凸台与围挡同时消失。
`SEAT_TOP=+1.0` 是为了吃掉半包开口(Y93.0~95.8)那截 0.052cm³ 残片
(取 0 时它脱空成独立碎块,P05/P08 各多一块)。
**为什么不重演 2026-10-04 的"肩叉顶部没连上"**:
那版切 R35 但 `SEAT_TOP=0`,在 z=0..1.0 残留一截薄片 + 承力桥判定失误。
这次实测:band 段(z=-2.4..+0.8)r33.5 处**围挡残留 0/36**,
r36..58(臂身)有料方位 P04 10/36、其余各 15/36,其余方位靠 z>+1.0 杯体连接,
**无整圈通槽**。关键差别是 SEAT_TOP 抬到 +1.0,不是改回 32.2。
**连带好处**:承压环由「方腔内切 32.2 − 沉孔外缘 30.25 = 1.95mm 窄环」
变为「完整 R35 圆环 4.75mm」,**受压面积 383 -> 974 mm²(2.5 倍)**。
**验证**:
- 13/13 单实体 ✓(P04 272.3 / P05 151.4 / P06 158.8 / P07 222.6 / P08 138.1 cm³)
- 静态干涉 8/8 零 ✓
- `motor_tol_check.py` 5 关节 × ΔL∈{−2,0,+2} 共 15 组全部 0.0mm³、面压间隙 0.0 ✓
- 4×S02 沉孔残余 **0.00mm³**(沉孔 z=-8..-4 与圆腔 z=-2.6..+1.0 不重叠)✓
**踩的坑(重要)**:
1. **自己写的光栅化投影渲染器(从 mesh_arm.json 三角网格直接光栅)完全不能用** ——
深度缓冲/背面剔除/缩放全错,出一堆三角形碎面。排查这个问题花掉十几轮。
**教训:项目已有 `src/shot.py`(headless Edge)就是唯一可靠的渲染链路,别自己造。**
2. `src/shot.py` 并发渲染会随机 FAIL —— 根因是**没加 `--user-data-dir`**,
多个 Edge 实例抢同一个默认 profile 的锁。已修:每次 `tempfile.mkdtemp()`
建独立 profile,收尾 `shutil.rmtree`。加上之后 4 视图并发 100% 成功。
3. 写 BOM 说明时在 `"` 引号字符串里放了未转义的中文引号 `"一圈围挡"` ——
直接 SyntaxError 把 109KB 的 arm_model.py 写坏。已从 `.bak/` 备份恢复。
**教训:批量替换长中文注释时,别把双引号文本塞进 Python 双引号字符串。**
已建立 `.bak/arm_model.py.bak20261005` / `arm_build.py.bak20261005`。
**方法论教训(第三次了)**:
"改不动 / 消不掉"的几何问题,先想清楚**是不是某个取值的必然结果**,
而不是急着找"残留料"去切。SEAT_R 这个旋钮从 64.4 方腔 -> 32.2 -> 35.0,
每次换取值都改变了"环墙是否存在",而我一直只在 32.2 这一档里找原因。
**参数化几何里,"某段料消失"几乎总是某个半径/长度取值的函数,不是布尔残留。**
-78
View File
@@ -1,78 +0,0 @@
# 2026-10-04 工作日志
## J3(M3)电机前端"围挡":只切敞口那一圈(最终方案)
**用户反馈**(配图,第四次提"围挡"):
"我又有重新更改,改动文件记得重新读取,然后这里电机的另一头也有围挡,需要删除"
→ 上一轮修的是**尾端**承压面(SEAT_R=35),这次是电机**另一头**(前端)。
中途两次纠正:
1. "切太猛了,下面整个切了我怎么固定电机,只切我圈起来的一点"
2. "只切一圈围挡,不切整个面"
### 定位(先证后动,这次做对的地方)
1. **颜色比值锁零件**:截图里 B/G≈0.73 = P05 大臂旋转座(0x059669 翠绿),
环内青色盘 B/G≈1.00 = P06 大臂骨架(0x0d9488)。在渲染图上实测这两个比值确实
分别落在 0.7 / 1.0 两簇,映射成立。
2. 颜色 + 结构(P05 围着 P06 的 J3 凸台、Ø37 孔 ≈ 0.53×外径)⇒ 唯一可能是 **J3 关节**
(P05 与 P06 全机唯一交界面,Z384~390)。
3. 沿 J3 轴逐 z / 逐方位扫"首个材料半径"(`BRepClass3d_SolidClassifier`),实测:
- P05:`Z389.0~395.5` 是 **r30.25~35.0 的前端定位环**(6mm 高环墙)
- P05:`Z384.0~389.5` 是 **R35/Ø37 前端压盖**(4×M3x8 BCD55)
- P06:`Z=383.0` 毂面 + R17.8 凸台顶到 Z=390.05(穿过压盖 Ø37 孔)
⇒ 环墙 + Ø70 压盖把 M3 前端盖(Ø60.3)连同凸台围在中间 = 用户说的"围挡"。
### 我走的两条弯路(重点教训)
**弯路 1:整个前端都切掉**(用户当场驳回:"下面整个切了我怎么固定电机")
把 `z_bot` 389→395 + 删定位环 + 删压盖 + P06 毂面 383→388.5。后果:M3 退回
"仅后端板 4×M3x10 单面锁死",电机前端失去支撑面。
**教训:先问清楚要删的是哪个特征、哪一圈,不要把"围挡"理解成"整个前端端部"。**
**弯路 2:整圈削到与电机齐平(R35→R31.8)**(自测就没过)
- 这圈壁与第 3 步 Loft 在 `X=±35` 处与 Ø70 柱面**相切**(退化的切向楔形),
整圈切完两片楔形脱空 → 实测 **1.32mm³ 孤立碎块,P05 变成 2 个实体** ✗
(把切环外半径放到 44 消掉碎块,则会在背脊下端开一个 6.5mm 深的缺口,也不能接受)
- 压盖根部只有 0.5mm 熔接带(CLAMP_ROOT),环削到 1.55mm 宽后连接面
从 2.37mm² 掉到 0.78mm² → 4×M3x8 的夹紧力会把压盖从本体上拉下来 ✗
### 最终方案:只切敞口(-Y)那一圈
新增开关 `arm_model.M3_FENCE_OPEN_CUT = True`,在第 12 步熔接压盖之后加一刀
**盒切**,Y 界限与第 6 步 `cut_open` 完全一致(`Y_AXIS-40 .. Y_AXIS+3`),Z `388.9~395.6`:
- 敞口(-Y)半圈从 Z=395.5 一路开到压盖背面 Z=389 → 用户圈的那一圈围挡消失
- 背脊(+Y)侧整圈定位环保留:继续定位 M3 前端盖(内孔 r30.25 vs 前端盖 Ø60.3,
单边 0.10mm),并作为 R35 压盖**唯一且足够强**的支撑/熔接面(4.75mm 宽 × 0.5mm 熔接带)
- 压盖本体 / Ø37 让位孔 / 4×M3x8 沉孔 / 腔口下沿 z_bot=389 全部不动
- 附带好处:盒切把 Loft 与柱面**相切的那两片楔形**一起带走(楔形在 Y_AXIS 附近
< Y_AXIS+3)→ 不会出现孤立碎块,单实体天然保持
- 同步回退了弯路 1 造成的所有连带改动:S12 恢复 16 颗(`screw_mesh.py` /
`docs/screws.json`)、P06 毂面回到 383.0、凸台回到 65.0、README 全部改写
### 验证(全部通过)
- P05 **solids=1** ✓ 体积 154.8 → **151.6 cm³(只少 3.2)** Z 384.0..525.0(压盖与腔口都在)
- 逐方位剖面(`scratch/check_ring_flush.py`):Z384~388.5 八个方位全是 r18.5~35.0
(压盖整圈完好);Z≥389 只在 225°/270°/315° 有料(背脊侧环墙保留),
0°~180°(敞口侧)已清空 ✓✓
- 全量重建:**13/13 单实体** ✓、**8/8 静态干涉零** ✓(含新增的 P05×P06 一对)
- `motor_tol_check.py` 5 关节 × ±2mm 共 15 组 0.0mm³、面压间隙 0.0 ✓
- `verify_3mf.py` 12/12 通过(EXPECT 表里 P05 件高原本写 136.0,是 2026-10-03 加压盖
之前的老值,改回 141.0)
- STEP/STL/3MF 重导 ✓、螺钉实例 93 颗(S12:16)✓、查看器 + 可达包络重打包 ✓
- `sweep_v2.py` 全行程重扫:travel.json 与改前**逐字节一致**(J2 −105…+9、J4 −111…+16、
J5 −93…+34、J1/J3/J6 整周)✓
- 渲染确认:`docs/shots/m3_front_open_after.png`(隐藏电机、从敞口侧看进去,
围挡已无、压盖面与 4 颗 S12 螺钉都在)、`m3_front_assembled_after.png`
### 顺带修掉的两个工具 bug(非本次需求,但一直让构建不干净)
- `scratch/gen_env_mesh.py` 的包络自证断言是**假失败**:网格 x 从 `xs.min()` 起按
`np.arange(step_x=8)` 对齐,最后一格最多比真极值短一个步长(实测差 6.46mm),
而断言写的是 `abs(max-x_fwd) < 1.0` → 包络注不进 `web/mesh_arm.json`,
查看器里"可达包络"整个消失。已改为"不得超出、且至多少一个步长"。
- `src/shot.py` 的 `take_shot` 先 `os.remove` 旧图,在带 safe-delete 垫片的环境里
会把"删到回收站"的失败抛出来、打断整轮渲染;Edge 的 `--screenshot` 本来就覆盖,
已删掉这次删除。
### 方法论(第四次"围挡"事件的总结)
"围挡"这类"看着多余的一圈料"几乎总是**某个半径/高度取值的必然几何**,不是残留:
- 尾端:承压面段腔口半径 `SEAT_R` < 外壁 R35 → 环墙必然存在(1536.9mm³)
- 前端:定位环 6mm 高 + R35 压盖比电机 Ø63.6 凸 3.2mm → 环墙+凸缘必然围住前端盖
两次的正确解法都不是"多切一点",而是**改那个取值**;而且改动前要先问清楚
删的是哪一圈 —— 删整圈会同时删掉压盖唯一的支撑面。
-73
View File
@@ -1,73 +0,0 @@
## 【2026-10-05e】前端围挡整改推广到全机 5 处(J2/J3/J4/J5/J6)
**用户要求**:"其他几个也有一样的情况,也修一下"
→ J3/M3 那一处的前端围挡,其余四处电机舱(J2/M2、J4/M4、J5/M5、J6/M6)完全同构,一并处理。
### 根因(几何实测,不是残留料)
`Joint.front_collar_pocket(r_bore=30.25)` 把座孔从 R30.15 扩到 R30.25,于是留下一圈
**r30.25..35、6mm 高的环墙**,比电机前端盖 Ø60.3 凸出 4.75mm,把前端盖整圈围住;
J2/J3/J4/J5 上还骑着 R35 前端压盖(Ø70 再凸 3.2mm)。
实测五处环段都在**关节局部 z 49.5..56**:Y 轴电机 Y150..156、roll 电机 Z395.5..389。
### 做法:把 P05 专用盒切泛化成 `cut_motor_fence()`
- 开关 `M3_FENCE_OPEN_CUT` → **`FENCE_OPEN_CUT`**(语义从 P05 专用变全机共用)。
- 新函数 `cut_motor_fence(shape, j, tag, name3, z0=49.4, z1=56.1)`:
在**关节局部坐标系**里切 `x ±40 / y -3..+40 / z 49.4..56.1`,再 `j.L()` 摆到全局。
- 敞口方向恒为**局部 +y**,**无需逐件配置**:实测 5 处电机舱的本体
(环外 r=37/40 有料处 = 背脊侧)方位角全部落在局部角 180~360
(J2 240..315 / J3 225..315 / J4 210..330 / J5 210..330 / J6 195..345)。
- 一对 z 参数通吃全机(因为五处环段都在局部 z 49.5..56)。
- 切口边界取 **y=-3.0 而非 0**:环壁在该处仍与背脊侧实体连通,切出来是真正的缺口
而不是零厚度缝,单实体拓扑天然保持。
- 四个调用点(都在压盖熔接之后、收尾刀具之前):
P04 7b2(j2)、P06 5b2(j4)、P07 7c2(j5)、make_pitch_to_roll_bracket 第13步(j_roll,
条件放宽为 `roll_clamp or FENCE_OPEN_CUT`,这样**尚无前端压盖的 P08/M6 也只切定位环**)。
### 关键教训(值得记住)
1. **P05 的全局盒切其实是局部盒切**:当时那记
`(X_ARM±40, Y_AXIS-40..+3, Z388.9..395.6)` 换算到 J3 局部系正好是
`x±40 / y -3..+40 / z 49.4..56.1`。一开始我按全局坐标去复用,语义就错了 ——
泛化时必须先确认原刀在局部系里是什么。
2. **P05 回归验证**:改用通用函数后 P05 体积 151.60 cm³ 与 2026-10-05d 版**逐位一致**,
剖面 0..165° 敞口侧全空、180..345° 背脊侧有料,证明泛化没有改变既有行为。
3. **写文件务必"先在内存改好、断言全过、最后一次性写出"**:本轮用
`io.open(P,"w").write(s)` 时中途抛异常(`newline="\\n"` 非法值),
**把 119KB 的 arm_model.py 截成了 0 字节**,靠 `.bak/arm_model.py.bak20261005e` 救回。
后续改成 `写 .tmp 再 os.replace()` + 所有 `assert` 前置,原子性才可靠。
4. **heredoc 里 `sed -i` 转义`**:`sed -i 's/newline="\\\\n"/newline="\\n"/'` 这类在
Bash heredoc 里层数容易错,直接改脚本文件更稳。
### 验证结果(全过)
| 项目 | 结果 |
|:--|:--|
| 单实体 | **13/13** ✓ |
| 静态碰撞 | **8/8 零干涉** ✓ |
| 电机 ±2mm 公差 | **15/15** ✓(现覆盖 J2~J6 全部 5 个关节) |
| 3MF | **12/12** ✓ |
| 全行程重扫 | 696 s,`docs/travel.json` 与改前**完全一致**(J2 −105…+9、J4 −111…+16、J5 −93…+34、J1/J3/J6 整周) |
| 逐方位剖面 | 5/5 敞口侧 0/12 有料、背脊侧 11/12 有料 |
| 误伤检查 | 环外本体(r=37/40)改前改后**逐方位完全一致**;压盖沉孔段(z=58)一致 |
减重:P04 −3.4 / P05 −3.2 / P06 −3.2 / P07 −2.1 / P08 −3.0 cm³
(P07 只减 2.1 是因为它 `front_collar_pocket` 用了 `r_bore=30.25+CLEARANCE_GAP=32.25`,
环壁只有 r32.25..35 更薄)。
### 文档同步
- `README.md`:零件表 P04/P06/P07/P08 行加"前端围挡只切敞口那半圈"标记;把
2026-10-05d 的 P05 专用段落升级合并成 2026-10-05e 全机版(去掉了重复叙述)。
- `docs/bom.json`:P04/P06/P07/P08 四件说明各追加一段(bom.json 是 **CRLF**,
写出时必须保持原行尾风格,否则整文件 diff 会被刷满)。
- `docs/screws.json`:S12 备注把 `M3_FENCE_OPEN_CUT` 旧名改掉 + 追加推广说明,
强调 16 颗螺钉的数量/型号/孔位/相位/沉孔深度**一概不变**。
### 产出
- `docs/shots/fence_all_sections.svg` / `.png` —— 五处「改前 vs 改后」横剖面对比图
(脚本 `scratch/gen_fence_sections.py`,用 BRepClass3d_SolidClassifier 沿 720 个方位
逐 0.2mm 步进探测材料外缘半径,直接画材料剖面多边形;比调相机截图可靠得多)。
- `docs/shots/fence_j2_after.png` / `j4` / `j5` / `j6` —— 四处查看器渲染图。
### 查看器渲染的坑(省得再踩)
`look.py` 的相机参数是**世界坐标偏移**:`tx/ty` 要填 **95 / 部件高度**(例
`az=1.5708&po=1.95&rd=150&tx=95&ty=520&tz=-128`),tz 是**负值**。
我先前按局部坐标猜(`tx=19.5`、`tz=+440`),四张图 md5 完全相同 —— 那是渲染没生效,
不是"参数无效但画面相同"。判断参数是否生效要看 **md5 是否互不相同**。
-6
View File
@@ -1,6 +0,0 @@
{
"network": {
"httpProxy": "http://127.0.0.1:7897",
"noProxy": "localhost,127.0.0.1,192.168.31.10,z.ai,bigmodel.cn,modelscope.cn,sharellm.net,motomoto.lol"
}
}
-37
View File
@@ -1,37 +0,0 @@
# ZCode 工作区文件搜索忽略规则(.zcodeignore)
# 语法与 .gitignore 一致,只影响 ZCode 的 @ 文件候选 / Command Center / 文件树搜索,
# 不影响文件树浏览、上传或 Agent 文件访问。
# 修改 .gitignore 不会自动同步到本文件;可在设置页「从 .gitignore 同步」。
# ===== ↑ 以上同步自 .gitignore(「从 .gitignore 同步」只重写以上部分)=====
.git/
.hg/
.svn/
node_modules/
bower_components/
jspm_packages/
__pycache__/
site-packages/
venv/
coverage/
htmlcov/
lcov-report/
cmakefiles/
cmake-build-*/
bazel-*/
pods/
deriveddata/
storybook-static/
playwright-report/
test-results/
allure-results/
allure-report/
cdk.out/
*.egg-info/
*.dist-info/
eggs/
pip-wheel-metadata/
wheels/
# ----- ↑ 以上为 ZCode 默认排除规则(自定义规则请写在本行下方,不会被同步/恢复改动)-----
# 自定义规则写在下方(本行提示可删除)
+6
View File
@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
"""6 自由度人形机械臂:CadQuery 参数化建模、行程/干涉扫描、制造文件与查看器数据。
入口一律从仓根以 `python -m arm.xxx` 运行:`python -m` 会把仓根放进 sys.path,
包内 import 因此直接可解析,不需要 pip install。
"""
+3
View File
@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
"""行程与干涉扫描、强度与力矩核算、可达包络采样与网格注入。
"""
@@ -14,8 +14,15 @@ import math
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from sweep_v2 import CHAIN, DOCS
from arm.analysis.sweep_v2 import CHAIN
from arm.paths import DOCS
# 这两个脚本的顶层就是"算完并写文件",被 import 时会静默重写 travel/mesh 派生产物
# (实测:一次 importlib 冒烟测试就把 docs/travel.json 重写了一遍)。这里改成直接炸。
if __name__ != "__main__":
raise RuntimeError(
"finalize_travel 是脚本:顶层代码一执行就重写产物,不能 import。"
"要用就跑 python -m arm.analysis.finalize_travel。")
d = json.load(open(DOCS + "/travel_detail.json", encoding="utf-8"))
+100
View File
@@ -0,0 +1,100 @@
# -*- coding: utf-8 -*-
"""生成可达外包络的 ENV 零件(经纬网单件)追加进 web/mesh_arm.json:
ENV_WL_包络经纬网 —— kind=line,LineSegments 渲染(经向 10°/条、纬向 8mm/环)
包络 = rmax(x) 母线绕 J1 轴(平行 X,过 (y=128, z=610))整周回转;grp=root 不随关节动。"""
import json
import math
import os
import sys
import numpy as np
from arm.paths import DATA, CACHE
from arm import kinematics as HL
Y_AX = HL.Y_AXIS # 128
Z_AX = HL.Z_RL # 610
def main():
d = np.load(CACHE + "/ws_cloud.npz")
xs, rmax = d["xs"], d["rmax"]
ok = ~np.isnan(rmax)
xs, rmax = xs[ok], rmax[ok]
def grid(step_x, dphi_deg):
"""经纬参数网格 -> verts(NX*NP,3), segs(扁平索引对), tris。"""
xs_e = np.arange(xs.min(), xs.max() + 1e-9, step_x)
rm_e = np.interp(xs_e, xs, rmax)
nph = int(round(360.0 / dphi_deg))
phg = np.linspace(0.0, 2.0 * math.pi, nph, endpoint=False)
nx = len(xs_e)
V = np.empty((nx * nph, 3))
for i, x in enumerate(xs_e):
r = rm_e[i]
for j, ph in enumerate(phg):
V[i * nph + j] = (x, Y_AX + r * math.cos(ph), Z_AX + r * math.sin(ph))
segs = []
for i in range(nx - 1): # 经线(沿 x)
for j in range(nph):
segs += [i * nph + j, (i + 1) * nph + j]
for i in range(nx): # 纬线(沿 φ)
for j in range(nph):
j2 = (j + 1) % nph
segs += [i * nph + j, i * nph + j2]
tris = []
for i in range(nx - 1):
for j in range(nph):
j2 = (j + 1) % nph
a, b = i * nph + j, i * nph + j2
c, e = (i + 1) * nph + j, (i + 1) * nph + j2
tris += [a, c, b, b, c, e]
return V, segs, tris
# 经纬网单件:经向 10°一条、纬向 8mm一环(无需贴片面,线自带罩体感)
Vl, sl, _tl = grid(8.0, 10.0)
# 自证:包络尺寸应等于 ws_metrics 极值。
# 【2026-10-05c 修正】原来是 <1.0 的硬比较,但网格 x 是从 xs.min() 起按 step_x
# 对齐的(np.arange),最后一个采样点最多比真极值短一个步长 —— 一旦
# (x_fwd - xs.min()) 不是 step_x 的整数倍,断言就会假失败(实测差 6.46mm,
# 正好 < 8.0),导致包络注不进去、查看器里"可达包络"整个消失。改为
# "不得超出、且至多少一个步长"这个真正成立的判据。
step_x = 8.0
met = json.load(open(CACHE + "/ws_metrics.json", encoding="utf-8"))
assert Vl[:, 0].min() >= met["x_back"] - 1e-6, (Vl[:, 0].min(), met["x_back"])
assert Vl[:, 0].min() <= met["x_back"] + step_x + 1e-6, (Vl[:, 0].min(), met["x_back"])
assert Vl[:, 0].max() <= met["x_fwd"] + 1e-6, (Vl[:, 0].max(), met["x_fwd"])
assert Vl[:, 0].max() >= met["x_fwd"] - step_x - 1e-6, (Vl[:, 0].max(), met["x_fwd"])
rl = np.hypot(Vl[:, 1] - Y_AX, Vl[:, 2] - Z_AX)
# rl.max() 就是 rmax(x) 网格插值后的峰值,与 met["r_max"](原始点云极值)之间
# 只允许有插值/取整量级的差(实测 0.044mm),故给 1.0mm 容差。
assert abs(rl.max() - met["r_max"]) < 1.0, (rl.max(), met["r_max"])
assert np.all((Vl[:, 1] - Y_AX) ** 2 + (Vl[:, 2] - Z_AX) ** 2
<= (np.interp(Vl[:, 0], xs, rmax) + 1e-6) ** 2), "line"
print("包络经纬网: 顶点%d 线段%d(经10° x 纬8mm)" % (len(Vl), len(sl) // 2))
# ---- 追加进 mesh_arm.json ----
path = DATA + "/mesh_arm.json"
data = json.load(open(path, encoding="utf-8"))
data["parts"] = [p for p in data["parts"] if not p["name"].startswith("ENV_")]
mx = max(p["idx"] for p in data["parts"])
note_ws = ("可达外包络(运动学,docs/travel.json 滑块限位下的全组合 FK 包络):"
"r=rmax(x) 绕 J1 轴整周回转。高 %.0f~%.0f mm,前伸 %.0f / 后伸 %.0f mm,"
"离 J1 轴最远 %.0f mm;包络内含死区(肩下折不进),多关节同极位由控制器避碰。"
% (met["z_min"], met["z_max"], met["x_fwd"], met["x_back"], met["r_max"]))
line = dict(name="ENV_WL_包络经纬网", cn="包络经纬网", v=[list(p) for p in Vl],
s=sl, color=0x60a5fa, printed=False, vol=round(met["vol_dm3"] * 1e6, 1),
note=note_ws + " 经纬线:经向 10°/条,纬向 8mm/环。",
grp="root", jaw=0.0, ex=[0.0, 0.0, 0.0], idx=mx + 1,
kind="line")
data["parts"] += [line]
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, separators=(",", ":"))
os.replace(tmp, path)
print("已写入", path, "大小 %.1f MB" % (os.path.getsize(path) / 1e6))
if __name__ == "__main__":
main()
@@ -19,22 +19,19 @@ import math
import os
import re
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
import cadquery as cq
from armcore import OUT, DOCS, solids_of, bb, mesh_of
from arm.paths import OUT, DATA, DOCS
from arm.cad.helpers import solids_of, bb, mesh_of
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common
from OCP.BRepGProp import BRepGProp
from OCP.GProp import GProp_GProps
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
from OCP.gp import gp_Pnt, gp_Dir, gp_Ax2, gp_Vec
import human_layout as HL
from arm import kinematics as HL
RHO = {"PLA": 1.24e-3, "motor_g": 210.0} # g/mm3 for printed, g per module
# a slicer does not print solid: 3 perimeters + 30% infill lands near this fraction of
# the CAD envelope, and using the solid mass would blame the part for plastic that is not there
@@ -159,7 +156,7 @@ def section_props(sh, normal, origin, bend=2, size=400.0):
def arm_masses():
"""Everything distal to each joint, in grams, from the model itself."""
meta = json.load(io.open(OUT.replace("/out", "/web") + "/mesh_arm.json", encoding="utf-8"))
meta = json.load(io.open(DATA + "/mesh_arm.json", encoding="utf-8"))
TREE = ["root", "J1", "J2", "J3", "J4", "J5", "J6"]
tot = {}
for p in meta["parts"]:
@@ -202,7 +199,7 @@ def _mesh_vol_cg(v, t):
def part_table(payload_kg=None):
"""[(name, mass_g, cg_xyz, grp)],含末端负载这个集中质量点。"""
meta = json.load(io.open(OUT.replace("/out", "/web") + "/mesh_arm.json", encoding="utf-8"))
meta = json.load(io.open(DATA + "/mesh_arm.json", encoding="utf-8"))
rows = []
for p in meta["parts"]:
nm = p["name"]
@@ -488,5 +485,10 @@ def main():
if __name__ == "__main__":
b = main()
print("\n不合格截面 %d 处" % len(b))
sys.stdout.flush() # os._exit 不刷缓冲,重定向到文件时否则输出全丢
# OCC/cadquery 的静态析构在解释器正常退出时必定段错误(实测:光是
# `python -c "import arm.cad.helpers"` 就返回 139,与本项目代码无关)。所以入口
# 脚本跑完后显式刷缓冲再 os._exit —— 否则全流程已经成功、exit code 却是 139,
# 会把上游脚本的 set -e 判成失败。只在 __main__ 出口生效, 被 import 时不触发。
sys.stdout.flush()
os._exit(0)
+13 -10
View File
@@ -27,8 +27,7 @@
就是臂撞躯干,是真约束。--no-stand 仅用于诊断:排除台架后重扫,给出"不含躯干约束"的
自由空间行程,用来分辨某个限位是臂自身折叠产生的还是躯干挡的(台架位置本身是假设,
改它等于改测试工装,不等于改设计)。
运行:python src/sweep_v2.py [--workers 20] [--step 2] [--combo-step 6]
运行:python -m arm.analysis.sweep_v2 [--workers 20] [--step 2] [--combo-step 6]
[--no-combos] [--joints J2,J4] [--no-gjk] [--no-stand]
"""
import json
@@ -38,20 +37,17 @@ import sys
import time
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
import multiprocessing as mp
import arm_build as AB
from arm.cad import arm_build as AB
import cadquery as cq
import human_layout as HL
from armcore import place, bb, DOCS
from arm import kinematics as HL
from arm.paths import DOCS
from arm.cad.helpers import place, bb
VOL_MIN = 2.0 # mm3,超过即计为干涉
CHAIN = ["root", "J1", "J2", "J3", "J4", "J5", "J6"]
MOUNT_PAIRS = {
@@ -175,7 +171,7 @@ def _do_simplex(s):
def load_model(include_fixture=True):
"""include_fixture=False 时把 P01 立柱 / P02 底盘(固定测试台架,非本体)排除在
碰撞配对之外——用来得到真机工作空间而不是台架工作空间。见 human_layout.FIXTURE_PREFIX。"""
import arm_model as AM
from arm import build_model as AM
JS = AM.build()
parts = []
for p in AB.PARTS:
@@ -721,3 +717,10 @@ def main():
if __name__ == "__main__":
main()
# OCC/cadquery 的静态析构在解释器正常退出时必定段错误(实测:光是
# `python -c "import arm.cad.helpers"` 就返回 139,与本项目代码无关)。所以入口
# 脚本跑完后显式刷缓冲再 os._exit —— 否则全流程已经成功、exit code 却是 139,
# 会把上游脚本的 set -e 判成失败。只在 __main__ 出口生效, 被 import 时不触发。
sys.stdout.flush()
os._exit(0)
@@ -29,17 +29,13 @@ import json
import math
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
import human_layout as HL
from armcore import DOCS
import strength_check as SC
from arm import kinematics as HL
from arm.paths import DOCS
from arm.analysis import strength_check as SC
G = 9.81
T_CONT = float(os.environ.get("ARMT_CONT", "2.5"))
T_PEAK = float(os.environ.get("ARMT_PEAK", "8.0"))
@@ -206,9 +202,12 @@ def main():
w = min(ok, key=lambda t: mp[t])
print(" 整臂瓶颈 %s -> %.2f kg" % (w, mp[w]))
print("\n已写出 docs/torque_balance.json + docs/torque_report.md")
sys.stdout.flush()
if __name__ == "__main__":
main()
# OCC/cadquery 的静态析构在解释器正常退出时必定段错误(实测:光是
# `python -c "import arm.cad.helpers"` 就返回 139,与本项目代码无关)。所以入口
# 脚本跑完后显式刷缓冲再 os._exit —— 否则全流程已经成功、exit code 却是 139,
# 会把上游脚本的 set -e 判成失败。只在 __main__ 出口生效, 被 import 时不触发。
sys.stdout.flush()
os._exit(0)
+143
View File
@@ -0,0 +1,143 @@
# -*- coding: utf-8 -*-
"""关节角组合可达空间(工具尖点)计算:FK 采样 + J1 旋转对称包络 + 体积估计"""
import json
import math
import os
import sys
import numpy as np
from arm import kinematics as HL
from arm.paths import DOCS, CACHE
TRAVEL = {t["axis"]: (t["lo"], t["hi"]) for t in
json.load(open(DOCS + "/travel.json", encoding="utf-8"))}
TIP = (HL.X_ARM, HL.Y_AXIS, HL.Z_TIP) # P09 法兰底面中心(工具安装面)
J1_AX = (HL.Y_AXIS, HL.Z_RL) # J1 转轴: 过 Y=128, Z=610, 平行 X
def rot_about(p, k, ang):
"""绕轴 (p 点, k 方向) 旋转 ang(弧度, 可为数组M,);返回 Rm(M,3,3), t(M,3)。
p' = Rm @ (p_pt - p) + p,与 AB.R 的 gp_Trsf.SetRotation 右手定则一致。"""
k = np.asarray(k, dtype=float)
k = k / np.linalg.norm(k)
ang = np.atleast_1d(np.asarray(ang, dtype=float))
px, py, pz = p
K = np.array([[0.0, -k[2], k[1]], [k[2], 0.0, -k[0]], [-k[1], k[0], 0.0]])
K2 = K @ K
Rm = np.eye(3)[None, :, :] + np.sin(ang)[:, None, None] * K[None, :, :] \
+ (1.0 - np.cos(ang))[:, None, None] * K2[None, :, :]
pp = np.array([px, py, pz])
t = pp[None, :] - np.einsum("mij,j->mi", Rm, pp)
return Rm, t
def fk_batch(angles, tip=TIP):
"""angles: dict idx->弧度数组(M,);返回 (M,3) 世界坐标。链式 acc 与 sweep_v2 一致:
父关节变换在最外层 p' = R1∘R2∘…∘R5 (p),各 R_i 绕其零位世界轴(human_layout 唯一事实源)。"""
pts = np.tile(np.asarray(tip, dtype=float), (angles[1].shape[0], 1))
for i in range(5, 0, -1): # J6 跳过:尖点在 J6 轴上,其转角不改变尖点位置
rear, d = HL.JOINTS[i - 1][2], HL.JOINTS[i - 1][3]
Rm, t = rot_about(rear, d, angles[i])
pts = np.einsum("mij,mj->mi", Rm, pts) + t
return pts
def sweep(step=2.0):
j2 = np.radians(np.arange(TRAVEL["J2"][0], TRAVEL["J2"][1] + 1e-9, step))
j4 = np.radians(np.arange(TRAVEL["J4"][0], TRAVEL["J4"][1] + 1e-9, step))
j5 = np.radians(np.arange(TRAVEL["J5"][0], TRAVEL["J5"][1] + 1e-9, step))
g2, g4, g5 = np.meshgrid(j2, j4, j5, indexing="ij")
M = g2.size
angles = {1: np.zeros(M), 2: g2.ravel(), 3: np.zeros(M),
4: g4.ravel(), 5: g5.ravel()}
pts = fk_batch(angles)
# 自证1:零位尖点必须回到 (18.5,128,93)
z0 = fk_batch({i: np.zeros(1) for i in range(1, 6)})
assert np.allclose(z0[0], TIP, atol=1e-9), z0[0]
# 自证2:J1/J6 旋转不改变 (x, r) 与极值位置——J1 取中值扫一遍,r 分布应与 J1=0 相同
for phi in (0.5, 1.2, 2.4):
v1 = np.full(M, phi)
p1 = fk_batch({1: v1, 2: g2.ravel(), 3: np.zeros(M),
4: g4.ravel(), 5: g5.ravel()})
r1 = np.hypot(p1[:, 1] - J1_AX[0], p1[:, 2] - J1_AX[1])
r0 = np.hypot(pts[:, 1] - J1_AX[0], pts[:, 2] - J1_AX[1])
assert np.allclose(np.sort(r0), np.sort(r1), atol=1e-9), "J1 对称性被破坏"
# 自证3:J3 旋转(大臂自转)不改变尖点位置——关节轴过尖点所在纵线
print("J3 对称性: 尖点 x=%.1f 与 J3 轴 x=%.1f 一致=%s, y=%.1f 与轴 y=%.1f 一致=%s"
% (TIP[0], HL.X_ARM, abs(TIP[0] - HL.X_ARM) < 1e-9,
TIP[1], HL.Y_AXIS, abs(TIP[1] - HL.Y_AXIS) < 1e-9))
print("J6 含于轴: 尖点 (%.1f,%.1f) vs 轴 (%.1f,%.1f)"
% (TIP[0], TIP[1], HL.JOINTS[5][2][0], HL.JOINTS[5][2][1]))
return pts
def envelope(pts, grid=1.0):
"""(x, r) 半平面栅格化占用 + 体积(绕 J1 轴回转)。"""
x = pts[:, 0]
r = np.hypot(pts[:, 1] - J1_AX[0], pts[:, 2] - J1_AX[1])
x0, x1 = math.floor(x.min()), math.ceil(x.max())
r0, r1 = 0.0, math.ceil(r.max())
nx = int(round((x1 - x0) / grid)) + 1
nr = int(round((r1 - r0) / grid)) + 1
xi = np.clip(((x - x0) / grid).round().astype(int), 0, nx - 1)
ri = np.clip((r / grid).round().astype(int), 0, nr - 1)
occ = np.zeros((nx, nr), dtype=bool)
occ[xi, ri] = True
# 每个 x 列只保留 r <= 该列可达最大 r(回转体沿 r 连续填充)
for i in range(nx):
j = np.nonzero(occ[i])[0]
if j.size:
occ[i, : j.max() + 1] = True
rr = r0 + np.arange(nr) * grid
vol = float((occ * (2.0 * math.pi * rr)[None, :] * grid * grid).sum())
return dict(occ=occ, x0=x0, grid=grid, rm_axes=rr, vol_mm3=vol,
nx=nx, nr=nr)
def main():
pts = sweep()
x, y, z = pts[:, 0], pts[:, 1], pts[:, 2]
r = np.hypot(y - J1_AX[0], z - J1_AX[1])
env = envelope(pts)
# 每个 x 的 r 包络(侧视/俯视剖面用)
xs = env["x0"] + np.arange(env["nx"]) * env["grid"]
rmax_x, rmin_x = [], []
for i in range(env["nx"]):
j = np.nonzero(env["occ"][i])[0]
if j.size:
rmax_x.append(env["rm_axes"][j.max()])
rmin_x.append(env["rm_axes"][j.min()])
else:
rmax_x.append(np.nan)
rmin_x.append(np.nan)
os.makedirs(CACHE, exist_ok=True)
np.savez_compressed(CACHE + "/ws_cloud.npz",
pts=pts, xs=np.array(xs),
rmax=np.array(rmax_x), rmin=np.array(rmin_x))
met = dict(
z_max=float(z.max()), z_min=float(z.min()),
x_fwd=float(x.max()), x_back=float(x.min()),
y_lo=float(y.min()), y_hi=float(y.max()),
r_max=float(r.max()), r_min=float(r.min()),
reach_from_j1_axis=float(r.max()),
spread=float(z.max() - z.min()),
vol_dm3=env["vol_mm3"] / 1e6,
tip=TIP, j1_axis=list(J1_AX),
limits={k: list(v) for k, v in TRAVEL.items()},
)
# 自证4:极值点姿态可解释(打印取极值的角度组合索引)
imax = int(np.argmax(z)); imin = int(np.argmin(z))
ifwd = int(np.argmax(x)); iback = int(np.argmin(x))
met["pose_extreme"] = dict(
zmax_idx=imax, zmin_idx=imin, xfwd_idx=ifwd, xback_idx=iback)
print(json.dumps({k: v for k, v in met.items()
if k not in ("limits", "pose_extreme")},
ensure_ascii=False, indent=1))
json.dump(met, open(CACHE + "/ws_metrics.json", "w", encoding="utf-8"),
ensure_ascii=False, indent=1)
if __name__ == "__main__":
main()
+34 -50
View File
@@ -10,17 +10,17 @@ import math
import os
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cadquery as cq
import arm_build as AB
from armcore import (M, C, cyl, box, ring, place, trs, bb, bbox_str, solids_of, OUT, WEB, DOCS)
from arm_build import (Joint, add, bx, Ycyl, Ybox, Yplate, prism, dogbone, sector,
from arm.cad import arm_build as AB
from arm.paths import OUT, WEB, DOCS
from arm.constants import M, C
from arm.cad.helpers import cyl, box, ring, place, trs, bb, bbox_str, solids_of
from arm.cad.arm_build import (Joint, add, bx, Ycyl, Ybox, Yplate, prism, dogbone, sector,
FLANGE_Z, COVER_Z, HUB_FACE_Z, CLAMP_SPAN, HUB_M2_CLOCK,
BORE_R, CB_R, PCB_W, D2R, T, R, PARTS)
import human_layout as HL
from arm import kinematics as HL
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common
from human_layout import (HUB, PLATE_T, R_MOT, Z_TIP, Z_WR, Z_EI, Z_SH, Z_RL, Z_R1,
from arm.kinematics import (HUB, PLATE_T, R_MOT, Z_TIP, Z_WR, Z_EI, Z_SH, Z_RL, Z_R1,
Z_R3, Z_HUB3, Z_EL, Z_R6, Z_HUB6,
Z_R4, Z_CAP, Z_M6, Z_CHEST0, Z_CHEST1, X_J1_HUB, X_ARM, L1,
X_FORK_GAP, X_BAY_EXT,
@@ -33,24 +33,23 @@ AB.VERSION = VERSION
AB.ITER = ITER
# ------------------------------------------------ 逐件模块(几何已拆出)
from parts import PART_MODULES
from part_tools import (
from arm.cad.parts import PART_MODULES
from arm.cad.part_tools import (
BOSS, CHAMFER_EDGE, CLAMP_BACKOFF, CLAMP_BCD, CLAMP_CLOCK, CLAMP_CLOCK_LOCAL, CLAMP_CLR,
CLAMP_ROOT, CLAMP_T, CLEARANCE_GAP, DISC_R, FENCE_OPEN_CUT, FILLET_LARGE, FILLET_MEDIUM,
FILLET_SMALL, FILLET_TINY, FILLET_XLARGE, HOLLOW_FILLET, HOLLOW_WALL, SLEEVE_R, XB, Xcyl,
Xplate, Y_MID, cut_motor_fence, hollow_channel, hollow_tent_window, hub_m2_holes,
make_direct_hub, make_pitch_to_roll_bracket, motor_front_clamp,
)
from parts.p01_column import X_COL, Y_COL, Z_LOCK, p_chest, split_p01_poka_yoke
from parts.p02_base import make_p02_poka_yoke, p_base_foot
from parts.p03_girdle import p_girdle
from parts.p04_fork import P04_CH, p_fork
from parts.p05_roll_base import p_shoulder_roll_base
from parts.p06_humerus import P06_HOLLOW_WIN, P06_WIN_XZ, p_humerus_bone
from parts.p07_forearm import P07_CH, p_forearm_bone
from parts.p08_wrist import p_wrist_bracket
from parts.p09_flange import p_tool_flange
from arm.cad.parts.p01_column import X_COL, Y_COL, Z_LOCK, p_chest, split_p01_poka_yoke
from arm.cad.parts.p02_base import make_p02_poka_yoke, p_base_foot
from arm.cad.parts.p03_girdle import p_girdle
from arm.cad.parts.p04_fork import P04_CH, p_fork
from arm.cad.parts.p05_roll_base import p_shoulder_roll_base
from arm.cad.parts.p06_humerus import P06_HOLLOW_WIN, P06_WIN_XZ, p_humerus_bone
from arm.cad.parts.p07_forearm import P07_CH, p_forearm_bone
from arm.cad.parts.p08_wrist import p_wrist_bracket
from arm.cad.parts.p09_flange import p_tool_flange
# 兼容旧工具:scratch/ 的诊断脚本按 arm_model.<符号> 取用件构造函数与开关
def build():
@@ -80,9 +79,7 @@ def build():
for t in ("J1", "J2", "J3", "J4", "J5", "J6"):
AB.GRP[motor_of[t]] = par[t]
AB.JAW = {}
# 逐件模块:几何、件号/配色/说明、装配帧归属都在 src/parts/ 里,
# 逐件模块:几何、件号/配色/说明、装配帧归属都在 arm/cad/parts/ 里,
# 这里只按 BOM 顺序收集 specs(),并把件号登记到装配帧图上。
parts_to_build = []
for _m in PART_MODULES:
@@ -177,39 +174,26 @@ if __name__ == "__main__":
# 紧固件(螺钉)实例注入:把 docs/screws.json 的 72 颗螺钉按真实孔位还原成三维实例,
# 追加进 web/mesh_arm.json 的 parts(查看器可点 BOM 螺丝行高亮定位;纯数据,不动 CAD)
try:
import screw_mesh
screw_mesh.main()
except Exception as e:
print("螺钉实例注入失败(不影响主流程,可手动跑 src/screw_mesh.py):", e, flush=True)
from arm.dataio import screw_mesh
screw_mesh.main()
cl = AB.collide()
try:
from build_viewer import rebuild_viewer
rebuild_viewer()
except Exception as e:
print("rebuild_viewer error:", e, flush=True)
from arm.viewer.build_viewer import rebuild_viewer
rebuild_viewer()
# 可达包络自动注入:AB.export 刚重写过 mesh_arm.json(包络件会被冲掉),
# 这里重跑 FK 采样 + 包络网格注入,再重打包一次查看器(约 3~4s,纯数据无 CAD 计算)
try:
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "scratch"))
import ws_sweep
import gen_env_mesh
t_env = time.time()
ws_sweep.main()
gen_env_mesh.main()
from build_viewer import rebuild_viewer as _rbv
_rbv()
print(f"-> 可达包络已自动注入查看器(+{time.time()-t_env:.1f}s)✓\n", flush=True)
except Exception as e:
print("可达包络自动注入失败(不影响主流程,修复后手动跑 scratch/gen_env_mesh.py + src/build_viewer.py):",
e, flush=True)
from arm.analysis import ws_sweep, gen_env_mesh
t_env = time.time()
ws_sweep.main()
gen_env_mesh.main()
rebuild_viewer()
print(f"-> 可达包络已自动注入查看器(+{time.time() - t_env:.1f}s)✓\n", flush=True)
print("=" * 80, flush=True)
print("✅ 机器人手臂编译、导出与查看器发布全流程完成!", flush=True)
print("=" * 80, flush=True)
# OCC/cadquery 的静态析构在解释器正常退出时必定段错误(实测:光是
# `python -c "import arm.cad.helpers"` 就返回 139,与本项目代码无关)。所以入口
# 脚本跑完后显式刷缓冲再 os._exit —— 否则全流程已经成功、exit code 却是 139,
# 会把上游脚本的 set -e 判成失败。只在 __main__ 出口生效, 被 import 时不触发。
sys.stdout.flush()
os._exit(0)
+8
View File
@@ -0,0 +1,8 @@
# -*- coding: utf-8 -*-
"""几何与装配层。
helpers 放置/基础体/网格化等无状态 helper
arm_build Joint 装配模型、零件登记(add)、STEP/STL/BOM/mesh 导出
part_tools 跨件共用刀法(减重腔、压盖、转接座、圆角常量、螺钉孔位事实源)
parts/ 每个打印件一个模块:几何 + 件号/配色/说明 + 装配帧归属
"""
+8 -8
View File
@@ -9,7 +9,7 @@ bore + PCBA pocket + pinch screws; driven hub mating plane at motor rear datum
the existing face-spline halves + split clamp bolt straight on), the part
registry, and the STEP/STL/mesh/BOM/collision export pipeline.
Run: python arm_model.py -> out/, web/mesh_arm.json, docs/
Run: python -m arm.build_model -> out/, webapp/data/mesh_arm.json, docs/
"""
import math
import os
@@ -21,10 +21,10 @@ try:
except Exception:
pass
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cadquery as cq
from armcore import (M, C, V, trs, place, bb, bbox_str, cyl, ring, box, holes,
solids_of, mesh_of, MOTOR_TOL, OUT, WEB, DOCS, SRC_STEP, HALF_STEP, CLAMP_STEP)
from arm.paths import OUT, WEB, DATA, DOCS, SRC_STEP, HALF_STEP, CLAMP_STEP
from arm.constants import M, C, MOTOR_TOL
from arm.cad.helpers import V, trs, place, bb, bbox_str, cyl, ring, box, holes, solids_of, mesh_of
from OCP.gp import gp_Trsf, gp_Vec, gp_Pnt, gp_Dir, gp_Ax1
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common
from OCP.GProp import GProp_GProps
@@ -460,7 +460,7 @@ import time
from concurrent.futures import ThreadPoolExecutor
def export(JS):
for d in (OUT, OUT + "/parts_step", OUT + "/print_stl", OUT + "/assembly", WEB, DOCS):
for d in (OUT, OUT + "/parts_step", OUT + "/print_stl", OUT + "/assembly", DATA, DOCS):
os.makedirs(d, exist_ok=True)
print("=" * 80, flush=True)
@@ -499,7 +499,7 @@ def export(JS):
# 2. 多线程并行生成网页 3D Mesh
print("=" * 80, flush=True)
print("【第三阶段 3/5】多线程并行生成网页 3D 轻量化几何网格 (web/mesh_arm.json)...", flush=True)
print("【第三阶段 3/5】多线程并行生成网页 3D 轻量化几何网格 (webapp/data/mesh_arm.json)...", flush=True)
print("=" * 80, flush=True)
done_mesh = 0
@@ -541,12 +541,12 @@ def export(JS):
L1=round(d3(JS["J2"].rear, JS["J4"].rear), 1),
L2=round(d3(JS["J4"].rear, JS["J5"].rear), 1)),
measured=dict(M=M, C=C, HUB_FACE_Z=HUB_FACE_Z, CLAMP_SPAN=list(CLAMP_SPAN))),
open(WEB + "/mesh_arm.json", "w", encoding="utf-8"), ensure_ascii=False)
open(DATA + "/mesh_arm.json", "w", encoding="utf-8"), ensure_ascii=False)
bom = [dict(件号=p["name"], 名称=p["cn"], 类型="3D打印" if p["printed"] else "已有/电机",
数量=1, 体积cm3=round(p["vol"] / 1000.0, 1),
质量g_PLA=round(p["vol"] * 1.24e-3, 1), 包络=p["bb"], 说明=p["note"]) for p in PARTS]
json.dump(bom, open(DOCS + "/bom.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1)
print(f"-> 3D网格与BOM清单已保存至 web/mesh_arm.json 与 docs/bom.json ✓\n", flush=True)
print(f"-> 3D网格与BOM清单已保存至 webapp/data/mesh_arm.json 与 docs/bom.json ✓\n", flush=True)
return bom
+2 -62
View File
@@ -1,12 +1,9 @@
# -*- coding: utf-8 -*-
"""Shared constants + CAD/placement helpers for the robot arm build.
"""CAD 放置与几何 helper:刚体变换、求交/包围盒、基础体、网格化与 STEP 读取。
All motor interface numbers are MEASURED from
G:/Robot_v2/Model/Solidworks/电机.STEP and 配件/make_v9.py (see docs/interfaces.md).
实测接口数值在 arm/constants.py,仓库路径在 arm/paths.py——本模块不再持有它们。
"""
import math
import json
import os
import cadquery as cq
from OCP.gp import gp_Trsf, gp_Vec, gp_XYZ, gp_Mat, gp_Pnt, gp_Dir, gp_Ax1, gp_Ax2, gp_Quaternion
@@ -20,65 +17,8 @@ from OCP.TopAbs import TopAbs_FACE, TopAbs_REVERSED, TopAbs_SOLID
from OCP.TopoDS import TopoDS
V = cq.Vector
ROOT = r"G:/Robot_v2"
SRC_STEP = ROOT + r"/Model/Solidworks/电机.STEP"
HALF_STEP = ROOT + r"/Model/Solidworks/配件/端面齿半联轴器_相同件x2.STEP"
CLAMP_STEP = ROOT + r"/Model/Solidworks/配件/端面齿联轴器_抱箍_两片装配.STEP"
WORKSPACE = ROOT + r"/机器人手臂"
OUT = WORKSPACE + r"/out"
WEB = WORKSPACE + r"/web"
DOCS = WORKSPACE + r"/docs"
# ---------------- MEASURED: motor module (local frame: origin = PCBA rear face centre, +Z = output) ----
M = dict(
pcba_w=63.6, # square PCBA across flats
pcba_t=9.0, # z 0 -> 9.0
rearplate_d=60.0, # z 5.5 -> 10.5
hous_d=60.0, # housing OD, z 10.5 -> 52.5
hous_len=42.0,
cover_d=60.30, # top cover OD (R30.15), z 52.5 -> 56.0
cover_z=56.0, # cover front face = module total length
flange_z=54.942, # 2nd-stage output flange face
neck_d=36.0, # female neck bore in the cover
neck_depth=1.0,
centre_d=6.5, # centre bore in the flange
centre_depth=4.0,
m2_bcd=29.0,
m2_clock=(8.05, 128.07, 248.06),
m2_tap_d=1.85, # M2 pilot for self-tapping in printed material
m2_depth=6.0,
m2_clear_d=2.75,
m2_cb_d=5.2,
)
# ---------------- MEASURED: face-spline coupling (existing, printed by the user) ------------------------
C = dict(
n_teeth=12, tooth_r0=19.0, tooth_r1=24.5, tooth_s=2.5,
flange_t=4.50, part_z=5.560, # mirrored stack
hub_r=27.0,
gap=9.00, # cover-face to cover-face when closed
clamp_od_r=30.0, clamp_len=7.80,
lug_r1=37.0, lug_t=4.50,
bolt_r=32.50, bolt_d=3.80,
z0=1.66, z1=9.46, # clamp envelope in the flange frame (motor cover face = 1.060)
)
# axial stations, motor local frame:
COVER_Z = M["cover_z"] # 56.0
HUB_FACE_Z = COVER_Z + C["gap"] # 65.0 = mating face of the driven hub
CLAMP_R = C["clamp_od_r"] + 0.6 # radial keep-out around the joint axis
LUG_R = C["lug_r1"] + 1.0 # radial keep-out where the bolt lugs are
# ---------------- clearances / print rules -------------------------------------------------------------
CLR = dict(bore_slip=0.20, pilot=0.10, hole_3=3.4, hole_27=2.8, wall_min=2.0)
FIT = dict(motor_bore_d=M["hous_d"] + 2 * 0.15, cover_cb_d=M["cover_d"] + 2 * 0.35)
# 电机【轴向长度】公差(用户 2026-10-07 要求:余量偏大稍微减去 1mm,由 2mm 改为 1mm)。
# 这条链是"后端板螺钉 + 前端压盖螺钉"两个刚性夹持面,螺钉头一旦坐死在沉孔台阶上
# 就再也不能进给,所以公差没法靠"拧紧"消化,只能靠两件事吃掉(见 arm_build.Joint.pocket
# 与 motor_front_clamp):
# 长出来的 0~+1mm -> 后端板 4xM3 螺钉头下垫 0~1mm 垫片圈(螺钉被垫高 -> 电机被拉回)
# 短下去的 0~-1mm -> 电机尾端面与后端板承压面之间垫 0~1mm 垫片(承压面已按 MOTOR_TOL
# 让空,见 Joint.pocket 的 REAR_SEAT)
# 两边都不动压盖/凸台的几何,关节轴向长度不变。
MOTOR_TOL = 1.0
# ---------------- geometry helpers --------------------------------------------------------------------
+5 -6
View File
@@ -1,13 +1,12 @@
# -*- coding: utf-8 -*-
"""跨件共用的刀法与统一配方:X 向基础体、减重腔 helper、电机前端压盖、止口 hub、pitch->roll 转接座骨架。"""
import arm_build as AB
from arm.cad import arm_build as AB
import cadquery as cq
import human_layout as HL
from arm import kinematics as HL
import math
from armcore import cyl, place
from arm_build import COVER_Z, R, T, Ycyl, bx, prism
from human_layout import X_ARM, Y_AXIS, Y_COVER
from arm.cad.helpers import cyl, place
from arm.cad.arm_build import COVER_Z, R, T, Ycyl, bx, prism
from arm.kinematics import X_ARM, Y_AXIS, Y_COVER
Y_MID = Y_AXIS
@@ -3,6 +3,6 @@
arm_model.build() 只按 PART_MODULES 顺序收集 specs();改哪件就开哪个文件。
"""
from parts import p01_column, p02_base, p03_girdle, p04_fork, p05_roll_base, p06_humerus, p07_forearm, p08_wrist, p09_flange
from arm.cad.parts import p01_column, p02_base, p03_girdle, p04_fork, p05_roll_base, p06_humerus, p07_forearm, p08_wrist, p09_flange
PART_MODULES = [p01_column, p02_base, p03_girdle, p04_fork, p05_roll_base, p06_humerus, p07_forearm, p08_wrist, p09_flange]
@@ -1,11 +1,10 @@
# -*- coding: utf-8 -*-
"""P01 立柱三段(A/B/C):躯干承力柱 + 阶梯榫卯与防反装对锁孔。"""
import cadquery as cq
from armcore import cyl
from arm_build import Ycyl, bx
from human_layout import Y_AXIS, Z_CHEST1
from part_tools import Xcyl
from arm.cad.helpers import cyl
from arm.cad.arm_build import Ycyl, bx
from arm.kinematics import Y_AXIS, Z_CHEST1
from arm.cad.part_tools import Xcyl
# ========== 躯干部分 ==========
# ========== 躯干/测试台架部分 (后置防倾翻高刚性机架) ==========
@@ -1,8 +1,8 @@
# -*- coding: utf-8 -*-
"""P02 底座三段(中核心 / 前向力臂 / 后向力臂):后置防倾翻机架与防呆母榫。"""
import cadquery as cq
from armcore import cyl
from parts.p01_column import X_COL, Y_COL
from arm.cad.helpers import cyl
from arm.cad.parts.p01_column import X_COL, Y_COL
def make_p02_poka_yoke():
@@ -2,11 +2,11 @@
"""P03 肩吊臂:板式桁架 + 侧开式电机托架 + M1 后置法兰。"""
import cadquery as cq
import math
from armcore import cyl
from arm_build import Yplate, bx
from human_layout import X_BAY_EXT, Y_AXIS, Z_CHEST1, Z_R1, Z_RL
from part_tools import Xcyl, make_surface_tie_bridge
from parts.p01_column import X_COL, Y_COL, Z_LOCK
from arm.cad.helpers import cyl
from arm.cad.arm_build import Yplate, bx
from arm.kinematics import X_BAY_EXT, Y_AXIS, Z_CHEST1, Z_R1, Z_RL
from arm.cad.part_tools import Xcyl, make_surface_tie_bridge
from arm.cad.parts.p01_column import X_COL, Y_COL, Z_LOCK
def p_girdle(j1):
@@ -1,13 +1,13 @@
# -*- coding: utf-8 -*-
"""P04 肩叉:J1 从动杯体、M2 前端压盖、立柱 U 形减重槽、Hermite 弧面前脸。"""
import arm_build as AB
from arm.cad import arm_build as AB
import cadquery as cq
import math
from armcore import cyl
from arm_build import COVER_Z, Ycyl, bx
from human_layout import X_ARM, X_FORK_GAP, Y_AXIS, Y_COVER, Y_REAR, Z_RL, Z_SH
from arm.cad.helpers import cyl
from arm.cad.arm_build import COVER_Z, Ycyl, bx
from arm.kinematics import X_ARM, X_FORK_GAP, Y_AXIS, Y_COVER, Y_REAR, Z_RL, Z_SH
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common
from part_tools import (Xplate, clamp_hole_drills, cut_motor_fence, hollow_channel,
from arm.cad.part_tools import (Xplate, clamp_hole_drills, cut_motor_fence, hollow_channel,
hub_m2_holes, make_surface_tie_bridge, motor_front_clamp)
@@ -1,9 +1,8 @@
# -*- coding: utf-8 -*-
"""P05 大臂旋转座:J2 俯仰 -> J3 滚转(复用 part_tools 的 pitch->roll 骨架)。"""
import cadquery as cq
from human_layout import Z_R3, Z_SH
from part_tools import make_pitch_to_roll_bracket
from arm.kinematics import Z_R3, Z_SH
from arm.cad.part_tools import make_pitch_to_roll_bracket
def p_shoulder_roll_base(j2, j3, half=None):
"""P05_大臂旋转座_螺旋体30%_墙4底5_竖打(深度圆角优化版)
@@ -1,11 +1,11 @@
# -*- coding: utf-8 -*-
"""P06 大臂骨架:J3 滚转 -> J4 俯仰的 Loft 空心梁。"""
import arm_build as AB
from arm.cad import arm_build as AB
import cadquery as cq
from armcore import cyl
from arm_build import COVER_Z, Ycyl, bx
from human_layout import X_ARM, Y_AXIS, Y_COVER, Y_REAR, Z_EL, Z_R3
from part_tools import (
from arm.cad.helpers import cyl
from arm.cad.arm_build import COVER_Z, Ycyl, bx
from arm.kinematics import X_ARM, Y_AXIS, Y_COVER, Y_REAR, Z_EL, Z_R3
from arm.cad.part_tools import (
CLAMP_BACKOFF, CLAMP_CLR, CLAMP_T, cut_motor_fence, hub_m2_holes,
make_surface_tie_bridge_neg_x, motor_front_clamp,
)
@@ -1,13 +1,13 @@
# -*- coding: utf-8 -*-
"""P07 小臂骨架:J4 -> J5 的 L 型结构 + 顶板减重槽。"""
import arm_build as AB
from arm.cad import arm_build as AB
import cadquery as cq
import human_layout as HL
from arm import kinematics as HL
import math
from armcore import cyl
from arm_build import Ycyl, bx
from human_layout import PLATE_T, X_ARM, Y_COVER, Y_HUB, Y_REAR, Z_EL, Z_WR
from part_tools import (
from arm.cad.helpers import cyl
from arm.cad.arm_build import Ycyl, bx
from arm.kinematics import PLATE_T, X_ARM, Y_COVER, Y_HUB, Y_REAR, Z_EL, Z_WR
from arm.cad.part_tools import (
FILLET_SMALL, cut_motor_fence, hollow_channel, hub_m2_holes,
make_surface_tie_bridge_neg_x, motor_front_clamp,
)
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
"""P08 腕偏转座:J5 俯仰 -> J6 滚转(复用 part_tools 的 pitch->roll 骨架)。"""
from human_layout import Z_R6, Z_WR
from part_tools import make_pitch_to_roll_bracket
from arm.kinematics import Z_R6, Z_WR
from arm.cad.part_tools import make_pitch_to_roll_bracket
def p_wrist_bracket(j5, j6):
"""P08_腕偏转座_螺旋体25%_墙4底5_竖打(深度圆角优化版)
@@ -1,11 +1,10 @@
# -*- coding: utf-8 -*-
"""P09 工具法兰:末端法兰盘与 J6 直锁 hub。"""
import math
from armcore import cyl
from arm_build import D2R
from human_layout import PLATE_T, X_ARM, Y_AXIS, Z_HUB6
from part_tools import CHAMFER_EDGE, CLAMP_BACKOFF, FILLET_SMALL, FILLET_TINY, make_direct_hub
from arm.cad.helpers import cyl
from arm.cad.arm_build import D2R
from arm.kinematics import PLATE_T, X_ARM, Y_AXIS, Z_HUB6
from arm.cad.part_tools import CHAMFER_EDGE, CLAMP_BACKOFF, FILLET_SMALL, FILLET_TINY, make_direct_hub
def p_tool_flange(j6):
"""P09_工具法兰_螺旋体30%_墙4底5_竖打 深度圆角优化版
+58
View File
@@ -0,0 +1,58 @@
# -*- coding: utf-8 -*-
"""实测接口数值:电机模组、端面齿联轴器、配合公差与电机轴向公差。
全部 MEASURED 自 G:/Robot_v2/Model/Solidworks/电机.STEP 与配件(见 docs/interfaces.md)。
改之前先确认量的是同一个基准面。"""
# 注:本段由 armcore.py 原样搬来,行内容与注释未作改动。
# ---------------- MEASURED: motor module (local frame: origin = PCBA rear face centre, +Z = output) ----
M = dict(
pcba_w=63.6, # square PCBA across flats
pcba_t=9.0, # z 0 -> 9.0
rearplate_d=60.0, # z 5.5 -> 10.5
hous_d=60.0, # housing OD, z 10.5 -> 52.5
hous_len=42.0,
cover_d=60.30, # top cover OD (R30.15), z 52.5 -> 56.0
cover_z=56.0, # cover front face = module total length
flange_z=54.942, # 2nd-stage output flange face
neck_d=36.0, # female neck bore in the cover
neck_depth=1.0,
centre_d=6.5, # centre bore in the flange
centre_depth=4.0,
m2_bcd=29.0,
m2_clock=(8.05, 128.07, 248.06),
m2_tap_d=1.85, # M2 pilot for self-tapping in printed material
m2_depth=6.0,
m2_clear_d=2.75,
m2_cb_d=5.2,
)
# ---------------- MEASURED: face-spline coupling (existing, printed by the user) ------------------------
C = dict(
n_teeth=12, tooth_r0=19.0, tooth_r1=24.5, tooth_s=2.5,
flange_t=4.50, part_z=5.560, # mirrored stack
hub_r=27.0,
gap=9.00, # cover-face to cover-face when closed
clamp_od_r=30.0, clamp_len=7.80,
lug_r1=37.0, lug_t=4.50,
bolt_r=32.50, bolt_d=3.80,
z0=1.66, z1=9.46, # clamp envelope in the flange frame (motor cover face = 1.060)
)
# axial stations, motor local frame:
COVER_Z = M["cover_z"] # 56.0
HUB_FACE_Z = COVER_Z + C["gap"] # 65.0 = mating face of the driven hub
CLAMP_R = C["clamp_od_r"] + 0.6 # radial keep-out around the joint axis
LUG_R = C["lug_r1"] + 1.0 # radial keep-out where the bolt lugs are
# ---------------- clearances / print rules -------------------------------------------------------------
CLR = dict(bore_slip=0.20, pilot=0.10, hole_3=3.4, hole_27=2.8, wall_min=2.0)
FIT = dict(motor_bore_d=M["hous_d"] + 2 * 0.15, cover_cb_d=M["cover_d"] + 2 * 0.35)
# 电机【轴向长度】公差(用户 2026-10-07 要求:余量偏大稍微减去 1mm,由 2mm 改为 1mm)。
# 这条链是"后端板螺钉 + 前端压盖螺钉"两个刚性夹持面,螺钉头一旦坐死在沉孔台阶上
# 就再也不能进给,所以公差没法靠"拧紧"消化,只能靠两件事吃掉(见 arm_build.Joint.pocket
# 与 motor_front_clamp):
# 长出来的 0~+1mm -> 后端板 4xM3 螺钉头下垫 0~1mm 垫片圈(螺钉被垫高 -> 电机被拉回)
# 短下去的 0~-1mm -> 电机尾端面与后端板承压面之间垫 0~1mm 垫片(承压面已按 MOTOR_TOL
# 让空,见 Joint.pocket 的 REAR_SEAT)
# 两边都不动压盖/凸台的几何,关节轴向长度不变。
MOTOR_TOL = 1.0
+3
View File
@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
"""数据注入与工程图:螺钉实例注入 mesh、四张工程图 SVG 生成。
"""
+11 -6
View File
@@ -17,9 +17,16 @@ import os
import sys
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from armcore import WEB, DOCS
D = json.load(io.open(WEB + "/mesh_arm.json", encoding="utf-8"))
from arm.paths import DATA, 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(DATA + "/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"]}
@@ -326,7 +333,5 @@ sheets = [
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" % (
print("sheets: %d (%d KB) -> docs/sheet_{front,joint,exploded,side}.svg(查看器读这 4 个文件)" % (
len(sheets), sum(len(s) for s in sheets) // 1024))
os._exit(0)
+15 -14
View File
@@ -3,7 +3,7 @@
docs/screws.json 是紧固件清单的唯一事实源(规格 / 长度 / 数量 / 位置 / 备注)。
本模块把清单里的每一颗螺钉按其真实装配孔位还原成三维实例(承载头面坐标 + 拧入方向 +
随动关节组),生成轻量三角网格后追加进 web/mesh_arm.json 的 parts 中。
随动关节组),生成轻量三角网格后追加进 webapp/data/mesh_arm.json 的 parts 中。
查看器因此可以:显示全部螺钉、点击 BOM 螺丝行高亮定位、点击螺钉反查清单。
实例坐标来源(与 arm_model.py 成孔代码一一对应,改模型后按同样规则复核):
@@ -38,9 +38,8 @@ docs/screws.json 是紧固件清单的唯一事实源(规格 / 长度 / 数量
长度处理:S08/S10 螺杆按真实长度会伸到底座底面(z=0)以下悬空(工作台/预埋螺母未建模),
这里按可见段截断到 z=0,头部与孔位仍按 BOM 规格,避免出现"悬空螺杆"。
运行:python src/screw_mesh.py -> 注入网格(纯数据,秒级,不动 CAD)
再 python src/build_viewer.py -> 重新打包单文件查看器
运行:python -m arm.dataio.screw_mesh -> 注入网格(纯数据,秒级,不动 CAD)
再 python -m arm.viewer.build_viewer -> 重新打包单文件查看器
arm_model.py 主流程末尾已自动调用本模块,重跑完整构建不会丢失螺钉。
"""
import json
@@ -52,14 +51,9 @@ try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
_HERE = os.path.dirname(os.path.abspath(__file__))
_ROOT = os.path.dirname(_HERE)
sys.path.insert(0, _HERE)
import human_layout as HL # noqa: E402
from part_tools import CLAMP_BCD, CLAMP_CLOCK # noqa: E402 压盖孔位唯一事实源
from arm.paths import DATA, DOCS
from arm import kinematics as HL
from arm.cad.part_tools import CLAMP_BCD, CLAMP_CLOCK
X_COL = -115.0 # 立柱中心(与 parts/p01_column.py 的 X_COL 一致)
Y_COL = HL.Y_AXIS # 128.0
GRP_PAR = {"J2": "J1", "J3": "J2", "J4": "J3", "J5": "J4", "J6": "J5"}
@@ -293,11 +287,11 @@ def instances(m2_bcd, m2_clock):
# ---------------------------------------------------------------- 注入
def main():
path = os.path.join(_ROOT, "web", "mesh_arm.json")
path = DATA + "/mesh_arm.json"
print("读取 %s ..." % path, flush=True)
d = json.load(open(path, encoding="utf-8"))
M = d["measured"]["M"]
specs = json.load(open(os.path.join(_ROOT, "docs", "screws.json"), encoding="utf-8"))
specs = json.load(open(DOCS + "/screws.json", encoding="utf-8"))
byid = {s["id"]: s for s in specs}
d["parts"] = [p for p in d["parts"] if not p.get("screw")] # 幂等:先清旧螺钉
@@ -344,3 +338,10 @@ def main():
if __name__ == "__main__":
main()
# OCC/cadquery 的静态析构在解释器正常退出时必定段错误(实测:光是
# `python -c "import arm.cad.helpers"` 就返回 139,与本项目代码无关)。所以入口
# 脚本跑完后显式刷缓冲再 os._exit —— 否则全流程已经成功、exit code 却是 139,
# 会把上游脚本的 set -e 判成失败。只在 __main__ 出口生效, 被 import 时不触发。
sys.stdout.flush()
os._exit(0)
+28
View File
@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
"""仓库内路径的唯一来源。
原先这些常量是硬编码的 G:/Robot_v2 绝对路径,仓库换盘符即全断;现在由本文件
位置推出。REPO 的父目录就是原来的 ROOT(=G:/Robot_v2),所以外部 STEP 输入的解析
结果与从前逐字相同。三个 ARM_* 环境变量是临时覆盖入口。
"""
import os
_HERE = os.path.dirname(os.path.abspath(__file__)).replace("\\", "/") # .../arm
REPO = os.environ.get("ARM_WORKSPACE") or os.path.dirname(_HERE)
ROOT = os.environ.get("ARM_ROOT") or os.path.dirname(REPO)
MODEL = os.environ.get("ARM_MODEL_ROOT") or (ROOT + r"/Model/Solidworks")
SRC_STEP = MODEL + r"/电机.STEP"
HALF_STEP = MODEL + r"/配件/端面齿半联轴器_相同件x2.STEP"
CLAMP_STEP = MODEL + r"/配件/端面齿联轴器_抱箍_两片装配.STEP"
OUT = REPO + r"/out"
WEB = REPO + r"/web" # 旧单文件产物目录,S8 删除;新代码不要再往里写
# 查看器运行时数据与生成脚本(gitignore,由 build_viewer 重写)
DATA = REPO + r"/webapp/data"
GEN = REPO + r"/webapp/generated"
DOCS = REPO + r"/docs"
# 可达包络 FK 采样的缓存(ws_cloud.npz / ws_metrics.json)。原先躺在 gitignore 的
# scratch/ 里,构建却依赖它——缺了新克隆就跑不出 ENV 件。
CACHE = REPO + r"/data/cache"
# 前端源码目录与分发产物
WEBAPP = REPO + r"/webapp"
DIST = REPO + r"/dist"
+3
View File
@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
"""查看器侧:数据打包、本地静态服务、headless 截图。
"""
+98
View File
@@ -0,0 +1,98 @@
# -*- coding: utf-8 -*-
"""把生成物落到 webapp/,并打一份单文件分发版。
单文件时代这里做 8 路字符串替换再写 3 份 16MB 副本;现在前端本身就是 webapp/ 下的
文件,本脚本只剩两件事:
write_data() 网格/工程图/行程/碰撞/螺钉 -> generated/00_data.js(普通脚本 const)
build_single() 把 webapp/ 的 css/js 原样内联成 dist/机器人手臂3D查看器.html(分发用)
S5 数据改 fetch() 后 write_data() 的 00_data.js 退役;S8 由 pack.py 的受限 ESM 内联器
接管 build_single()。
"""
import io
import json
import os
import re
import sys
import time
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
from arm.paths import DATA, DIST, DOCS, GEN, WEBAPP
# 与 SHEET_METAS 的顺序一致;工程图读 draw_sheet 写出的真 SVG,
# 不再维护第二份 sheets.jsdata(单文件时代的内联载体,内容与 SVG 完全重复)。
SHEET_ORDER = ["sheet_front", "sheet_joint", "sheet_exploded", "sheet_side"]
def _read(path, dflt=None):
if os.path.exists(path):
return io.open(path, encoding="utf-8").read()
if dflt is None:
raise SystemExit("缺少查看器数据文件: %s(先跑 python -m arm.build_model)" % path)
return dflt
def write_data():
t0 = time.time()
print("【数据】汇总查看器数据 -> webapp/generated/00_data.js", flush=True)
os.makedirs(GEN, exist_ok=True)
mesh = _read(DATA + "/mesh_arm.json")
svgs = [_read(DOCS + "/%s.svg" % n) for n in SHEET_ORDER]
trv = _read(DOCS + "/travel.json", "[]")
col = _read(DOCS + "/collide.json", '{"caps":[],"parts":{}}')
scr = _read(DOCS + "/screws.json", "[]")
print(" - 网格 %d KB / 工程图 %d 张 / travel %d B / collide %d KB / screws %d B"
% (len(mesh) // 1024, len(svgs), len(trv), len(col) // 1024, len(scr)), flush=True)
out = ["/* 生成物,勿手改:由 python -m arm.viewer.build_viewer 写入。",
" 普通脚本顶层 const —— 经典脚本共享全局词法作用域,后续脚本直接可见。 */"]
for name, raw in (("DATA", mesh), ("TRAVEL", trv), ("COLLIDE", col), ("SCREWS", scr)):
out.append("const %s = %s;" % (name, raw.strip()))
out.append("const SHEETS = %s;" % json.dumps(svgs, ensure_ascii=False))
text = "\n".join(out) + "\n"
tmp = GEN + "/00_data.js.tmp"
io.open(tmp, "w", encoding="utf-8").write(text)
os.replace(tmp, GEN + "/00_data.js")
print(" 完成:%d KB, %.2fs ✓" % (len(text) // 1024, time.time() - t0), flush=True)
def build_single():
"""分发用单文件:仍是普通脚本,所以内联是平凡的(S5 变 ESM 后交给 pack.py)。"""
t0 = time.time()
os.makedirs(DIST, exist_ok=True)
html = _read(WEBAPP + "/index.html")
total = len(html)
def sub(m, kind):
nonlocal total
body = _read(WEBAPP + "/" + m.group(1))
total += len(body)
return ("<style>\n" + body + "\n</style>") if kind == "css" \
else ("<script>\n" + body + "\n</script>")
out = re.sub(r'<link rel="stylesheet" href="([^"]+)">', lambda m: sub(m, "css"), html)
out = re.sub(r'<script src="([^"]+)"></script>', lambda m: sub(m, "js"), out)
left = re.findall(r'(?:src|href)="(?!data:)([^"]+)"', out)
assert not left, "单文件里仍有外部引用: %s" % left[:5]
assert len(out) >= total * 0.95, "内联后 %d 远小于各部之和 %d,疑似漏文件" % (len(out), total)
dst = DIST + "/机器人手臂3D查看器.html"
# Windows 上被浏览器打开的 html 是独占锁;写不进去就直接报,不要静默留旧版。
io.open(dst, "w", encoding="utf-8").write(out)
print(" - 单文件分发版 %.2f MB, %.2fs ✓ %s"
% (os.path.getsize(dst) / 1e6, time.time() - t0, dst), flush=True)
def rebuild_viewer():
"""build_model 流水线的最后一步(沿用旧函数名,调用点不改)。"""
write_data()
build_single()
if __name__ == "__main__":
rebuild_viewer()
+132
View File
@@ -0,0 +1,132 @@
# -*- coding: utf-8 -*-
"""查看器的本地静态服务器(前端拆分后,多文件 + ES Module + Worker 都需要 http 才能跑)。
python -m arm.viewer.serve [--port 8123] [--print-only]
要点:
- docroot = 仓库根,`/` 直接给 webapp/index.html;/docs/*.json 这类数据同域可读。
- Cache-Control: no-store —— 重新生成后旧标签页仍显示旧版是这里踩过的坑,
过去只能人肉 Ctrl+F5;不缓存之后刷新即新构建。
- 显式 MIME:Windows 上 .js 常被注册成 text/plain,那会让 <script type=module>
静默不执行(页面全黑且没有明显报错),所以这里写死。
- 屏蔽 .git / backups / 定位图 / out 等目录,不把整个仓库暴露到端口上。
"""
import argparse
import os
import sys
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from arm.paths import REPO
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
DENY = (".git", "backups", ".bak", "定位图", "out", "scratch", "node_modules")
MIME = {
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".svg": "image/svg+xml",
".css": "text/css; charset=utf-8",
".html": "text/html; charset=utf-8",
".npz": "application/octet-stream",
".png": "image/png",
".wasm": "application/wasm",
}
class H(SimpleHTTPRequestHandler):
# HTTP/1.1 + keep-alive + 大缓冲:默认 HTTP/1.0 每请求一条连接,
# 4 个 headless Edge 同时各拉 14MB 数据脚本时会把页面加载拖到截图之后。
protocol_version = "HTTP/1.1"
wbufsize = 1 << 20
# 按 (路径, mtime) 缓存字节:同一份 14MB 数据在一次批量截图里要发几十次。
_CACHE = {}
def __init__(self, *a, **k):
super().__init__(*a, directory=REPO, **k)
def _cached_bytes(self, path):
try:
st = os.stat(path)
except OSError:
return None
key = (path, st.st_mtime_ns, st.st_size)
body = H._CACHE.get(key)
if body is None:
with open(path, "rb") as f:
body = f.read()
if st.st_size <= 64 << 20:
if len(H._CACHE) > 12:
H._CACHE.clear()
H._CACHE[key] = body
return body
def send_head(self):
rel = self.path.split("?", 1)[0].split("#", 1)[0].lstrip("/").replace("\\", "/")
top = rel.split("/")[0].lower()
if top in [d.lower() for d in DENY] or ".." in rel:
self.send_error(404, "denied")
return None
fs = self.translate_path(self.path)
if os.path.isfile(fs):
body = self._cached_bytes(fs)
if body is not None:
self.send_response(200)
self.send_header("Content-Type", self.guess_type(fs))
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return None
return super().send_head()
def guess_type(self, path):
ext = os.path.splitext(path)[1].lower()
return MIME.get(ext) or super().guess_type(path)
def do_GET(self):
"""`/` 必须 302 到 /webapp/index.html,不能在原地把内容发出去:
那样文档基址是 `/`,页面里所有相对路径(styles/… js/… vendor/…)都会
解析到 /styles/base.css 之类而 404 —— 实测整页样式与脚本全丢。"""
if self.path.split("?", 1)[0].rstrip("/") in ("", "/"):
q = self.path.split("?", 1)[1] if "?" in self.path else ""
self.send_response(302)
self.send_header("Location", "/webapp/index.html" + ("?" + q if q else ""))
self.send_header("Content-Length", "0")
self.end_headers()
return
super().do_GET()
def end_headers(self):
self.send_header("Cache-Control", "no-store")
super().end_headers()
def log_message(self, fmt, *args):
sys.stderr.write("%s %s\n" % (self.address_string(), fmt % args))
def serve(port=8123):
srv = None
for p in range(port, port + 20):
try:
srv = ThreadingHTTPServer(("127.0.0.1", p), H)
port = p
break
except OSError:
continue
if srv is None:
raise SystemExit("端口 %d..%d 都被占用" % (port, port + 19))
url = "http://127.0.0.1:%d/" % port
print("docroot %s" % REPO, flush=True)
print("查看器: %s (Ctrl+C 停止;已禁用缓存,改完刷新即新构建)" % url, flush=True)
srv.serve_forever()
if __name__ == "__main__":
ap = argparse.ArgumentParser(description="查看器本地静态服务器")
ap.add_argument("--port", type=int, default=8123)
serve(ap.parse_args().port)
+157
View File
@@ -0,0 +1,157 @@
# -*- coding: utf-8 -*-
"""Headless Edge screenshots of the viewer, for the visual self-check loop."""
import os
import subprocess
import sys
import shutil
import glob
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
from arm.paths import DOCS, REPO
SHOTS = DOCS + "/shots"
# 虚拟时间预算:多文件版每页要拉 14MB 数据脚本 + 668KB three,4 并发下 20s 会不够(实测 8/10 视图两轮不一致)
# 时可上调。默认仍给 20000,用 ARM_VTB 覆盖做对照实验。
VTB = os.environ.get("ARM_VTB", "40000")
WORKERS = int(os.environ.get("ARM_SHOT_WORKERS", "2"))
# 前端拆分后是多文件 + Worker,只能走 http(file:// 下模块与 Worker 会被 CORS 拦掉)。
PORT = int(os.environ.get("ARM_PORT", "8123"))
BASE = "http://127.0.0.1:%d/webapp/index.html" % PORT
CAND = [r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe"]
EDGE = next((c for c in CAND if os.path.exists(c)), None)
if not EDGE:
EDGE = glob.glob(r"C:\Users\*\AppData\Local\Microsoft\Edge\*\msedge.exe")
EDGE = EDGE[0] if EDGE else None
if not EDGE:
raise SystemExit("msedge not found")
VIEWS = {
"01_iso": "?cam=iso",
"02_front": "?cam=front",
"03_side": "?cam=side",
"04_top": "?cam=top",
"05_exploded": "?cam=iso&expl=100",
"06_wrist": "?cam=wrist&xray=0",
"07_joint": "?cam=joint",
"08_pose": "?cam=iso&j2=35&j3=60&j4=-25&j1=25",
"09_sheet": "?sheet=1",
"10_solid": "?cam=iso&xray=0",
"11_tool_flange": "?cam=wrist&xray=0&j6=45",
"12_elbow_flare": "?cam=elbow&xray=0",
"13_forearm_side": "?cam=side&xray=0&rd=340&ty=245&tz=-135",
# 工程图 / 装配向导 / 运动模拟三种模式。URL 参数(?view=&sheet=&theme=&guide=)就是
# 为这几条截图加的:不把它们纳入基线,拆 CSS 与那 700 行工程图 JS 时等于没有关卡。
"m1_sheet_front": "?view=sheet",
"m2_sheet_joint": "?view=sheet&sheet=1",
"m3_sheet_exploded": "?view=sheet&sheet=2",
"m4_sheet_side": "?view=sheet&sheet=3",
"m5_guide_step1": "?view=sheet&sheet=guide&guide=0",
"m6_guide_step5": "?view=sheet&sheet=guide&guide=4",
"m7_sheet_blueprint": "?view=sheet&theme=blueprint",
"m8_sheet_dark": "?view=sheet&theme=dark",
"m9_motion": "?view=motion",
"harness_full_iso": "?cam=iso&xray=0",
"harness_overview": "?az=-0.7&po=1.2&rd=520&tx=0&ty=400&tz=-120&xray=0",
"harness_p04_p05": "?az=0.8&po=1.35&rd=280&tx=25&ty=470&tz=-128&xray=0",
"harness_p04_p05_xray": "?az=0.8&po=1.35&rd=280&tx=25&ty=470&tz=-128&alpha=0.35&xray=1",
"harness_p06_window": "?az=-0.65&po=1.25&rd=240&tx=25&ty=355&tz=-128&xray=0",
"harness_p07_window": "?az=-0.65&po=1.25&rd=260&tx=25&ty=275&tz=-130&xray=0",
"harness_p07_xray": "?az=-0.65&po=1.25&rd=260&tx=25&ty=275&tz=-130&alpha=0.35&xray=1",
"harness_xray_full": "?cam=iso&xray=1",
"harness_xray_internal": "?az=-0.7&po=1.2&rd=520&tx=0&ty=400&tz=-120&alpha=0.35&xray=1",
}
os.makedirs(SHOTS, exist_ok=True)
want = sys.argv[1:] or list(VIEWS)
def take_shot(nm):
q = VIEWS[nm]
out = "%s/%s.png" % (SHOTS, nm)
import time, tempfile
t_bust = int(time.time() * 1000)
# serve.py 已下发 Cache-Control: no-store,不再需要 _t= 破缓存
target = q if q.startswith("http") else BASE + q + (
"&still=1" if "?" in q else "?still=1")
udd = tempfile.mkdtemp(prefix="eshot")
cmd = [EDGE, "--headless=new", "--use-gl=angle", "--use-angle=d3d11",
"--disable-cache", "--disable-application-cache", "--disk-cache-size=0",
"--user-data-dir=" + udd,
"--enable-gpu-rasterization", "--ignore-gpu-blocklist",
"--hide-scrollbars", "--force-device-scale-factor=1",
"--window-size=1500,860", "--virtual-time-budget=" + VTB,
"--screenshot=" + out, target]
r = subprocess.run(cmd, capture_output=True, timeout=180)
shutil.rmtree(udd, ignore_errors=True)
ok = os.path.exists(out) and os.path.getsize(out) > 20000
err = (r.stderr or b"").decode("utf-8", errors="ignore").strip()[:90].replace("\n", " ")
sz = os.path.getsize(out) if os.path.exists(out) else 0
print("%-18s %s %8s %s" % (nm, "OK " if ok else "FAIL", sz, err), flush=True)
return ok
def warmup():
"""拉一次最大的那个数据脚本,成功才算服务可用。"""
import urllib.request
try:
with urllib.request.urlopen(BASE, timeout=5) as r:
r.read()
with urllib.request.urlopen("http://127.0.0.1:%d/webapp/generated/00_data.js" % PORT,
timeout=30) as r:
n = len(r.read())
return n > 1000000
except Exception:
return False
def ensure_server():
"""端口没开就自己拉一个 serve 子进程,跑完回收;不让截图依赖"你先把服务开着"。"""
import socket
import time
sk = socket.socket()
try:
sk.connect(("127.0.0.1", PORT))
sk.close()
return None
except OSError:
pass
errf = open(os.path.join(REPO, "scratch", "_serve_err.txt"), "wb")
p = subprocess.Popen([sys.executable, "-m", "arm.viewer.serve", "--port", str(PORT)],
cwd=REPO, stdout=subprocess.DEVNULL, stderr=errf)
for _ in range(60):
time.sleep(0.25)
sk = socket.socket()
try:
sk.connect(("127.0.0.1", PORT))
sk.close()
if warmup():
print("serve 已拉起 :%d(首屏数据已预热)" % PORT, flush=True)
return p
except OSError:
pass
finally:
try:
sk.close()
except Exception:
pass
p.kill()
err = open(os.path.join(REPO, "scratch", "_serve_err.txt"), "rb").read().decode("utf-8", "ignore").strip()[-400:]
raise SystemExit("serve 拉不起来:端口 %d 可能被占;子进程输出:\n%s" % (PORT, err))
if __name__ == "__main__":
from concurrent.futures import ThreadPoolExecutor
srv = ensure_server()
try:
with ThreadPoolExecutor(max_workers=WORKERS) as executor:
results = list(executor.map(take_shot, want))
finally:
if srv:
srv.terminate()
print("edge:", EDGE)
print("all shots ok:", all(results))
-42
View File
@@ -1,42 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1150" height="760" viewBox="0 0 1150 760">
<rect width="1150" height="760" fill="#15181f"/>
<rect x="400.0" y="154.0" width="396.0" height="396.0" fill="#1d4ed8" stroke="#9dc4ff" stroke-width="2.0"/>
<rect x="466.0" y="220.0" width="264.0" height="264.0" fill="#12161d" stroke="#6f86b8" stroke-width="1.4"/>
<rect x="488.8" y="484.0" width="26.4" height="66.0" fill="#243043" stroke="#ffd166" stroke-width="1.4"/>
<rect x="488.8" y="154.0" width="26.4" height="66.0" fill="#243043" stroke="#96dcff" stroke-width="1.4"/>
<rect x="484.0" y="460.0" width="36.0" height="24.0" fill="#7d8794" stroke="#c3cad6" stroke-width="1.2"/>
<rect x="484.0" y="220.0" width="36.0" height="24.0" fill="#7d8794" stroke="#c3cad6" stroke-width="1.2"/>
<rect x="488.8" y="400.0" width="26.4" height="150.0" fill="#c3cad6" stroke="#ffffff" stroke-width="1.0"/>
<rect x="488.8" y="154.0" width="26.4" height="150.0" fill="#c3cad6" stroke="#ffffff" stroke-width="1.0"/>
<rect x="481.0" y="550.0" width="42.0" height="24.0" fill="#c3cad6" stroke="#ffffff" stroke-width="1.2"/>
<rect x="481.0" y="130.0" width="42.0" height="24.0" fill="#c3cad6" stroke="#ffffff" stroke-width="1.2"/>
<rect x="716.8" y="484.0" width="26.4" height="66.0" fill="#243043" stroke="#ffd166" stroke-width="1.4"/>
<rect x="716.8" y="154.0" width="26.4" height="66.0" fill="#243043" stroke="#96dcff" stroke-width="1.4"/>
<rect x="712.0" y="460.0" width="36.0" height="24.0" fill="#7d8794" stroke="#c3cad6" stroke-width="1.2"/>
<rect x="712.0" y="220.0" width="36.0" height="24.0" fill="#7d8794" stroke="#c3cad6" stroke-width="1.2"/>
<rect x="716.8" y="400.0" width="26.4" height="150.0" fill="#c3cad6" stroke="#ffffff" stroke-width="1.0"/>
<rect x="716.8" y="154.0" width="26.4" height="150.0" fill="#c3cad6" stroke="#ffffff" stroke-width="1.0"/>
<rect x="709.0" y="550.0" width="42.0" height="24.0" fill="#c3cad6" stroke="#ffffff" stroke-width="1.2"/>
<rect x="709.0" y="130.0" width="42.0" height="24.0" fill="#c3cad6" stroke="#ffffff" stroke-width="1.2"/>
<text x="598.0" y="360.0" font-size="22" fill="#5f7396" text-anchor="middle" font-weight="700" font-family="Consolas,Microsoft YaHei,monospace">内腔 44x44</text>
<text x="598.0" y="558.0" font-size="17" fill="#ffd166" text-anchor="middle" font-weight="700" font-family="Consolas,Microsoft YaHei,monospace">前 -Y y=95</text>
<text x="598.0" y="158.0" font-size="17" fill="#96dcff" text-anchor="middle" font-weight="700" font-family="Consolas,Microsoft YaHei,monospace">后 +Y y=161</text>
<path d="M481.0,562.0 L150.0,562.0" stroke="#8fa4c8" stroke-width="1.2"/>
<text x="10.0" y="554.0" font-size="16" fill="#d9e4f7" text-anchor="start" font-weight="700" font-family="Consolas,Microsoft YaHei,monospace">M4x25 前面一颗</text>
<text x="10.0" y="574.0" font-size="13" fill="#8fa4c8" text-anchor="start" font-weight="400" font-family="Consolas,Microsoft YaHei,monospace">头 y=91..95 外露 4mm · 杆到 y=120</text>
<path d="M484.0,472.0 L150.0,472.0" stroke="#8fa4c8" stroke-width="1.2"/>
<text x="10.0" y="464.0" font-size="16" fill="#d9e4f7" text-anchor="start" font-weight="700" font-family="Consolas,Microsoft YaHei,monospace">前螺母贴前壁内侧</text>
<text x="10.0" y="484.0" font-size="13" fill="#8fa4c8" text-anchor="start" font-weight="400" font-family="Consolas,Microsoft YaHei,monospace">y=106..110 · 压紧前侧 10.5mm 搭接壁</text>
<path d="M515.2,352.0 L150.0,352.0" stroke="#8fa4c8" stroke-width="1.2"/>
<text x="10.0" y="344.0" font-size="16" fill="#96dcff" text-anchor="start" font-weight="700" font-family="Consolas,Microsoft YaHei,monospace">两端杆头净空 16.2</text>
<text x="10.0" y="364.0" font-size="13" fill="#8fa4c8" text-anchor="start" font-weight="400" font-family="Consolas,Microsoft YaHei,monospace">前 120 / 后 136 · 互不碰头</text>
<path d="M484.0,232.0 L150.0,232.0" stroke="#8fa4c8" stroke-width="1.2"/>
<text x="10.0" y="224.0" font-size="16" fill="#d9e4f7" text-anchor="start" font-weight="700" font-family="Consolas,Microsoft YaHei,monospace">后螺母贴后壁内侧</text>
<text x="10.0" y="244.0" font-size="13" fill="#8fa4c8" text-anchor="start" font-weight="400" font-family="Consolas,Microsoft YaHei,monospace">y=146..150 · 压紧后侧 10.5mm 搭接壁</text>
<path d="M481.0,142.0 L150.0,142.0" stroke="#8fa4c8" stroke-width="1.2"/>
<text x="10.0" y="134.0" font-size="16" fill="#d9e4f7" text-anchor="start" font-weight="700" font-family="Consolas,Microsoft YaHei,monospace">M4x25 背面一颗</text>
<text x="10.0" y="154.0" font-size="13" fill="#8fa4c8" text-anchor="start" font-weight="400" font-family="Consolas,Microsoft YaHei,monospace">头 y=161..165 · 杆到 y=136</text>
<text x="12.0" y="30.0" font-size="21" fill="#e8eefc" text-anchor="start" font-weight="700" font-family="Consolas,Microsoft YaHei,monospace">P01 立柱分段缝 · 两侧对锁(M4x25 前后各一颗,共 8 颗)· 俯视剖面</text>
<text x="12.0" y="52.0" font-size="14" fill="#9fb4d8" text-anchor="start" font-weight="400" font-family="Consolas,Microsoft YaHei,monospace">孔位 x=-131/-93 与 Ø4.4 通孔均未改动,与已打印的 A/B 段完全一致;偏心差 6mm 仍作防反装防呆</text>
<text x="1138.0" y="746.0" font-size="13" fill="#6f86b8" text-anchor="end" font-weight="400" font-family="Consolas,Microsoft YaHei,monospace">单位 mm · 6px/mm · 数据取自 web/mesh_arm.json 的 S05 实例</text>
</svg>

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Some files were not shown because too many files have changed in this diff Show More