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,历史里仍可取回)。
This commit is contained in:
Binary file not shown.
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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° 连续旋转(全对全动态扫描验证)"
|
||||
}
|
||||
]
|
||||
@@ -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
+11
-1
@@ -2,7 +2,7 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# 构建产物(可用 src/ 下的脚本重新生成)
|
||||
# 构建产物(可用 python -m arm.* 重新生成)
|
||||
out/
|
||||
|
||||
# 临时调试脚本与截图
|
||||
@@ -44,3 +44,13 @@ docs/shots/m5_guide_step1.png
|
||||
docs/shots/m6_guide_step5.png
|
||||
docs/shots/m7_sheet_blueprint.png
|
||||
docs/shots/m8_sheet_dark.png
|
||||
|
||||
# 其它编辑器的本地配置/会话残留(不是工程产物,也不该跟着版本走)
|
||||
.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)
|
||||
@@ -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 这一档里找原因。
|
||||
**参数化几何里,"某段料消失"几乎总是某个半径/长度取值的函数,不是布尔残留。**
|
||||
@@ -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 → 环墙+凸缘必然围住前端盖
|
||||
两次的正确解法都不是"多切一点",而是**改那个取值**;而且改动前要先问清楚
|
||||
删的是哪一圈 —— 删整圈会同时删掉压盖唯一的支撑面。
|
||||
@@ -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 是否互不相同**。
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 默认排除规则(自定义规则请写在本行下方,不会被同步/恢复改动)-----
|
||||
# 自定义规则写在下方(本行提示可删除)
|
||||
Binary file not shown.
Reference in New Issue
Block a user