Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e99b333ea | ||
|
|
79519b042c | ||
|
|
3b748c811c | ||
|
|
26eb0815c2 | ||
|
|
7a8e5aab63 | ||
|
|
bb919d33c9 | ||
|
|
ff160f576e | ||
|
|
5675159987 | ||
|
|
39c870c1cf | ||
|
|
7be41cb1ad | ||
|
|
6c2af53d18 | ||
|
|
f5f394a4bf | ||
|
|
76ac563d21 | ||
|
|
de009bd516 | ||
|
|
29f3887be1 | ||
|
|
fe48b4e777 | ||
|
|
4de5e03a0b | ||
|
|
5f2cbd5179 | ||
|
|
2ff02eb9a8 | ||
|
|
dc27781a72 | ||
|
|
8e5431c460 | ||
|
|
b06662c152 | ||
|
|
70654e45f7 | ||
|
|
fc17ae10d0 | ||
|
|
2f5a72a6ab | ||
|
|
7a194dd4a7 | ||
|
|
a41a5440c6 | ||
|
|
864b21f894 | ||
|
|
b396298819 | ||
|
|
53a50ecab9 | ||
|
|
425229f412 | ||
|
|
7e063eb3d5 | ||
|
|
406f180cab | ||
|
|
86323f1691 | ||
|
|
0f5b84b919 | ||
|
|
0a19b29fd3 | ||
|
|
1bf59e7175 | ||
|
|
cafa038110 | ||
|
|
527bc25c11 | ||
|
|
9a7404856e | ||
|
|
1f2f2a2bb1 | ||
|
|
0653cf0d8d | ||
|
|
95ba43a1c7 | ||
|
|
5365add657 | ||
|
|
573082df26 | ||
|
|
5a87c0655a | ||
|
|
0b47905a1c | ||
|
|
0d522dd782 | ||
|
|
d60cbcdb3c | ||
|
|
979a5814c1 | ||
|
|
c2732fb3cd | ||
|
|
7fe1e5e0b3 | ||
|
|
3c4bc6c8ab | ||
|
|
4a59ca84f6 | ||
|
|
faf6dd2748 | ||
|
|
b1cf4126fd | ||
|
|
e431862793 | ||
|
|
c65b9986ca | ||
|
|
db0bbbc15d | ||
|
|
21ad209747 | ||
|
|
c143b56232 | ||
|
|
83dcc1c3ee | ||
|
|
9ed213417b | ||
|
|
e6418da084 | ||
|
|
241333c213 | ||
|
|
c128288c1b | ||
|
|
b25a7a8bfa | ||
|
|
c175357b72 | ||
|
|
d03f34822c | ||
|
|
8ad6b58fca | ||
|
|
591678703a | ||
|
|
b6141d237c | ||
|
|
a1ee7d1a4d | ||
|
|
f40a40c56b | ||
|
|
3675fbbd9b | ||
|
|
5591adb39a | ||
|
|
5348bc6f6f | ||
|
|
f8cdabb781 | ||
|
|
8800a68cad | ||
|
|
2a0a2e55ad | ||
|
|
cc43b3d3be | ||
|
|
67964496be | ||
|
|
46bdb98dd8 | ||
|
|
318ad0e264 | ||
|
|
1dfdd48792 | ||
|
|
11205f81c7 | ||
|
|
8cf830493e | ||
|
|
e79cfd8e8f | ||
|
|
f713355351 | ||
|
|
531a293688 | ||
|
|
649bd69810 | ||
|
|
b32d44efe0 | ||
|
|
7e040fc766 | ||
|
|
b67d3ce56e |
@@ -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
|
||||
@@ -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° 连续旋转(全对全动态扫描验证)"
|
||||
}
|
||||
]
|
||||
@@ -1,20 +1,51 @@
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# 构建产物(可用 src/ 下的脚本重新生成)
|
||||
out/
|
||||
|
||||
# flaky/ 是那 13 张的参考像素 + thresholds.json 阈值;PNG 本身不需要入库。
|
||||
# 临时调试脚本与截图
|
||||
scratch/
|
||||
|
||||
# 关卡截图落点 = 纯生成物:每次 `python -m tests.verify --shots` 由 shot.py 重写
|
||||
# 关卡比对的权威物是 tests/baselines/(md5 名单 + flaky 参考像素),不在这里。
|
||||
# 其它编辑器的本地配置/会话残留(不是工程产物,也不该跟着版本走)
|
||||
# 单文件查看器优化前的完整备份(带版本标识,勿随手删)
|
||||
backups/
|
||||
|
||||
# 重构前的源码快照与脚本自动备份(git 历史里已有旧版本,不重复入库)
|
||||
.bak/
|
||||
*.bak_*
|
||||
|
||||
# 定位图/ 只是"给用户看的核验图落点",一次性、会反复清空;
|
||||
# 旧 web/ 单文件产物目录(已删,源码在 webapp/)
|
||||
# 构建产物(可用 python -m arm.* 重新生成)
|
||||
# 构建产物:由 python -m arm.* 重新生成,体积大(mesh 14MB + 单文件查看器 16MB×3),
|
||||
# 比对的权威物在 tests/baselines/ —— shots_stable.md5 是 92 张的 md5 名单,
|
||||
# 编辑器 / 系统文件
|
||||
Thumbs.db
|
||||
# 过去被跟踪导致仓库背着 47MB 生成物;取消跟踪后重建不再脏 git status。
|
||||
# 重构前的源码快照与脚本自动备份(git 历史里已有旧版本,不重复入库)
|
||||
# (其中有 13 张在同一份构建下也不逐字节复现,抗锯齿/量化噪声)。
|
||||
*.bak_*
|
||||
*.pyc
|
||||
.bak/
|
||||
.trae/
|
||||
.workbuddy/
|
||||
.zcode/
|
||||
.zcodeignore
|
||||
/机器人手臂3D查看器.html
|
||||
Desktop.ini
|
||||
Thumbs.db
|
||||
__pycache__/
|
||||
backups/
|
||||
data/cache/
|
||||
dist/
|
||||
docs/shots/
|
||||
ml/data/
|
||||
ml/node_modules/
|
||||
ml/runs/
|
||||
out/
|
||||
scratch/
|
||||
web/
|
||||
webapp/data/
|
||||
webapp/generated/
|
||||
定位图/
|
||||
|
||||
# 策略模型产物(由 python -m ml.export_onnx 生成,20 MB 级,不入库)
|
||||
webapp/models/*.onnx
|
||||
webapp/models/*.meta.json
|
||||
|
||||
# 从头重跑时旧产物挪到这里(不入库)
|
||||
ml/archive/
|
||||
|
||||
# onnxruntime-web 本地副本(约 33 MB):**入库**,离线/无 npm 时直接可用,不再依赖 CDN(2026-10-18 用户要求)
|
||||
# 来源 onnxruntime-web@1.20.1 dist,六个文件 + VERSION;ml/prep/vendor_ort.sh 在缺失时仍可补齐
|
||||
|
||||
@@ -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 默认排除规则(自定义规则请写在本行下方,不会被同步/恢复改动)-----
|
||||
# 自定义规则写在下方(本行提示可删除)
|
||||
@@ -0,0 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""6 自由度人形机械臂:CadQuery 参数化建模、行程/干涉扫描、制造文件与查看器数据。
|
||||
|
||||
入口一律从仓根以 `python -m arm.xxx` 运行:`python -m` 会把仓根放进 sys.path,
|
||||
包内 import 因此直接可解析,不需要 pip install。
|
||||
"""
|
||||
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""行程与干涉扫描、强度与力矩核算、可达包络采样与网格注入。
|
||||
"""
|
||||
@@ -14,8 +14,15 @@ import math
|
||||
|
||||
import os
|
||||
import sys
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from sweep_v2 import CHAIN, DOCS
|
||||
from arm.analysis.sweep_v2 import CHAIN
|
||||
from arm.paths import DOCS
|
||||
|
||||
# 这两个脚本的顶层就是"算完并写文件",被 import 时会静默重写 travel/mesh 派生产物
|
||||
# (实测:一次 importlib 冒烟测试就把 docs/travel.json 重写了一遍)。这里改成直接炸。
|
||||
if __name__ != "__main__":
|
||||
raise RuntimeError(
|
||||
"finalize_travel 是脚本:顶层代码一执行就重写产物,不能 import。"
|
||||
"要用就跑 python -m arm.analysis.finalize_travel。")
|
||||
|
||||
d = json.load(open(DOCS + "/travel_detail.json", encoding="utf-8"))
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""生成可达外包络的 ENV 零件(经纬网单件)追加进 web/mesh_arm.json:
|
||||
ENV_WL_包络经纬网 —— kind=line,LineSegments 渲染(经向 10°/条、纬向 8mm/环)
|
||||
包络 = rmax(x) 母线绕 J1 轴(平行 X,过 (y=128, z=610))整周回转;grp=root 不随关节动。"""
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
from arm.paths import DATA, CACHE
|
||||
from arm import kinematics as HL
|
||||
|
||||
Y_AX = HL.Y_AXIS # 128
|
||||
Z_AX = HL.Z_RL # 610
|
||||
|
||||
|
||||
def main():
|
||||
d = np.load(CACHE + "/ws_cloud.npz")
|
||||
xs, rmax = d["xs"], d["rmax"]
|
||||
ok = ~np.isnan(rmax)
|
||||
xs, rmax = xs[ok], rmax[ok]
|
||||
|
||||
def grid(step_x, dphi_deg):
|
||||
"""经纬参数网格 -> verts(NX*NP,3), segs(扁平索引对), tris。"""
|
||||
xs_e = np.arange(xs.min(), xs.max() + 1e-9, step_x)
|
||||
rm_e = np.interp(xs_e, xs, rmax)
|
||||
nph = int(round(360.0 / dphi_deg))
|
||||
phg = np.linspace(0.0, 2.0 * math.pi, nph, endpoint=False)
|
||||
nx = len(xs_e)
|
||||
V = np.empty((nx * nph, 3))
|
||||
for i, x in enumerate(xs_e):
|
||||
r = rm_e[i]
|
||||
for j, ph in enumerate(phg):
|
||||
V[i * nph + j] = (x, Y_AX + r * math.cos(ph), Z_AX + r * math.sin(ph))
|
||||
segs = []
|
||||
for i in range(nx - 1): # 经线(沿 x)
|
||||
for j in range(nph):
|
||||
segs += [i * nph + j, (i + 1) * nph + j]
|
||||
for i in range(nx): # 纬线(沿 φ)
|
||||
for j in range(nph):
|
||||
j2 = (j + 1) % nph
|
||||
segs += [i * nph + j, i * nph + j2]
|
||||
tris = []
|
||||
for i in range(nx - 1):
|
||||
for j in range(nph):
|
||||
j2 = (j + 1) % nph
|
||||
a, b = i * nph + j, i * nph + j2
|
||||
c, e = (i + 1) * nph + j, (i + 1) * nph + j2
|
||||
tris += [a, c, b, b, c, e]
|
||||
return V, segs, tris
|
||||
|
||||
# 经纬网单件:经向 10°一条、纬向 8mm一环(无需贴片面,线自带罩体感)
|
||||
Vl, sl, _tl = grid(8.0, 10.0)
|
||||
|
||||
# 自证:包络尺寸应等于 ws_metrics 极值。
|
||||
# 【2026-10-05c 修正】原来是 <1.0 的硬比较,但网格 x 是从 xs.min() 起按 step_x
|
||||
# 对齐的(np.arange),最后一个采样点最多比真极值短一个步长 —— 一旦
|
||||
# (x_fwd - xs.min()) 不是 step_x 的整数倍,断言就会假失败(实测差 6.46mm,
|
||||
# 正好 < 8.0),导致包络注不进去、查看器里"可达包络"整个消失。改为
|
||||
# "不得超出、且至多少一个步长"这个真正成立的判据。
|
||||
step_x = 8.0
|
||||
met = json.load(open(CACHE + "/ws_metrics.json", encoding="utf-8"))
|
||||
assert Vl[:, 0].min() >= met["x_back"] - 1e-6, (Vl[:, 0].min(), met["x_back"])
|
||||
assert Vl[:, 0].min() <= met["x_back"] + step_x + 1e-6, (Vl[:, 0].min(), met["x_back"])
|
||||
assert Vl[:, 0].max() <= met["x_fwd"] + 1e-6, (Vl[:, 0].max(), met["x_fwd"])
|
||||
assert Vl[:, 0].max() >= met["x_fwd"] - step_x - 1e-6, (Vl[:, 0].max(), met["x_fwd"])
|
||||
rl = np.hypot(Vl[:, 1] - Y_AX, Vl[:, 2] - Z_AX)
|
||||
# rl.max() 就是 rmax(x) 网格插值后的峰值,与 met["r_max"](原始点云极值)之间
|
||||
# 只允许有插值/取整量级的差(实测 0.044mm),故给 1.0mm 容差。
|
||||
assert abs(rl.max() - met["r_max"]) < 1.0, (rl.max(), met["r_max"])
|
||||
assert np.all((Vl[:, 1] - Y_AX) ** 2 + (Vl[:, 2] - Z_AX) ** 2
|
||||
<= (np.interp(Vl[:, 0], xs, rmax) + 1e-6) ** 2), "line"
|
||||
print("包络经纬网: 顶点%d 线段%d(经10° x 纬8mm)" % (len(Vl), len(sl) // 2))
|
||||
|
||||
# ---- 追加进 mesh_arm.json ----
|
||||
path = DATA + "/mesh_arm.json"
|
||||
data = json.load(open(path, encoding="utf-8"))
|
||||
data["parts"] = [p for p in data["parts"] if not p["name"].startswith("ENV_")]
|
||||
mx = max(p["idx"] for p in data["parts"])
|
||||
note_ws = ("可达外包络(运动学,docs/travel.json 滑块限位下的全组合 FK 包络):"
|
||||
"r=rmax(x) 绕 J1 轴整周回转。高 %.0f~%.0f mm,前伸 %.0f / 后伸 %.0f mm,"
|
||||
"离 J1 轴最远 %.0f mm;包络内含死区(肩下折不进),多关节同极位由控制器避碰。"
|
||||
% (met["z_min"], met["z_max"], met["x_fwd"], met["x_back"], met["r_max"]))
|
||||
line = dict(name="ENV_WL_包络经纬网", cn="包络经纬网", v=[list(p) for p in Vl],
|
||||
s=sl, color=0x60a5fa, printed=False, vol=round(met["vol_dm3"] * 1e6, 1),
|
||||
note=note_ws + " 经纬线:经向 10°/条,纬向 8mm/环。",
|
||||
grp="root", jaw=0.0, ex=[0.0, 0.0, 0.0], idx=mx + 1,
|
||||
kind="line")
|
||||
data["parts"] += [line]
|
||||
tmp = path + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, separators=(",", ":"))
|
||||
os.replace(tmp, path)
|
||||
print("已写入", path, "大小 %.1f MB" % (os.path.getsize(path) / 1e6))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -19,22 +19,19 @@ import math
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
import cadquery as cq
|
||||
from armcore import OUT, DOCS, solids_of, bb, mesh_of
|
||||
from arm.paths import OUT, DATA, DOCS
|
||||
from arm.cad.helpers import solids_of, bb, mesh_of
|
||||
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common
|
||||
from OCP.BRepGProp import BRepGProp
|
||||
from OCP.GProp import GProp_GProps
|
||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||
from OCP.gp import gp_Pnt, gp_Dir, gp_Ax2, gp_Vec
|
||||
|
||||
import human_layout as HL
|
||||
|
||||
from arm import kinematics as HL
|
||||
RHO = {"PLA": 1.24e-3, "motor_g": 210.0} # g/mm3 for printed, g per module
|
||||
# a slicer does not print solid: 3 perimeters + 30% infill lands near this fraction of
|
||||
# the CAD envelope, and using the solid mass would blame the part for plastic that is not there
|
||||
@@ -159,7 +156,7 @@ def section_props(sh, normal, origin, bend=2, size=400.0):
|
||||
|
||||
def arm_masses():
|
||||
"""Everything distal to each joint, in grams, from the model itself."""
|
||||
meta = json.load(io.open(OUT.replace("/out", "/web") + "/mesh_arm.json", encoding="utf-8"))
|
||||
meta = json.load(io.open(DATA + "/mesh_arm.json", encoding="utf-8"))
|
||||
TREE = ["root", "J1", "J2", "J3", "J4", "J5", "J6"]
|
||||
tot = {}
|
||||
for p in meta["parts"]:
|
||||
@@ -202,7 +199,7 @@ def _mesh_vol_cg(v, t):
|
||||
|
||||
def part_table(payload_kg=None):
|
||||
"""[(name, mass_g, cg_xyz, grp)],含末端负载这个集中质量点。"""
|
||||
meta = json.load(io.open(OUT.replace("/out", "/web") + "/mesh_arm.json", encoding="utf-8"))
|
||||
meta = json.load(io.open(DATA + "/mesh_arm.json", encoding="utf-8"))
|
||||
rows = []
|
||||
for p in meta["parts"]:
|
||||
nm = p["name"]
|
||||
@@ -488,5 +485,10 @@ def main():
|
||||
if __name__ == "__main__":
|
||||
b = main()
|
||||
print("\n不合格截面 %d 处" % len(b))
|
||||
sys.stdout.flush() # os._exit 不刷缓冲,重定向到文件时否则输出全丢
|
||||
|
||||
# OCC/cadquery 的静态析构在解释器正常退出时必定段错误(实测:光是
|
||||
# `python -c "import arm.cad.helpers"` 就返回 139,与本项目代码无关)。所以入口
|
||||
# 脚本跑完后显式刷缓冲再 os._exit —— 否则全流程已经成功、exit code 却是 139,
|
||||
# 会把上游脚本的 set -e 判成失败。只在 __main__ 出口生效, 被 import 时不触发。
|
||||
sys.stdout.flush()
|
||||
os._exit(0)
|
||||
@@ -27,8 +27,7 @@
|
||||
就是臂撞躯干,是真约束。--no-stand 仅用于诊断:排除台架后重扫,给出"不含躯干约束"的
|
||||
自由空间行程,用来分辨某个限位是臂自身折叠产生的还是躯干挡的(台架位置本身是假设,
|
||||
改它等于改测试工装,不等于改设计)。
|
||||
|
||||
运行:python src/sweep_v2.py [--workers 20] [--step 2] [--combo-step 6]
|
||||
运行:python -m arm.analysis.sweep_v2 [--workers 20] [--step 2] [--combo-step 6]
|
||||
[--no-combos] [--joints J2,J4] [--no-gjk] [--no-stand]
|
||||
"""
|
||||
import json
|
||||
@@ -38,20 +37,17 @@ import sys
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
import multiprocessing as mp
|
||||
|
||||
import arm_build as AB
|
||||
from arm.cad import arm_build as AB
|
||||
import cadquery as cq
|
||||
import human_layout as HL
|
||||
from armcore import place, bb, DOCS
|
||||
|
||||
from arm import kinematics as HL
|
||||
from arm.paths import DOCS
|
||||
from arm.cad.helpers import place, bb
|
||||
VOL_MIN = 2.0 # mm3,超过即计为干涉
|
||||
CHAIN = ["root", "J1", "J2", "J3", "J4", "J5", "J6"]
|
||||
MOUNT_PAIRS = {
|
||||
@@ -175,7 +171,7 @@ def _do_simplex(s):
|
||||
def load_model(include_fixture=True):
|
||||
"""include_fixture=False 时把 P01 立柱 / P02 底盘(固定测试台架,非本体)排除在
|
||||
碰撞配对之外——用来得到真机工作空间而不是台架工作空间。见 human_layout.FIXTURE_PREFIX。"""
|
||||
import arm_model as AM
|
||||
from arm import build_model as AM
|
||||
JS = AM.build()
|
||||
parts = []
|
||||
for p in AB.PARTS:
|
||||
@@ -721,3 +717,10 @@ def main():
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
# OCC/cadquery 的静态析构在解释器正常退出时必定段错误(实测:光是
|
||||
# `python -c "import arm.cad.helpers"` 就返回 139,与本项目代码无关)。所以入口
|
||||
# 脚本跑完后显式刷缓冲再 os._exit —— 否则全流程已经成功、exit code 却是 139,
|
||||
# 会把上游脚本的 set -e 判成失败。只在 __main__ 出口生效, 被 import 时不触发。
|
||||
sys.stdout.flush()
|
||||
os._exit(0)
|
||||
@@ -29,17 +29,13 @@ import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
import human_layout as HL
|
||||
from armcore import DOCS
|
||||
import strength_check as SC
|
||||
|
||||
from arm import kinematics as HL
|
||||
from arm.paths import DOCS
|
||||
from arm.analysis import strength_check as SC
|
||||
G = 9.81
|
||||
T_CONT = float(os.environ.get("ARMT_CONT", "2.5"))
|
||||
T_PEAK = float(os.environ.get("ARMT_PEAK", "8.0"))
|
||||
@@ -206,9 +202,12 @@ def main():
|
||||
w = min(ok, key=lambda t: mp[t])
|
||||
print(" 整臂瓶颈 %s -> %.2f kg" % (w, mp[w]))
|
||||
print("\n已写出 docs/torque_balance.json + docs/torque_report.md")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
# OCC/cadquery 的静态析构在解释器正常退出时必定段错误(实测:光是
|
||||
# `python -c "import arm.cad.helpers"` 就返回 139,与本项目代码无关)。所以入口
|
||||
# 脚本跑完后显式刷缓冲再 os._exit —— 否则全流程已经成功、exit code 却是 139,
|
||||
# 会把上游脚本的 set -e 判成失败。只在 __main__ 出口生效, 被 import 时不触发。
|
||||
sys.stdout.flush()
|
||||
os._exit(0)
|
||||
@@ -0,0 +1,143 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""关节角组合可达空间(工具尖点)计算:FK 采样 + J1 旋转对称包络 + 体积估计"""
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
from arm import kinematics as HL
|
||||
from arm.paths import DOCS, CACHE
|
||||
|
||||
TRAVEL = {t["axis"]: (t["lo"], t["hi"]) for t in
|
||||
json.load(open(DOCS + "/travel.json", encoding="utf-8"))}
|
||||
|
||||
TIP = (HL.X_ARM, HL.Y_AXIS, HL.Z_TIP) # P09 法兰底面中心(工具安装面)
|
||||
J1_AX = (HL.Y_AXIS, HL.Z_RL) # J1 转轴: 过 Y=128, Z=610, 平行 X
|
||||
|
||||
|
||||
def rot_about(p, k, ang):
|
||||
"""绕轴 (p 点, k 方向) 旋转 ang(弧度, 可为数组M,);返回 Rm(M,3,3), t(M,3)。
|
||||
p' = Rm @ (p_pt - p) + p,与 AB.R 的 gp_Trsf.SetRotation 右手定则一致。"""
|
||||
k = np.asarray(k, dtype=float)
|
||||
k = k / np.linalg.norm(k)
|
||||
ang = np.atleast_1d(np.asarray(ang, dtype=float))
|
||||
px, py, pz = p
|
||||
K = np.array([[0.0, -k[2], k[1]], [k[2], 0.0, -k[0]], [-k[1], k[0], 0.0]])
|
||||
K2 = K @ K
|
||||
Rm = np.eye(3)[None, :, :] + np.sin(ang)[:, None, None] * K[None, :, :] \
|
||||
+ (1.0 - np.cos(ang))[:, None, None] * K2[None, :, :]
|
||||
pp = np.array([px, py, pz])
|
||||
t = pp[None, :] - np.einsum("mij,j->mi", Rm, pp)
|
||||
return Rm, t
|
||||
|
||||
|
||||
def fk_batch(angles, tip=TIP):
|
||||
"""angles: dict idx->弧度数组(M,);返回 (M,3) 世界坐标。链式 acc 与 sweep_v2 一致:
|
||||
父关节变换在最外层 p' = R1∘R2∘…∘R5 (p),各 R_i 绕其零位世界轴(human_layout 唯一事实源)。"""
|
||||
pts = np.tile(np.asarray(tip, dtype=float), (angles[1].shape[0], 1))
|
||||
for i in range(5, 0, -1): # J6 跳过:尖点在 J6 轴上,其转角不改变尖点位置
|
||||
rear, d = HL.JOINTS[i - 1][2], HL.JOINTS[i - 1][3]
|
||||
Rm, t = rot_about(rear, d, angles[i])
|
||||
pts = np.einsum("mij,mj->mi", Rm, pts) + t
|
||||
return pts
|
||||
|
||||
|
||||
def sweep(step=2.0):
|
||||
j2 = np.radians(np.arange(TRAVEL["J2"][0], TRAVEL["J2"][1] + 1e-9, step))
|
||||
j4 = np.radians(np.arange(TRAVEL["J4"][0], TRAVEL["J4"][1] + 1e-9, step))
|
||||
j5 = np.radians(np.arange(TRAVEL["J5"][0], TRAVEL["J5"][1] + 1e-9, step))
|
||||
g2, g4, g5 = np.meshgrid(j2, j4, j5, indexing="ij")
|
||||
M = g2.size
|
||||
angles = {1: np.zeros(M), 2: g2.ravel(), 3: np.zeros(M),
|
||||
4: g4.ravel(), 5: g5.ravel()}
|
||||
pts = fk_batch(angles)
|
||||
# 自证1:零位尖点必须回到 (18.5,128,93)
|
||||
z0 = fk_batch({i: np.zeros(1) for i in range(1, 6)})
|
||||
assert np.allclose(z0[0], TIP, atol=1e-9), z0[0]
|
||||
# 自证2:J1/J6 旋转不改变 (x, r) 与极值位置——J1 取中值扫一遍,r 分布应与 J1=0 相同
|
||||
for phi in (0.5, 1.2, 2.4):
|
||||
v1 = np.full(M, phi)
|
||||
p1 = fk_batch({1: v1, 2: g2.ravel(), 3: np.zeros(M),
|
||||
4: g4.ravel(), 5: g5.ravel()})
|
||||
r1 = np.hypot(p1[:, 1] - J1_AX[0], p1[:, 2] - J1_AX[1])
|
||||
r0 = np.hypot(pts[:, 1] - J1_AX[0], pts[:, 2] - J1_AX[1])
|
||||
assert np.allclose(np.sort(r0), np.sort(r1), atol=1e-9), "J1 对称性被破坏"
|
||||
# 自证3:J3 旋转(大臂自转)不改变尖点位置——关节轴过尖点所在纵线
|
||||
print("J3 对称性: 尖点 x=%.1f 与 J3 轴 x=%.1f 一致=%s, y=%.1f 与轴 y=%.1f 一致=%s"
|
||||
% (TIP[0], HL.X_ARM, abs(TIP[0] - HL.X_ARM) < 1e-9,
|
||||
TIP[1], HL.Y_AXIS, abs(TIP[1] - HL.Y_AXIS) < 1e-9))
|
||||
print("J6 含于轴: 尖点 (%.1f,%.1f) vs 轴 (%.1f,%.1f)"
|
||||
% (TIP[0], TIP[1], HL.JOINTS[5][2][0], HL.JOINTS[5][2][1]))
|
||||
return pts
|
||||
|
||||
|
||||
def envelope(pts, grid=1.0):
|
||||
"""(x, r) 半平面栅格化占用 + 体积(绕 J1 轴回转)。"""
|
||||
x = pts[:, 0]
|
||||
r = np.hypot(pts[:, 1] - J1_AX[0], pts[:, 2] - J1_AX[1])
|
||||
x0, x1 = math.floor(x.min()), math.ceil(x.max())
|
||||
r0, r1 = 0.0, math.ceil(r.max())
|
||||
nx = int(round((x1 - x0) / grid)) + 1
|
||||
nr = int(round((r1 - r0) / grid)) + 1
|
||||
xi = np.clip(((x - x0) / grid).round().astype(int), 0, nx - 1)
|
||||
ri = np.clip((r / grid).round().astype(int), 0, nr - 1)
|
||||
occ = np.zeros((nx, nr), dtype=bool)
|
||||
occ[xi, ri] = True
|
||||
# 每个 x 列只保留 r <= 该列可达最大 r(回转体沿 r 连续填充)
|
||||
for i in range(nx):
|
||||
j = np.nonzero(occ[i])[0]
|
||||
if j.size:
|
||||
occ[i, : j.max() + 1] = True
|
||||
rr = r0 + np.arange(nr) * grid
|
||||
vol = float((occ * (2.0 * math.pi * rr)[None, :] * grid * grid).sum())
|
||||
return dict(occ=occ, x0=x0, grid=grid, rm_axes=rr, vol_mm3=vol,
|
||||
nx=nx, nr=nr)
|
||||
|
||||
|
||||
def main():
|
||||
pts = sweep()
|
||||
x, y, z = pts[:, 0], pts[:, 1], pts[:, 2]
|
||||
r = np.hypot(y - J1_AX[0], z - J1_AX[1])
|
||||
env = envelope(pts)
|
||||
# 每个 x 的 r 包络(侧视/俯视剖面用)
|
||||
xs = env["x0"] + np.arange(env["nx"]) * env["grid"]
|
||||
rmax_x, rmin_x = [], []
|
||||
for i in range(env["nx"]):
|
||||
j = np.nonzero(env["occ"][i])[0]
|
||||
if j.size:
|
||||
rmax_x.append(env["rm_axes"][j.max()])
|
||||
rmin_x.append(env["rm_axes"][j.min()])
|
||||
else:
|
||||
rmax_x.append(np.nan)
|
||||
rmin_x.append(np.nan)
|
||||
os.makedirs(CACHE, exist_ok=True)
|
||||
np.savez_compressed(CACHE + "/ws_cloud.npz",
|
||||
pts=pts, xs=np.array(xs),
|
||||
rmax=np.array(rmax_x), rmin=np.array(rmin_x))
|
||||
met = dict(
|
||||
z_max=float(z.max()), z_min=float(z.min()),
|
||||
x_fwd=float(x.max()), x_back=float(x.min()),
|
||||
y_lo=float(y.min()), y_hi=float(y.max()),
|
||||
r_max=float(r.max()), r_min=float(r.min()),
|
||||
reach_from_j1_axis=float(r.max()),
|
||||
spread=float(z.max() - z.min()),
|
||||
vol_dm3=env["vol_mm3"] / 1e6,
|
||||
tip=TIP, j1_axis=list(J1_AX),
|
||||
limits={k: list(v) for k, v in TRAVEL.items()},
|
||||
)
|
||||
# 自证4:极值点姿态可解释(打印取极值的角度组合索引)
|
||||
imax = int(np.argmax(z)); imin = int(np.argmin(z))
|
||||
ifwd = int(np.argmax(x)); iback = int(np.argmin(x))
|
||||
met["pose_extreme"] = dict(
|
||||
zmax_idx=imax, zmin_idx=imin, xfwd_idx=ifwd, xback_idx=iback)
|
||||
print(json.dumps({k: v for k, v in met.items()
|
||||
if k not in ("limits", "pose_extreme")},
|
||||
ensure_ascii=False, indent=1))
|
||||
json.dump(met, open(CACHE + "/ws_metrics.json", "w", encoding="utf-8"),
|
||||
ensure_ascii=False, indent=1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -10,17 +10,17 @@ import math
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import cadquery as cq
|
||||
import arm_build as AB
|
||||
from armcore import (M, C, cyl, box, ring, place, trs, bb, bbox_str, solids_of, OUT, WEB, DOCS)
|
||||
from arm_build import (Joint, add, bx, Ycyl, Ybox, Yplate, prism, dogbone, sector,
|
||||
from arm.cad import arm_build as AB
|
||||
from arm.paths import OUT, WEB, DOCS
|
||||
from arm.constants import M, C
|
||||
from arm.cad.helpers import cyl, box, ring, place, trs, bb, bbox_str, solids_of
|
||||
from arm.cad.arm_build import (Joint, add, bx, Ycyl, Ybox, Yplate, prism, dogbone, sector,
|
||||
FLANGE_Z, COVER_Z, HUB_FACE_Z, CLAMP_SPAN, HUB_M2_CLOCK,
|
||||
BORE_R, CB_R, PCB_W, D2R, T, R, PARTS)
|
||||
import human_layout as HL
|
||||
from arm import kinematics as HL
|
||||
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common
|
||||
from human_layout import (HUB, PLATE_T, R_MOT, Z_TIP, Z_WR, Z_EI, Z_SH, Z_RL, Z_R1,
|
||||
from arm.kinematics import (HUB, PLATE_T, R_MOT, Z_TIP, Z_WR, Z_EI, Z_SH, Z_RL, Z_R1,
|
||||
Z_R3, Z_HUB3, Z_EL, Z_R6, Z_HUB6,
|
||||
Z_R4, Z_CAP, Z_M6, Z_CHEST0, Z_CHEST1, X_J1_HUB, X_ARM, L1,
|
||||
X_FORK_GAP, X_BAY_EXT,
|
||||
@@ -33,24 +33,23 @@ AB.VERSION = VERSION
|
||||
AB.ITER = ITER
|
||||
|
||||
# ------------------------------------------------ 逐件模块(几何已拆出)
|
||||
from parts import PART_MODULES
|
||||
from part_tools import (
|
||||
from arm.cad.parts import PART_MODULES
|
||||
from arm.cad.part_tools import (
|
||||
BOSS, CHAMFER_EDGE, CLAMP_BACKOFF, CLAMP_BCD, CLAMP_CLOCK, CLAMP_CLOCK_LOCAL, CLAMP_CLR,
|
||||
CLAMP_ROOT, CLAMP_T, CLEARANCE_GAP, DISC_R, FENCE_OPEN_CUT, FILLET_LARGE, FILLET_MEDIUM,
|
||||
FILLET_SMALL, FILLET_TINY, FILLET_XLARGE, HOLLOW_FILLET, HOLLOW_WALL, SLEEVE_R, XB, Xcyl,
|
||||
Xplate, Y_MID, cut_motor_fence, hollow_channel, hollow_tent_window, hub_m2_holes,
|
||||
make_direct_hub, make_pitch_to_roll_bracket, motor_front_clamp,
|
||||
)
|
||||
from parts.p01_column import X_COL, Y_COL, Z_LOCK, p_chest, split_p01_poka_yoke
|
||||
from parts.p02_base import make_p02_poka_yoke, p_base_foot
|
||||
from parts.p03_girdle import p_girdle
|
||||
from parts.p04_fork import P04_CH, p_fork
|
||||
from parts.p05_roll_base import p_shoulder_roll_base
|
||||
from parts.p06_humerus import P06_HOLLOW_WIN, P06_WIN_XZ, p_humerus_bone
|
||||
from parts.p07_forearm import P07_CH, p_forearm_bone
|
||||
from parts.p08_wrist import p_wrist_bracket
|
||||
from parts.p09_flange import p_tool_flange
|
||||
|
||||
from arm.cad.parts.p01_column import X_COL, Y_COL, Z_LOCK, p_chest, split_p01_poka_yoke
|
||||
from arm.cad.parts.p02_base import make_p02_poka_yoke, p_base_foot
|
||||
from arm.cad.parts.p03_girdle import p_girdle
|
||||
from arm.cad.parts.p04_fork import P04_CH, p_fork
|
||||
from arm.cad.parts.p05_roll_base import p_shoulder_roll_base
|
||||
from arm.cad.parts.p06_humerus import P06_HOLLOW_WIN, P06_WIN_XZ, p_humerus_bone
|
||||
from arm.cad.parts.p07_forearm import P07_CH, p_forearm_bone
|
||||
from arm.cad.parts.p08_wrist import p_wrist_bracket
|
||||
from arm.cad.parts.p09_flange import p_tool_flange
|
||||
# 兼容旧工具:scratch/ 的诊断脚本按 arm_model.<符号> 取用件构造函数与开关
|
||||
|
||||
def build():
|
||||
@@ -80,9 +79,7 @@ def build():
|
||||
for t in ("J1", "J2", "J3", "J4", "J5", "J6"):
|
||||
AB.GRP[motor_of[t]] = par[t]
|
||||
AB.JAW = {}
|
||||
|
||||
|
||||
# 逐件模块:几何、件号/配色/说明、装配帧归属都在 src/parts/ 里,
|
||||
# 逐件模块:几何、件号/配色/说明、装配帧归属都在 arm/cad/parts/ 里,
|
||||
# 这里只按 BOM 顺序收集 specs(),并把件号登记到装配帧图上。
|
||||
parts_to_build = []
|
||||
for _m in PART_MODULES:
|
||||
@@ -177,39 +174,26 @@ if __name__ == "__main__":
|
||||
|
||||
# 紧固件(螺钉)实例注入:把 docs/screws.json 的 72 颗螺钉按真实孔位还原成三维实例,
|
||||
# 追加进 web/mesh_arm.json 的 parts(查看器可点 BOM 螺丝行高亮定位;纯数据,不动 CAD)
|
||||
try:
|
||||
import screw_mesh
|
||||
screw_mesh.main()
|
||||
except Exception as e:
|
||||
print("螺钉实例注入失败(不影响主流程,可手动跑 src/screw_mesh.py):", e, flush=True)
|
||||
|
||||
from arm.dataio import screw_mesh
|
||||
screw_mesh.main()
|
||||
cl = AB.collide()
|
||||
|
||||
try:
|
||||
from build_viewer import rebuild_viewer
|
||||
rebuild_viewer()
|
||||
except Exception as e:
|
||||
print("rebuild_viewer error:", e, flush=True)
|
||||
|
||||
from arm.viewer.build_viewer import rebuild_viewer
|
||||
rebuild_viewer()
|
||||
# 可达包络自动注入:AB.export 刚重写过 mesh_arm.json(包络件会被冲掉),
|
||||
# 这里重跑 FK 采样 + 包络网格注入,再重打包一次查看器(约 3~4s,纯数据无 CAD 计算)
|
||||
try:
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
"..", "scratch"))
|
||||
import ws_sweep
|
||||
import gen_env_mesh
|
||||
t_env = time.time()
|
||||
ws_sweep.main()
|
||||
gen_env_mesh.main()
|
||||
from build_viewer import rebuild_viewer as _rbv
|
||||
_rbv()
|
||||
print(f"-> 可达包络已自动注入查看器(+{time.time()-t_env:.1f}s)✓\n", flush=True)
|
||||
except Exception as e:
|
||||
print("可达包络自动注入失败(不影响主流程,修复后手动跑 scratch/gen_env_mesh.py + src/build_viewer.py):",
|
||||
e, flush=True)
|
||||
|
||||
from arm.analysis import ws_sweep, gen_env_mesh
|
||||
t_env = time.time()
|
||||
ws_sweep.main()
|
||||
gen_env_mesh.main()
|
||||
rebuild_viewer()
|
||||
print(f"-> 可达包络已自动注入查看器(+{time.time() - t_env:.1f}s)✓\n", flush=True)
|
||||
print("=" * 80, flush=True)
|
||||
print("✅ 机器人手臂编译、导出与查看器发布全流程完成!", flush=True)
|
||||
print("=" * 80, flush=True)
|
||||
|
||||
# OCC/cadquery 的静态析构在解释器正常退出时必定段错误(实测:光是
|
||||
# `python -c "import arm.cad.helpers"` 就返回 139,与本项目代码无关)。所以入口
|
||||
# 脚本跑完后显式刷缓冲再 os._exit —— 否则全流程已经成功、exit code 却是 139,
|
||||
# 会把上游脚本的 set -e 判成失败。只在 __main__ 出口生效, 被 import 时不触发。
|
||||
sys.stdout.flush()
|
||||
os._exit(0)
|
||||
@@ -0,0 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""几何与装配层。
|
||||
|
||||
helpers 放置/基础体/网格化等无状态 helper
|
||||
arm_build Joint 装配模型、零件登记(add)、STEP/STL/BOM/mesh 导出
|
||||
part_tools 跨件共用刀法(减重腔、压盖、转接座、圆角常量、螺钉孔位事实源)
|
||||
parts/ 每个打印件一个模块:几何 + 件号/配色/说明 + 装配帧归属
|
||||
"""
|
||||
@@ -9,7 +9,7 @@ bore + PCBA pocket + pinch screws; driven hub mating plane at motor rear datum
|
||||
the existing face-spline halves + split clamp bolt straight on), the part
|
||||
registry, and the STEP/STL/mesh/BOM/collision export pipeline.
|
||||
|
||||
Run: python arm_model.py -> out/, web/mesh_arm.json, docs/
|
||||
Run: python -m arm.build_model -> out/, webapp/data/mesh_arm.json, docs/
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
@@ -21,10 +21,10 @@ try:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import cadquery as cq
|
||||
from armcore import (M, C, V, trs, place, bb, bbox_str, cyl, ring, box, holes,
|
||||
solids_of, mesh_of, MOTOR_TOL, OUT, WEB, DOCS, SRC_STEP, HALF_STEP, CLAMP_STEP)
|
||||
from arm.paths import OUT, WEB, DATA, DOCS, SRC_STEP, HALF_STEP, CLAMP_STEP
|
||||
from arm.constants import M, C, MOTOR_TOL
|
||||
from arm.cad.helpers import V, trs, place, bb, bbox_str, cyl, ring, box, holes, solids_of, mesh_of
|
||||
from OCP.gp import gp_Trsf, gp_Vec, gp_Pnt, gp_Dir, gp_Ax1
|
||||
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common
|
||||
from OCP.GProp import GProp_GProps
|
||||
@@ -460,7 +460,7 @@ import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
def export(JS):
|
||||
for d in (OUT, OUT + "/parts_step", OUT + "/print_stl", OUT + "/assembly", WEB, DOCS):
|
||||
for d in (OUT, OUT + "/parts_step", OUT + "/print_stl", OUT + "/assembly", DATA, DOCS):
|
||||
os.makedirs(d, exist_ok=True)
|
||||
|
||||
print("=" * 80, flush=True)
|
||||
@@ -499,7 +499,7 @@ def export(JS):
|
||||
|
||||
# 2. 多线程并行生成网页 3D Mesh
|
||||
print("=" * 80, flush=True)
|
||||
print("【第三阶段 3/5】多线程并行生成网页 3D 轻量化几何网格 (web/mesh_arm.json)...", flush=True)
|
||||
print("【第三阶段 3/5】多线程并行生成网页 3D 轻量化几何网格 (webapp/data/mesh_arm.json)...", flush=True)
|
||||
print("=" * 80, flush=True)
|
||||
|
||||
done_mesh = 0
|
||||
@@ -541,12 +541,12 @@ def export(JS):
|
||||
L1=round(d3(JS["J2"].rear, JS["J4"].rear), 1),
|
||||
L2=round(d3(JS["J4"].rear, JS["J5"].rear), 1)),
|
||||
measured=dict(M=M, C=C, HUB_FACE_Z=HUB_FACE_Z, CLAMP_SPAN=list(CLAMP_SPAN))),
|
||||
open(WEB + "/mesh_arm.json", "w", encoding="utf-8"), ensure_ascii=False)
|
||||
open(DATA + "/mesh_arm.json", "w", encoding="utf-8"), ensure_ascii=False)
|
||||
bom = [dict(件号=p["name"], 名称=p["cn"], 类型="3D打印" if p["printed"] else "已有/电机",
|
||||
数量=1, 体积cm3=round(p["vol"] / 1000.0, 1),
|
||||
质量g_PLA=round(p["vol"] * 1.24e-3, 1), 包络=p["bb"], 说明=p["note"]) for p in PARTS]
|
||||
json.dump(bom, open(DOCS + "/bom.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1)
|
||||
print(f"-> 3D网格与BOM清单已保存至 web/mesh_arm.json 与 docs/bom.json ✓\n", flush=True)
|
||||
print(f"-> 3D网格与BOM清单已保存至 webapp/data/mesh_arm.json 与 docs/bom.json ✓\n", flush=True)
|
||||
return bom
|
||||
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Shared constants + CAD/placement helpers for the robot arm build.
|
||||
"""CAD 放置与几何 helper:刚体变换、求交/包围盒、基础体、网格化与 STEP 读取。
|
||||
|
||||
All motor interface numbers are MEASURED from
|
||||
G:/Robot_v2/Model/Solidworks/电机.STEP and 配件/make_v9.py (see docs/interfaces.md).
|
||||
实测接口数值在 arm/constants.py,仓库路径在 arm/paths.py——本模块不再持有它们。
|
||||
"""
|
||||
import math
|
||||
import json
|
||||
import os
|
||||
|
||||
import cadquery as cq
|
||||
from OCP.gp import gp_Trsf, gp_Vec, gp_XYZ, gp_Mat, gp_Pnt, gp_Dir, gp_Ax1, gp_Ax2, gp_Quaternion
|
||||
@@ -20,65 +17,8 @@ from OCP.TopAbs import TopAbs_FACE, TopAbs_REVERSED, TopAbs_SOLID
|
||||
from OCP.TopoDS import TopoDS
|
||||
|
||||
V = cq.Vector
|
||||
ROOT = r"G:/Robot_v2"
|
||||
SRC_STEP = ROOT + r"/Model/Solidworks/电机.STEP"
|
||||
HALF_STEP = ROOT + r"/Model/Solidworks/配件/端面齿半联轴器_相同件x2.STEP"
|
||||
CLAMP_STEP = ROOT + r"/Model/Solidworks/配件/端面齿联轴器_抱箍_两片装配.STEP"
|
||||
WORKSPACE = ROOT + r"/机器人手臂"
|
||||
OUT = WORKSPACE + r"/out"
|
||||
WEB = WORKSPACE + r"/web"
|
||||
DOCS = WORKSPACE + r"/docs"
|
||||
|
||||
# ---------------- MEASURED: motor module (local frame: origin = PCBA rear face centre, +Z = output) ----
|
||||
M = dict(
|
||||
pcba_w=63.6, # square PCBA across flats
|
||||
pcba_t=9.0, # z 0 -> 9.0
|
||||
rearplate_d=60.0, # z 5.5 -> 10.5
|
||||
hous_d=60.0, # housing OD, z 10.5 -> 52.5
|
||||
hous_len=42.0,
|
||||
cover_d=60.30, # top cover OD (R30.15), z 52.5 -> 56.0
|
||||
cover_z=56.0, # cover front face = module total length
|
||||
flange_z=54.942, # 2nd-stage output flange face
|
||||
neck_d=36.0, # female neck bore in the cover
|
||||
neck_depth=1.0,
|
||||
centre_d=6.5, # centre bore in the flange
|
||||
centre_depth=4.0,
|
||||
m2_bcd=29.0,
|
||||
m2_clock=(8.05, 128.07, 248.06),
|
||||
m2_tap_d=1.85, # M2 pilot for self-tapping in printed material
|
||||
m2_depth=6.0,
|
||||
m2_clear_d=2.75,
|
||||
m2_cb_d=5.2,
|
||||
)
|
||||
# ---------------- MEASURED: face-spline coupling (existing, printed by the user) ------------------------
|
||||
C = dict(
|
||||
n_teeth=12, tooth_r0=19.0, tooth_r1=24.5, tooth_s=2.5,
|
||||
flange_t=4.50, part_z=5.560, # mirrored stack
|
||||
hub_r=27.0,
|
||||
gap=9.00, # cover-face to cover-face when closed
|
||||
clamp_od_r=30.0, clamp_len=7.80,
|
||||
lug_r1=37.0, lug_t=4.50,
|
||||
bolt_r=32.50, bolt_d=3.80,
|
||||
z0=1.66, z1=9.46, # clamp envelope in the flange frame (motor cover face = 1.060)
|
||||
)
|
||||
# axial stations, motor local frame:
|
||||
COVER_Z = M["cover_z"] # 56.0
|
||||
HUB_FACE_Z = COVER_Z + C["gap"] # 65.0 = mating face of the driven hub
|
||||
CLAMP_R = C["clamp_od_r"] + 0.6 # radial keep-out around the joint axis
|
||||
LUG_R = C["lug_r1"] + 1.0 # radial keep-out where the bolt lugs are
|
||||
# ---------------- clearances / print rules -------------------------------------------------------------
|
||||
CLR = dict(bore_slip=0.20, pilot=0.10, hole_3=3.4, hole_27=2.8, wall_min=2.0)
|
||||
FIT = dict(motor_bore_d=M["hous_d"] + 2 * 0.15, cover_cb_d=M["cover_d"] + 2 * 0.35)
|
||||
|
||||
# 电机【轴向长度】公差(用户 2026-10-07 要求:余量偏大稍微减去 1mm,由 2mm 改为 1mm)。
|
||||
# 这条链是"后端板螺钉 + 前端压盖螺钉"两个刚性夹持面,螺钉头一旦坐死在沉孔台阶上
|
||||
# 就再也不能进给,所以公差没法靠"拧紧"消化,只能靠两件事吃掉(见 arm_build.Joint.pocket
|
||||
# 与 motor_front_clamp):
|
||||
# 长出来的 0~+1mm -> 后端板 4xM3 螺钉头下垫 0~1mm 垫片圈(螺钉被垫高 -> 电机被拉回)
|
||||
# 短下去的 0~-1mm -> 电机尾端面与后端板承压面之间垫 0~1mm 垫片(承压面已按 MOTOR_TOL
|
||||
# 让空,见 Joint.pocket 的 REAR_SEAT)
|
||||
# 两边都不动压盖/凸台的几何,关节轴向长度不变。
|
||||
MOTOR_TOL = 1.0
|
||||
|
||||
# ---------------- geometry helpers --------------------------------------------------------------------
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""跨件共用的刀法与统一配方:X 向基础体、减重腔 helper、电机前端压盖、止口 hub、pitch->roll 转接座骨架。"""
|
||||
import arm_build as AB
|
||||
from arm.cad import arm_build as AB
|
||||
import cadquery as cq
|
||||
import human_layout as HL
|
||||
from arm import kinematics as HL
|
||||
import math
|
||||
from armcore import cyl, place
|
||||
from arm_build import COVER_Z, R, T, Ycyl, bx, prism
|
||||
from human_layout import X_ARM, Y_AXIS, Y_COVER
|
||||
|
||||
from arm.cad.helpers import cyl, place
|
||||
from arm.cad.arm_build import COVER_Z, R, T, Ycyl, bx, prism
|
||||
from arm.kinematics import X_ARM, Y_AXIS, Y_COVER
|
||||
|
||||
Y_MID = Y_AXIS
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
|
||||
arm_model.build() 只按 PART_MODULES 顺序收集 specs();改哪件就开哪个文件。
|
||||
"""
|
||||
from parts import p01_column, p02_base, p03_girdle, p04_fork, p05_roll_base, p06_humerus, p07_forearm, p08_wrist, p09_flange
|
||||
from arm.cad.parts import p01_column, p02_base, p03_girdle, p04_fork, p05_roll_base, p06_humerus, p07_forearm, p08_wrist, p09_flange
|
||||
|
||||
PART_MODULES = [p01_column, p02_base, p03_girdle, p04_fork, p05_roll_base, p06_humerus, p07_forearm, p08_wrist, p09_flange]
|
||||
@@ -1,11 +1,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""P01 立柱三段(A/B/C):躯干承力柱 + 阶梯榫卯与防反装对锁孔。"""
|
||||
import cadquery as cq
|
||||
from armcore import cyl
|
||||
from arm_build import Ycyl, bx
|
||||
from human_layout import Y_AXIS, Z_CHEST1
|
||||
from part_tools import Xcyl
|
||||
|
||||
from arm.cad.helpers import cyl
|
||||
from arm.cad.arm_build import Ycyl, bx
|
||||
from arm.kinematics import Y_AXIS, Z_CHEST1
|
||||
from arm.cad.part_tools import Xcyl
|
||||
|
||||
# ========== 躯干部分 ==========
|
||||
# ========== 躯干/测试台架部分 (后置防倾翻高刚性机架) ==========
|
||||
@@ -1,8 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""P02 底座三段(中核心 / 前向力臂 / 后向力臂):后置防倾翻机架与防呆母榫。"""
|
||||
import cadquery as cq
|
||||
from armcore import cyl
|
||||
from parts.p01_column import X_COL, Y_COL
|
||||
from arm.cad.helpers import cyl
|
||||
from arm.cad.parts.p01_column import X_COL, Y_COL
|
||||
|
||||
|
||||
def make_p02_poka_yoke():
|
||||
@@ -2,11 +2,11 @@
|
||||
"""P03 肩吊臂:板式桁架 + 侧开式电机托架 + M1 后置法兰。"""
|
||||
import cadquery as cq
|
||||
import math
|
||||
from armcore import cyl
|
||||
from arm_build import Yplate, bx
|
||||
from human_layout import X_BAY_EXT, Y_AXIS, Z_CHEST1, Z_R1, Z_RL
|
||||
from part_tools import Xcyl, make_surface_tie_bridge
|
||||
from parts.p01_column import X_COL, Y_COL, Z_LOCK
|
||||
from arm.cad.helpers import cyl
|
||||
from arm.cad.arm_build import Yplate, bx
|
||||
from arm.kinematics import X_BAY_EXT, Y_AXIS, Z_CHEST1, Z_R1, Z_RL
|
||||
from arm.cad.part_tools import Xcyl, make_surface_tie_bridge
|
||||
from arm.cad.parts.p01_column import X_COL, Y_COL, Z_LOCK
|
||||
|
||||
|
||||
def p_girdle(j1):
|
||||
@@ -1,13 +1,13 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""P04 肩叉:J1 从动杯体、M2 前端压盖、立柱 U 形减重槽、Hermite 弧面前脸。"""
|
||||
import arm_build as AB
|
||||
from arm.cad import arm_build as AB
|
||||
import cadquery as cq
|
||||
import math
|
||||
from armcore import cyl
|
||||
from arm_build import COVER_Z, Ycyl, bx
|
||||
from human_layout import X_ARM, X_FORK_GAP, Y_AXIS, Y_COVER, Y_REAR, Z_RL, Z_SH
|
||||
from arm.cad.helpers import cyl
|
||||
from arm.cad.arm_build import COVER_Z, Ycyl, bx
|
||||
from arm.kinematics import X_ARM, X_FORK_GAP, Y_AXIS, Y_COVER, Y_REAR, Z_RL, Z_SH
|
||||
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common
|
||||
from part_tools import (Xplate, clamp_hole_drills, cut_motor_fence, hollow_channel,
|
||||
from arm.cad.part_tools import (Xplate, clamp_hole_drills, cut_motor_fence, hollow_channel,
|
||||
hub_m2_holes, make_surface_tie_bridge, motor_front_clamp)
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""P05 大臂旋转座:J2 俯仰 -> J3 滚转(复用 part_tools 的 pitch->roll 骨架)。"""
|
||||
import cadquery as cq
|
||||
from human_layout import Z_R3, Z_SH
|
||||
from part_tools import make_pitch_to_roll_bracket
|
||||
|
||||
from arm.kinematics import Z_R3, Z_SH
|
||||
from arm.cad.part_tools import make_pitch_to_roll_bracket
|
||||
|
||||
def p_shoulder_roll_base(j2, j3, half=None):
|
||||
"""P05_大臂旋转座_螺旋体30%_墙4底5_竖打(深度圆角优化版)
|
||||
@@ -1,11 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""P06 大臂骨架:J3 滚转 -> J4 俯仰的 Loft 空心梁。"""
|
||||
import arm_build as AB
|
||||
from arm.cad import arm_build as AB
|
||||
import cadquery as cq
|
||||
from armcore import cyl
|
||||
from arm_build import COVER_Z, Ycyl, bx
|
||||
from human_layout import X_ARM, Y_AXIS, Y_COVER, Y_REAR, Z_EL, Z_R3
|
||||
from part_tools import (
|
||||
from arm.cad.helpers import cyl
|
||||
from arm.cad.arm_build import COVER_Z, Ycyl, bx
|
||||
from arm.kinematics import X_ARM, Y_AXIS, Y_COVER, Y_REAR, Z_EL, Z_R3
|
||||
from arm.cad.part_tools import (
|
||||
CLAMP_BACKOFF, CLAMP_CLR, CLAMP_T, cut_motor_fence, hub_m2_holes,
|
||||
make_surface_tie_bridge_neg_x, motor_front_clamp,
|
||||
)
|
||||
@@ -1,13 +1,13 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""P07 小臂骨架:J4 -> J5 的 L 型结构 + 顶板减重槽。"""
|
||||
import arm_build as AB
|
||||
from arm.cad import arm_build as AB
|
||||
import cadquery as cq
|
||||
import human_layout as HL
|
||||
from arm import kinematics as HL
|
||||
import math
|
||||
from armcore import cyl
|
||||
from arm_build import Ycyl, bx
|
||||
from human_layout import PLATE_T, X_ARM, Y_COVER, Y_HUB, Y_REAR, Z_EL, Z_WR
|
||||
from part_tools import (
|
||||
from arm.cad.helpers import cyl
|
||||
from arm.cad.arm_build import Ycyl, bx
|
||||
from arm.kinematics import PLATE_T, X_ARM, Y_COVER, Y_HUB, Y_REAR, Z_EL, Z_WR
|
||||
from arm.cad.part_tools import (
|
||||
FILLET_SMALL, cut_motor_fence, hollow_channel, hub_m2_holes,
|
||||
make_surface_tie_bridge_neg_x, motor_front_clamp,
|
||||
)
|
||||
@@ -1,8 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""P08 腕偏转座:J5 俯仰 -> J6 滚转(复用 part_tools 的 pitch->roll 骨架)。"""
|
||||
from human_layout import Z_R6, Z_WR
|
||||
from part_tools import make_pitch_to_roll_bracket
|
||||
|
||||
from arm.kinematics import Z_R6, Z_WR
|
||||
from arm.cad.part_tools import make_pitch_to_roll_bracket
|
||||
|
||||
def p_wrist_bracket(j5, j6):
|
||||
"""P08_腕偏转座_螺旋体25%_墙4底5_竖打(深度圆角优化版)
|
||||
@@ -1,11 +1,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""P09 工具法兰:末端法兰盘与 J6 直锁 hub。"""
|
||||
import math
|
||||
from armcore import cyl
|
||||
from arm_build import D2R
|
||||
from human_layout import PLATE_T, X_ARM, Y_AXIS, Z_HUB6
|
||||
from part_tools import CHAMFER_EDGE, CLAMP_BACKOFF, FILLET_SMALL, FILLET_TINY, make_direct_hub
|
||||
|
||||
from arm.cad.helpers import cyl
|
||||
from arm.cad.arm_build import D2R
|
||||
from arm.kinematics import PLATE_T, X_ARM, Y_AXIS, Z_HUB6
|
||||
from arm.cad.part_tools import CHAMFER_EDGE, CLAMP_BACKOFF, FILLET_SMALL, FILLET_TINY, make_direct_hub
|
||||
|
||||
def p_tool_flange(j6):
|
||||
"""P09_工具法兰_螺旋体30%_墙4底5_竖打 深度圆角优化版
|
||||
@@ -0,0 +1,58 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""实测接口数值:电机模组、端面齿联轴器、配合公差与电机轴向公差。
|
||||
|
||||
全部 MEASURED 自 G:/Robot_v2/Model/Solidworks/电机.STEP 与配件(见 docs/interfaces.md)。
|
||||
改之前先确认量的是同一个基准面。"""
|
||||
|
||||
# 注:本段由 armcore.py 原样搬来,行内容与注释未作改动。
|
||||
|
||||
# ---------------- MEASURED: motor module (local frame: origin = PCBA rear face centre, +Z = output) ----
|
||||
M = dict(
|
||||
pcba_w=63.6, # square PCBA across flats
|
||||
pcba_t=9.0, # z 0 -> 9.0
|
||||
rearplate_d=60.0, # z 5.5 -> 10.5
|
||||
hous_d=60.0, # housing OD, z 10.5 -> 52.5
|
||||
hous_len=42.0,
|
||||
cover_d=60.30, # top cover OD (R30.15), z 52.5 -> 56.0
|
||||
cover_z=56.0, # cover front face = module total length
|
||||
flange_z=54.942, # 2nd-stage output flange face
|
||||
neck_d=36.0, # female neck bore in the cover
|
||||
neck_depth=1.0,
|
||||
centre_d=6.5, # centre bore in the flange
|
||||
centre_depth=4.0,
|
||||
m2_bcd=29.0,
|
||||
m2_clock=(8.05, 128.07, 248.06),
|
||||
m2_tap_d=1.85, # M2 pilot for self-tapping in printed material
|
||||
m2_depth=6.0,
|
||||
m2_clear_d=2.75,
|
||||
m2_cb_d=5.2,
|
||||
)
|
||||
# ---------------- MEASURED: face-spline coupling (existing, printed by the user) ------------------------
|
||||
C = dict(
|
||||
n_teeth=12, tooth_r0=19.0, tooth_r1=24.5, tooth_s=2.5,
|
||||
flange_t=4.50, part_z=5.560, # mirrored stack
|
||||
hub_r=27.0,
|
||||
gap=9.00, # cover-face to cover-face when closed
|
||||
clamp_od_r=30.0, clamp_len=7.80,
|
||||
lug_r1=37.0, lug_t=4.50,
|
||||
bolt_r=32.50, bolt_d=3.80,
|
||||
z0=1.66, z1=9.46, # clamp envelope in the flange frame (motor cover face = 1.060)
|
||||
)
|
||||
# axial stations, motor local frame:
|
||||
COVER_Z = M["cover_z"] # 56.0
|
||||
HUB_FACE_Z = COVER_Z + C["gap"] # 65.0 = mating face of the driven hub
|
||||
CLAMP_R = C["clamp_od_r"] + 0.6 # radial keep-out around the joint axis
|
||||
LUG_R = C["lug_r1"] + 1.0 # radial keep-out where the bolt lugs are
|
||||
# ---------------- clearances / print rules -------------------------------------------------------------
|
||||
CLR = dict(bore_slip=0.20, pilot=0.10, hole_3=3.4, hole_27=2.8, wall_min=2.0)
|
||||
FIT = dict(motor_bore_d=M["hous_d"] + 2 * 0.15, cover_cb_d=M["cover_d"] + 2 * 0.35)
|
||||
|
||||
# 电机【轴向长度】公差(用户 2026-10-07 要求:余量偏大稍微减去 1mm,由 2mm 改为 1mm)。
|
||||
# 这条链是"后端板螺钉 + 前端压盖螺钉"两个刚性夹持面,螺钉头一旦坐死在沉孔台阶上
|
||||
# 就再也不能进给,所以公差没法靠"拧紧"消化,只能靠两件事吃掉(见 arm_build.Joint.pocket
|
||||
# 与 motor_front_clamp):
|
||||
# 长出来的 0~+1mm -> 后端板 4xM3 螺钉头下垫 0~1mm 垫片圈(螺钉被垫高 -> 电机被拉回)
|
||||
# 短下去的 0~-1mm -> 电机尾端面与后端板承压面之间垫 0~1mm 垫片(承压面已按 MOTOR_TOL
|
||||
# 让空,见 Joint.pocket 的 REAR_SEAT)
|
||||
# 两边都不动压盖/凸台的几何,关节轴向长度不变。
|
||||
MOTOR_TOL = 1.0
|
||||
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""数据注入与工程图:螺钉实例注入 mesh、四张工程图 SVG 生成。
|
||||
"""
|
||||
@@ -17,9 +17,16 @@ import os
|
||||
import sys
|
||||
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from armcore import WEB, DOCS
|
||||
D = json.load(io.open(WEB + "/mesh_arm.json", encoding="utf-8"))
|
||||
from arm.paths import DATA, DOCS
|
||||
|
||||
# 这两个脚本的顶层就是"算完并写文件",被 import 时会静默重写 travel/mesh 派生产物
|
||||
# (实测:一次 importlib 冒烟测试就把 docs/travel.json 重写了一遍)。这里改成直接炸。
|
||||
if __name__ != "__main__":
|
||||
raise RuntimeError(
|
||||
"draw_sheet 是脚本:顶层代码一执行就重写产物,不能 import。"
|
||||
"要用就跑 python -m arm.dataio.draw_sheet。")
|
||||
|
||||
D = json.load(io.open(DATA + "/mesh_arm.json", encoding="utf-8"))
|
||||
P, LAY, MS = D["parts"], D["layout"], D["measured"]
|
||||
MT, CT = MS["M"], MS["C"]
|
||||
JOINTS = {j["id"]: j for j in D["joints"]}
|
||||
@@ -326,7 +333,5 @@ sheets = [
|
||||
os.makedirs(DOCS, exist_ok=True)
|
||||
for n, s in zip(("sheet_front", "sheet_joint", "sheet_exploded", "sheet_side"), sheets):
|
||||
io.open(DOCS + "/" + n + ".svg", "w", encoding="utf-8").write(s)
|
||||
io.open(WEB + "/sheets.jsdata", "w", encoding="utf-8").write(json.dumps(sheets, ensure_ascii=False))
|
||||
print("sheets: %d (%d KB) -> docs/sheet_{front,joint,exploded,side}.svg" % (
|
||||
print("sheets: %d (%d KB) -> docs/sheet_{front,joint,exploded,side}.svg(查看器读这 4 个文件)" % (
|
||||
len(sheets), sum(len(s) for s in sheets) // 1024))
|
||||
os._exit(0)
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
docs/screws.json 是紧固件清单的唯一事实源(规格 / 长度 / 数量 / 位置 / 备注)。
|
||||
本模块把清单里的每一颗螺钉按其真实装配孔位还原成三维实例(承载头面坐标 + 拧入方向 +
|
||||
随动关节组),生成轻量三角网格后追加进 web/mesh_arm.json 的 parts 中。
|
||||
随动关节组),生成轻量三角网格后追加进 webapp/data/mesh_arm.json 的 parts 中。
|
||||
查看器因此可以:显示全部螺钉、点击 BOM 螺丝行高亮定位、点击螺钉反查清单。
|
||||
|
||||
实例坐标来源(与 arm_model.py 成孔代码一一对应,改模型后按同样规则复核):
|
||||
@@ -38,9 +38,8 @@ docs/screws.json 是紧固件清单的唯一事实源(规格 / 长度 / 数量
|
||||
|
||||
长度处理:S08/S10 螺杆按真实长度会伸到底座底面(z=0)以下悬空(工作台/预埋螺母未建模),
|
||||
这里按可见段截断到 z=0,头部与孔位仍按 BOM 规格,避免出现"悬空螺杆"。
|
||||
|
||||
运行:python src/screw_mesh.py -> 注入网格(纯数据,秒级,不动 CAD)
|
||||
再 python src/build_viewer.py -> 重新打包单文件查看器
|
||||
运行:python -m arm.dataio.screw_mesh -> 注入网格(纯数据,秒级,不动 CAD)
|
||||
再 python -m arm.viewer.build_viewer -> 重新打包单文件查看器
|
||||
arm_model.py 主流程末尾已自动调用本模块,重跑完整构建不会丢失螺钉。
|
||||
"""
|
||||
import json
|
||||
@@ -52,14 +51,9 @@ try:
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_ROOT = os.path.dirname(_HERE)
|
||||
sys.path.insert(0, _HERE)
|
||||
|
||||
import human_layout as HL # noqa: E402
|
||||
from part_tools import CLAMP_BCD, CLAMP_CLOCK # noqa: E402 压盖孔位唯一事实源
|
||||
|
||||
from arm.paths import DATA, DOCS
|
||||
from arm import kinematics as HL
|
||||
from arm.cad.part_tools import CLAMP_BCD, CLAMP_CLOCK
|
||||
X_COL = -115.0 # 立柱中心(与 parts/p01_column.py 的 X_COL 一致)
|
||||
Y_COL = HL.Y_AXIS # 128.0
|
||||
GRP_PAR = {"J2": "J1", "J3": "J2", "J4": "J3", "J5": "J4", "J6": "J5"}
|
||||
@@ -293,11 +287,11 @@ def instances(m2_bcd, m2_clock):
|
||||
|
||||
# ---------------------------------------------------------------- 注入
|
||||
def main():
|
||||
path = os.path.join(_ROOT, "web", "mesh_arm.json")
|
||||
path = DATA + "/mesh_arm.json"
|
||||
print("读取 %s ..." % path, flush=True)
|
||||
d = json.load(open(path, encoding="utf-8"))
|
||||
M = d["measured"]["M"]
|
||||
specs = json.load(open(os.path.join(_ROOT, "docs", "screws.json"), encoding="utf-8"))
|
||||
specs = json.load(open(DOCS + "/screws.json", encoding="utf-8"))
|
||||
byid = {s["id"]: s for s in specs}
|
||||
|
||||
d["parts"] = [p for p in d["parts"] if not p.get("screw")] # 幂等:先清旧螺钉
|
||||
@@ -344,3 +338,10 @@ def main():
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
# OCC/cadquery 的静态析构在解释器正常退出时必定段错误(实测:光是
|
||||
# `python -c "import arm.cad.helpers"` 就返回 139,与本项目代码无关)。所以入口
|
||||
# 脚本跑完后显式刷缓冲再 os._exit —— 否则全流程已经成功、exit code 却是 139,
|
||||
# 会把上游脚本的 set -e 判成失败。只在 __main__ 出口生效, 被 import 时不触发。
|
||||
sys.stdout.flush()
|
||||
os._exit(0)
|
||||
@@ -0,0 +1,28 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""仓库内路径的唯一来源。
|
||||
|
||||
原先这些常量是硬编码的 G:/Robot_v2 绝对路径,仓库换盘符即全断;现在由本文件
|
||||
位置推出。REPO 的父目录就是原来的 ROOT(=G:/Robot_v2),所以外部 STEP 输入的解析
|
||||
结果与从前逐字相同。三个 ARM_* 环境变量是临时覆盖入口。
|
||||
"""
|
||||
import os
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__)).replace("\\", "/") # .../arm
|
||||
REPO = os.environ.get("ARM_WORKSPACE") or os.path.dirname(_HERE)
|
||||
ROOT = os.environ.get("ARM_ROOT") or os.path.dirname(REPO)
|
||||
MODEL = os.environ.get("ARM_MODEL_ROOT") or (ROOT + r"/Model/Solidworks")
|
||||
SRC_STEP = MODEL + r"/电机.STEP"
|
||||
HALF_STEP = MODEL + r"/配件/端面齿半联轴器_相同件x2.STEP"
|
||||
CLAMP_STEP = MODEL + r"/配件/端面齿联轴器_抱箍_两片装配.STEP"
|
||||
OUT = REPO + r"/out"
|
||||
WEB = REPO + r"/web" # 旧单文件产物目录,S8 删除;新代码不要再往里写
|
||||
# 查看器运行时数据与生成脚本(gitignore,由 build_viewer 重写)
|
||||
DATA = REPO + r"/webapp/data"
|
||||
GEN = REPO + r"/webapp/generated"
|
||||
DOCS = REPO + r"/docs"
|
||||
# 可达包络 FK 采样的缓存(ws_cloud.npz / ws_metrics.json)。原先躺在 gitignore 的
|
||||
# scratch/ 里,构建却依赖它——缺了新克隆就跑不出 ENV 件。
|
||||
CACHE = REPO + r"/data/cache"
|
||||
# 前端源码目录与分发产物
|
||||
WEBAPP = REPO + r"/webapp"
|
||||
DIST = REPO + r"/dist"
|
||||
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""查看器侧:数据打包、本地静态服务、headless 截图。
|
||||
"""
|
||||
@@ -0,0 +1,98 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""把生成物落到 webapp/,并打一份单文件分发版。
|
||||
|
||||
单文件时代这里做 8 路字符串替换再写 3 份 16MB 副本;现在前端本身就是 webapp/ 下的
|
||||
文件,本脚本只剩两件事:
|
||||
write_data() 网格/工程图/行程/碰撞/螺钉 -> generated/00_data.js(普通脚本 const)
|
||||
build_single() 把 webapp/ 的 css/js 原样内联成 dist/机器人手臂3D查看器.html(分发用)
|
||||
|
||||
S5 数据改 fetch() 后 write_data() 的 00_data.js 退役;S8 由 pack.py 的受限 ESM 内联器
|
||||
接管 build_single()。
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
from arm.paths import DATA, DIST, DOCS, GEN, WEBAPP
|
||||
|
||||
# 与 SHEET_METAS 的顺序一致;工程图读 draw_sheet 写出的真 SVG,
|
||||
# 不再维护第二份 sheets.jsdata(单文件时代的内联载体,内容与 SVG 完全重复)。
|
||||
SHEET_ORDER = ["sheet_front", "sheet_joint", "sheet_exploded", "sheet_side"]
|
||||
|
||||
|
||||
def _read(path, dflt=None):
|
||||
if os.path.exists(path):
|
||||
return io.open(path, encoding="utf-8").read()
|
||||
if dflt is None:
|
||||
raise SystemExit("缺少查看器数据文件: %s(先跑 python -m arm.build_model)" % path)
|
||||
return dflt
|
||||
|
||||
|
||||
def write_data():
|
||||
t0 = time.time()
|
||||
print("【数据】汇总查看器数据 -> webapp/generated/00_data.js", flush=True)
|
||||
os.makedirs(GEN, exist_ok=True)
|
||||
|
||||
mesh = _read(DATA + "/mesh_arm.json")
|
||||
svgs = [_read(DOCS + "/%s.svg" % n) for n in SHEET_ORDER]
|
||||
trv = _read(DOCS + "/travel.json", "[]")
|
||||
col = _read(DOCS + "/collide.json", '{"caps":[],"parts":{}}')
|
||||
scr = _read(DOCS + "/screws.json", "[]")
|
||||
print(" - 网格 %d KB / 工程图 %d 张 / travel %d B / collide %d KB / screws %d B"
|
||||
% (len(mesh) // 1024, len(svgs), len(trv), len(col) // 1024, len(scr)), flush=True)
|
||||
|
||||
out = ["/* 生成物,勿手改:由 python -m arm.viewer.build_viewer 写入。",
|
||||
" 普通脚本顶层 const —— 经典脚本共享全局词法作用域,后续脚本直接可见。 */"]
|
||||
for name, raw in (("DATA", mesh), ("TRAVEL", trv), ("COLLIDE", col), ("SCREWS", scr)):
|
||||
out.append("const %s = %s;" % (name, raw.strip()))
|
||||
out.append("const SHEETS = %s;" % json.dumps(svgs, ensure_ascii=False))
|
||||
text = "\n".join(out) + "\n"
|
||||
tmp = GEN + "/00_data.js.tmp"
|
||||
io.open(tmp, "w", encoding="utf-8").write(text)
|
||||
os.replace(tmp, GEN + "/00_data.js")
|
||||
print(" 完成:%d KB, %.2fs ✓" % (len(text) // 1024, time.time() - t0), flush=True)
|
||||
|
||||
|
||||
def build_single():
|
||||
"""分发用单文件:仍是普通脚本,所以内联是平凡的(S5 变 ESM 后交给 pack.py)。"""
|
||||
t0 = time.time()
|
||||
os.makedirs(DIST, exist_ok=True)
|
||||
html = _read(WEBAPP + "/index.html")
|
||||
total = len(html)
|
||||
|
||||
def sub(m, kind):
|
||||
nonlocal total
|
||||
body = _read(WEBAPP + "/" + m.group(1))
|
||||
total += len(body)
|
||||
return ("<style>\n" + body + "\n</style>") if kind == "css" \
|
||||
else ("<script>\n" + body + "\n</script>")
|
||||
|
||||
out = re.sub(r'<link rel="stylesheet" href="([^"]+)">', lambda m: sub(m, "css"), html)
|
||||
out = re.sub(r'<script src="([^"]+)"></script>', lambda m: sub(m, "js"), out)
|
||||
|
||||
left = re.findall(r'(?:src|href)="(?!data:)([^"]+)"', out)
|
||||
assert not left, "单文件里仍有外部引用: %s" % left[:5]
|
||||
assert len(out) >= total * 0.95, "内联后 %d 远小于各部之和 %d,疑似漏文件" % (len(out), total)
|
||||
dst = DIST + "/机器人手臂3D查看器.html"
|
||||
# Windows 上被浏览器打开的 html 是独占锁;写不进去就直接报,不要静默留旧版。
|
||||
io.open(dst, "w", encoding="utf-8").write(out)
|
||||
print(" - 单文件分发版 %.2f MB, %.2fs ✓ %s"
|
||||
% (os.path.getsize(dst) / 1e6, time.time() - t0, dst), flush=True)
|
||||
|
||||
|
||||
def rebuild_viewer():
|
||||
"""build_model 流水线的最后一步(沿用旧函数名,调用点不改)。"""
|
||||
write_data()
|
||||
build_single()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
rebuild_viewer()
|
||||
@@ -0,0 +1,132 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""查看器的本地静态服务器(前端拆分后,多文件 + ES Module + Worker 都需要 http 才能跑)。
|
||||
|
||||
python -m arm.viewer.serve [--port 8123] [--print-only]
|
||||
|
||||
要点:
|
||||
- docroot = 仓库根,`/` 直接给 webapp/index.html;/docs/*.json 这类数据同域可读。
|
||||
- Cache-Control: no-store —— 重新生成后旧标签页仍显示旧版是这里踩过的坑,
|
||||
过去只能人肉 Ctrl+F5;不缓存之后刷新即新构建。
|
||||
- 显式 MIME:Windows 上 .js 常被注册成 text/plain,那会让 <script type=module>
|
||||
静默不执行(页面全黑且没有明显报错),所以这里写死。
|
||||
- 屏蔽 .git / backups / 定位图 / out 等目录,不把整个仓库暴露到端口上。
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
from arm.paths import REPO
|
||||
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
DENY = (".git", "backups", ".bak", "定位图", "out", "scratch", "node_modules")
|
||||
MIME = {
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".mjs": "text/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".html": "text/html; charset=utf-8",
|
||||
".npz": "application/octet-stream",
|
||||
".png": "image/png",
|
||||
".wasm": "application/wasm",
|
||||
}
|
||||
|
||||
|
||||
class H(SimpleHTTPRequestHandler):
|
||||
# HTTP/1.1 + keep-alive + 大缓冲:默认 HTTP/1.0 每请求一条连接,
|
||||
# 4 个 headless Edge 同时各拉 14MB 数据脚本时会把页面加载拖到截图之后。
|
||||
protocol_version = "HTTP/1.1"
|
||||
wbufsize = 1 << 20
|
||||
# 按 (路径, mtime) 缓存字节:同一份 14MB 数据在一次批量截图里要发几十次。
|
||||
_CACHE = {}
|
||||
|
||||
def __init__(self, *a, **k):
|
||||
super().__init__(*a, directory=REPO, **k)
|
||||
|
||||
def _cached_bytes(self, path):
|
||||
try:
|
||||
st = os.stat(path)
|
||||
except OSError:
|
||||
return None
|
||||
key = (path, st.st_mtime_ns, st.st_size)
|
||||
body = H._CACHE.get(key)
|
||||
if body is None:
|
||||
with open(path, "rb") as f:
|
||||
body = f.read()
|
||||
if st.st_size <= 64 << 20:
|
||||
if len(H._CACHE) > 12:
|
||||
H._CACHE.clear()
|
||||
H._CACHE[key] = body
|
||||
return body
|
||||
|
||||
def send_head(self):
|
||||
rel = self.path.split("?", 1)[0].split("#", 1)[0].lstrip("/").replace("\\", "/")
|
||||
top = rel.split("/")[0].lower()
|
||||
if top in [d.lower() for d in DENY] or ".." in rel:
|
||||
self.send_error(404, "denied")
|
||||
return None
|
||||
fs = self.translate_path(self.path)
|
||||
if os.path.isfile(fs):
|
||||
body = self._cached_bytes(fs)
|
||||
if body is not None:
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", self.guess_type(fs))
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return None
|
||||
return super().send_head()
|
||||
|
||||
def guess_type(self, path):
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
return MIME.get(ext) or super().guess_type(path)
|
||||
|
||||
|
||||
def do_GET(self):
|
||||
"""`/` 必须 302 到 /webapp/index.html,不能在原地把内容发出去:
|
||||
那样文档基址是 `/`,页面里所有相对路径(styles/… js/… vendor/…)都会
|
||||
解析到 /styles/base.css 之类而 404 —— 实测整页样式与脚本全丢。"""
|
||||
if self.path.split("?", 1)[0].rstrip("/") in ("", "/"):
|
||||
q = self.path.split("?", 1)[1] if "?" in self.path else ""
|
||||
self.send_response(302)
|
||||
self.send_header("Location", "/webapp/index.html" + ("?" + q if q else ""))
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
return
|
||||
super().do_GET()
|
||||
|
||||
def end_headers(self):
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
super().end_headers()
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
sys.stderr.write("%s %s\n" % (self.address_string(), fmt % args))
|
||||
|
||||
|
||||
def serve(port=8123):
|
||||
srv = None
|
||||
for p in range(port, port + 20):
|
||||
try:
|
||||
srv = ThreadingHTTPServer(("127.0.0.1", p), H)
|
||||
port = p
|
||||
break
|
||||
except OSError:
|
||||
continue
|
||||
if srv is None:
|
||||
raise SystemExit("端口 %d..%d 都被占用" % (port, port + 19))
|
||||
url = "http://127.0.0.1:%d/" % port
|
||||
print("docroot %s" % REPO, flush=True)
|
||||
print("查看器: %s (Ctrl+C 停止;已禁用缓存,改完刷新即新构建)" % url, flush=True)
|
||||
srv.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ap = argparse.ArgumentParser(description="查看器本地静态服务器")
|
||||
ap.add_argument("--port", type=int, default=8123)
|
||||
serve(ap.parse_args().port)
|
||||
@@ -0,0 +1,157 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Headless Edge screenshots of the viewer, for the visual self-check loop."""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import shutil
|
||||
import glob
|
||||
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
from arm.paths import DOCS, REPO
|
||||
SHOTS = DOCS + "/shots"
|
||||
# 虚拟时间预算:多文件版每页要拉 14MB 数据脚本 + 668KB three,4 并发下 20s 会不够(实测 8/10 视图两轮不一致)
|
||||
# 时可上调。默认仍给 20000,用 ARM_VTB 覆盖做对照实验。
|
||||
VTB = os.environ.get("ARM_VTB", "40000")
|
||||
WORKERS = int(os.environ.get("ARM_SHOT_WORKERS", "2"))
|
||||
# 前端拆分后是多文件 + Worker,只能走 http(file:// 下模块与 Worker 会被 CORS 拦掉)。
|
||||
PORT = int(os.environ.get("ARM_PORT", "8123"))
|
||||
BASE = "http://127.0.0.1:%d/webapp/index.html" % PORT
|
||||
CAND = [r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
|
||||
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe"]
|
||||
EDGE = next((c for c in CAND if os.path.exists(c)), None)
|
||||
if not EDGE:
|
||||
EDGE = glob.glob(r"C:\Users\*\AppData\Local\Microsoft\Edge\*\msedge.exe")
|
||||
EDGE = EDGE[0] if EDGE else None
|
||||
if not EDGE:
|
||||
raise SystemExit("msedge not found")
|
||||
|
||||
VIEWS = {
|
||||
"01_iso": "?cam=iso",
|
||||
"02_front": "?cam=front",
|
||||
"03_side": "?cam=side",
|
||||
"04_top": "?cam=top",
|
||||
"05_exploded": "?cam=iso&expl=100",
|
||||
"06_wrist": "?cam=wrist&xray=0",
|
||||
"07_joint": "?cam=joint",
|
||||
"08_pose": "?cam=iso&j2=35&j3=60&j4=-25&j1=25",
|
||||
"09_sheet": "?sheet=1",
|
||||
"10_solid": "?cam=iso&xray=0",
|
||||
"11_tool_flange": "?cam=wrist&xray=0&j6=45",
|
||||
"12_elbow_flare": "?cam=elbow&xray=0",
|
||||
"13_forearm_side": "?cam=side&xray=0&rd=340&ty=245&tz=-135",
|
||||
|
||||
# 工程图 / 装配向导 / 运动模拟三种模式。URL 参数(?view=&sheet=&theme=&guide=)就是
|
||||
# 为这几条截图加的:不把它们纳入基线,拆 CSS 与那 700 行工程图 JS 时等于没有关卡。
|
||||
|
||||
"m1_sheet_front": "?view=sheet",
|
||||
"m2_sheet_joint": "?view=sheet&sheet=1",
|
||||
"m3_sheet_exploded": "?view=sheet&sheet=2",
|
||||
"m4_sheet_side": "?view=sheet&sheet=3",
|
||||
"m5_guide_step1": "?view=sheet&sheet=guide&guide=0",
|
||||
"m6_guide_step5": "?view=sheet&sheet=guide&guide=4",
|
||||
"m7_sheet_blueprint": "?view=sheet&theme=blueprint",
|
||||
"m8_sheet_dark": "?view=sheet&theme=dark",
|
||||
"m9_motion": "?view=motion",
|
||||
|
||||
"harness_full_iso": "?cam=iso&xray=0",
|
||||
"harness_overview": "?az=-0.7&po=1.2&rd=520&tx=0&ty=400&tz=-120&xray=0",
|
||||
"harness_p04_p05": "?az=0.8&po=1.35&rd=280&tx=25&ty=470&tz=-128&xray=0",
|
||||
"harness_p04_p05_xray": "?az=0.8&po=1.35&rd=280&tx=25&ty=470&tz=-128&alpha=0.35&xray=1",
|
||||
"harness_p06_window": "?az=-0.65&po=1.25&rd=240&tx=25&ty=355&tz=-128&xray=0",
|
||||
"harness_p07_window": "?az=-0.65&po=1.25&rd=260&tx=25&ty=275&tz=-130&xray=0",
|
||||
"harness_p07_xray": "?az=-0.65&po=1.25&rd=260&tx=25&ty=275&tz=-130&alpha=0.35&xray=1",
|
||||
"harness_xray_full": "?cam=iso&xray=1",
|
||||
"harness_xray_internal": "?az=-0.7&po=1.2&rd=520&tx=0&ty=400&tz=-120&alpha=0.35&xray=1",
|
||||
}
|
||||
os.makedirs(SHOTS, exist_ok=True)
|
||||
|
||||
want = sys.argv[1:] or list(VIEWS)
|
||||
|
||||
|
||||
def take_shot(nm):
|
||||
q = VIEWS[nm]
|
||||
out = "%s/%s.png" % (SHOTS, nm)
|
||||
import time, tempfile
|
||||
t_bust = int(time.time() * 1000)
|
||||
# serve.py 已下发 Cache-Control: no-store,不再需要 _t= 破缓存
|
||||
target = q if q.startswith("http") else BASE + q + (
|
||||
"&still=1" if "?" in q else "?still=1")
|
||||
udd = tempfile.mkdtemp(prefix="eshot")
|
||||
cmd = [EDGE, "--headless=new", "--use-gl=angle", "--use-angle=d3d11",
|
||||
"--disable-cache", "--disable-application-cache", "--disk-cache-size=0",
|
||||
"--user-data-dir=" + udd,
|
||||
"--enable-gpu-rasterization", "--ignore-gpu-blocklist",
|
||||
"--hide-scrollbars", "--force-device-scale-factor=1",
|
||||
"--window-size=1500,860", "--virtual-time-budget=" + VTB,
|
||||
"--screenshot=" + out, target]
|
||||
r = subprocess.run(cmd, capture_output=True, timeout=180)
|
||||
shutil.rmtree(udd, ignore_errors=True)
|
||||
ok = os.path.exists(out) and os.path.getsize(out) > 20000
|
||||
err = (r.stderr or b"").decode("utf-8", errors="ignore").strip()[:90].replace("\n", " ")
|
||||
sz = os.path.getsize(out) if os.path.exists(out) else 0
|
||||
print("%-18s %s %8s %s" % (nm, "OK " if ok else "FAIL", sz, err), flush=True)
|
||||
return ok
|
||||
|
||||
def warmup():
|
||||
"""拉一次最大的那个数据脚本,成功才算服务可用。"""
|
||||
import urllib.request
|
||||
try:
|
||||
with urllib.request.urlopen(BASE, timeout=5) as r:
|
||||
r.read()
|
||||
with urllib.request.urlopen("http://127.0.0.1:%d/webapp/generated/00_data.js" % PORT,
|
||||
timeout=30) as r:
|
||||
n = len(r.read())
|
||||
return n > 1000000
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def ensure_server():
|
||||
"""端口没开就自己拉一个 serve 子进程,跑完回收;不让截图依赖"你先把服务开着"。"""
|
||||
import socket
|
||||
import time
|
||||
sk = socket.socket()
|
||||
try:
|
||||
sk.connect(("127.0.0.1", PORT))
|
||||
sk.close()
|
||||
return None
|
||||
except OSError:
|
||||
pass
|
||||
errf = open(os.path.join(REPO, "scratch", "_serve_err.txt"), "wb")
|
||||
p = subprocess.Popen([sys.executable, "-m", "arm.viewer.serve", "--port", str(PORT)],
|
||||
cwd=REPO, stdout=subprocess.DEVNULL, stderr=errf)
|
||||
for _ in range(60):
|
||||
time.sleep(0.25)
|
||||
sk = socket.socket()
|
||||
try:
|
||||
sk.connect(("127.0.0.1", PORT))
|
||||
sk.close()
|
||||
if warmup():
|
||||
print("serve 已拉起 :%d(首屏数据已预热)" % PORT, flush=True)
|
||||
return p
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
sk.close()
|
||||
except Exception:
|
||||
pass
|
||||
p.kill()
|
||||
err = open(os.path.join(REPO, "scratch", "_serve_err.txt"), "rb").read().decode("utf-8", "ignore").strip()[-400:]
|
||||
raise SystemExit("serve 拉不起来:端口 %d 可能被占;子进程输出:\n%s" % (PORT, err))
|
||||
|
||||
if __name__ == "__main__":
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
srv = ensure_server()
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=WORKERS) as executor:
|
||||
results = list(executor.map(take_shot, want))
|
||||
finally:
|
||||
if srv:
|
||||
srv.terminate()
|
||||
|
||||
print("edge:", EDGE)
|
||||
print("all shots ok:", all(results))
|
||||
@@ -0,0 +1,71 @@
|
||||
# ML 运行手册审查(ml/README.md)· 精简版
|
||||
|
||||
八轮审查(`0218d7f` → 本次)的已解决条目全部删去,原文在 git 历史里。本文件只留:实测数字 / 已定判据 / 仍待真数据回答的问题 / 正式训练步骤。
|
||||
|
||||
环境:本机 CBB-003(RTX 3090,训练可用 19–20 GB;28 核;Python 3.12 + torch 2.10;Edge + onnxruntime-web 1.20.1,已入库 `webapp/vendor/ort/`)。
|
||||
当前结论:第 1–6 步本机实测可跑;R4 通过;自检 7/8 通过(唯一失败项是 Windows 限制,已接受);**审查条目已全部处置,下一步直接正式训练。**
|
||||
|
||||
---
|
||||
|
||||
## 一、实测数字(对照用)
|
||||
|
||||
| 项 | 数字 | 口径 |
|
||||
|---|---|---|
|
||||
| 对拍 300 条 | PASS(fk 3.4e-13 mm、特征 6e-8、间隙梯度 1.4e-6) | 第 2 步 |
|
||||
| 生成 | 1.56 s/条 | 第 4 步,80 条 |
|
||||
| 统计 | keepRate 0.977、samplesPerTask 36.5、cert.minGap p50 4.4 / p90 5.9 mm | 88 条,未加 3 mm 过滤 |
|
||||
| 预训练 | 4.92 M 参数、6208 样本/s、60k 步 ≈ 2.8 h、显存峰值 2.76 GiB | batch 1024 |
|
||||
| APG 显存 | batch 48 / plans 150 → 13.08 GiB;batch 128 必 OOM | d=192,1 步 |
|
||||
| R4 部署口径 | wasm·16 线程·proxy P99 **12.67 ms**(< 15 通过);WebGPU P99 27.09,不用 | `feat_dyn` 新模型 |
|
||||
| 自检 | 7/8,3.5 分钟;`train_term=false`(Windows 下 TERM 送不到 python,超时靠 STOP 文件 + 每 10 分钟存档) | `selftest.json` |
|
||||
|
||||
## 二、已定的判据与做法
|
||||
|
||||
| 事项 | 定为 |
|
||||
|---|---|
|
||||
| R9(真查看器、训练好的模型、30 道贴柱题) | 稳态 `planWarmP99` < 45 ms、运动中饥饿 < 1%、认证超时占比 < 5%;冷启动 P99、刹停率、`tickGapMax` 只报 |
|
||||
| R9 不过 | 几何查询(特征、投影、认证)搬进 Worker,主线程只执行 |
|
||||
| APG | batch 48、steps 8000(总任务量同原 128×3000),本进程显存上限浮动(整卡 − 已用 − 1 GB 余量,见 `ml/common/vram.py`) |
|
||||
| 离线 ort | 只用入库本地副本,缺失即报错,不退回 CDN |
|
||||
| 训练余量 3 mm | `stats.json` 间隙 p10 ≥ 3 mm 保持,否则降到 p10 |
|
||||
| `R2_STRICT` | `pool_check.independent=true` 且截距 < 0.10 时打开 |
|
||||
| 共识过滤 | 门 1 < 0.90 自动开 |
|
||||
| 分歧 | 实现方为准,下一轮数据定 |
|
||||
|
||||
## 三、仍待真数据回答(正式训练会自动产出,不单独验证)
|
||||
|
||||
1. `gapDrop`:3 mm 过滤的真实剔除比例 → `stats.json`。
|
||||
2. R2 池无关性 → `r2.json` 的 `pool_check`。
|
||||
3. R8 出题耗时 → `policy_bench.json` 的 `genS`。
|
||||
4. APG 收敛与机时(batch 48 下首测)→ APG 日志。
|
||||
5. 真模型 R9 → `r9_browser.json`。
|
||||
|
||||
## 三′、首次正式预训练失败(2026-10-18,21850 步)与修正
|
||||
|
||||
现象:闭环到达 0–0.2%、穿柱 77–97%;MF 残差 2 万步停在 7–8、17k–19k 尖峰到 96;梯度 100% 被裁剪。用 last.pt 实测定位:
|
||||
1. **多峰头塌成单峰(主因)**:同一状态 8 个候选间标准差 0.030、动作幅度 0.151。FK / 平滑损失加在 `one_step(随机噪声)` 上去对齐这一条老师 Y,最优解是条件均值 E[Y|X],左绕 / 右绕取平均正好穿柱。→ 改加在 r = t、t ≤ 0.3 的配对去噪估计 x̂ = z − t·u 上。
|
||||
2. **MeanFlow 目标失控**:∂u/∂t 均方根 6–12,而 v 约 1.1,目标被自激的 JVP 项主导。时间嵌入 sin(1000·t) 太陡,JVP 又在 bf16 下算。→ 最高角频率 50;MF 分支 fp32;前 5000 步 p_neq = 0。
|
||||
3. **闭环分布偏移被低估(推断)**:增广只有 1°。→ 增广 40% / 3° / 12 步收回;新增 DAgger 阶段(策略走到的状态请老师重规划,2 轮);开训前加老师轨迹回放自检;每次评估加开环误差与候选多样性。
|
||||
|
||||
## 四、正式训练(三段,每段跑完回传)
|
||||
|
||||
| 段 | 命令 | 耗时 | 回传 |
|
||||
|---|---|---|---|
|
||||
| 数据 | `FROM=kin TO=prep bash ml/pipeline.sh all` | 数小时 | `ml/data/stats.json`、`ml/data/r2.json`、`ml/runs/r4.json` |
|
||||
| 训练(含 DAgger) | `FRESH_FROM=prep FROM=prep TO=m5 bash ml/pipeline.sh all`(增广改了,样本要重打包;gen 不重跑) | 约 9–11 小时(未实测:prep 0.5 + 预训练约 5(fp32 变慢)+ DAgger 约 3.5 + m5 0.3) | `ml/runs/replay_teacher.json`、`ml/runs/base/log.jsonl`、`ml/runs/m5_gate_*.json` |
|
||||
| 后训练与评测 | `FROM=apg TO=accept bash ml/pipeline.sh all` | 约 10–20 小时 | `ml/runs/acceptance.json`、`policy_bench.json`、`r9_browser.json` |
|
||||
|
||||
中途出错:把出错信息发我,修完用 `FROM=<出错阶段>` 接着跑,前面的不重做。
|
||||
|
||||
---
|
||||
|
||||
## 七、第一次真跑(2026-10-18,网页「开始训练」)暴露的一处阻塞 + 门 1 实测
|
||||
|
||||
**阻塞(已修,一行)**:`pipeline.sh` 的 `run() { echo "== $*"; "$@"; }` 把命令回显写进 **stdout**,而 `gate1` 是 `run node ml/gen/consistency.cjs … | tee ml/data/consistency.json.tmp` → 回显被一起存进 `consistency.json`,文件首行变成 `== node ml/gen/consistency.cjs 300 4242`。`gen` 阶段读它判 CONSENSUS 时 `json.load` 当场 `JSONDecodeError: Expecting value: line 1 column 1 (char 0)`,`set -e` 下整条 `all` 停在 gen(阶段 rc:kin 0、r4 0、gate1 0、gen 1)。
|
||||
- 与平台无关,沙箱 Linux 同样会中,只是没跑到这一步。
|
||||
- 修法:回显改走 stderr(`echo "== $*" >&2`)。全仓只有 `gate1` 这一处把 `run` 的输出重定向进文件(`check_r2` 等用 `--out` 自写,不受影响)。
|
||||
- 已写坏的 `ml/data/consistency.json` 就地剥掉首行复用,**没有重跑 25–30 分钟的门禁**。
|
||||
- 建议实现方顺手加一条自检:任何被 `tee`/`>` 落盘成 JSON 的产物,读之前先确认首字符是 `{`。
|
||||
|
||||
**门 1 实测(第一次真数据)**:`tasks 300 / pairs 1133 / consistentRate 0.838 / consistentByGateRule 0.838 / gate FAIL / deterministic 300/300`,分类看 `C 0.824`、`B 0.844`,`devP50 3.6`、`devP90 145.1`。
|
||||
→ 0.838 < 0.90,**自动走共识分支**(`--consensus 4`),与方案 R1 首测 78% 同一侧;`worstAxisOfInconsistent [43,4,42,40,16,39]` 说明跳变在各轴上分布均匀,不是某一根轴的系统性问题。gen 因此按 4–5 h 那一档估,不是 1 h。
|
||||
@@ -0,0 +1,530 @@
|
||||
# Transformer 避障运动策略:最终方案
|
||||
|
||||
> 基于 `main@3c7c814`(贴立柱修复后的规划器)。本文是方案,代码尚未编写。
|
||||
> 训练机:RTX 3090 24 GB,训练可用 19–20 GB(桌面常驻 4–5 GB,进程硬帽 0.78)。部署:浏览器(后续真机)。
|
||||
|
||||
---
|
||||
|
||||
## 0. 定位与边界
|
||||
|
||||
| 项目 | 决定 |
|
||||
|---|---|
|
||||
| 部署形态 | **无运行时老师**:运行时只有「神经策略 + 碰撞安全层」,不加载 IK 大表、PRM 路网、A*/RRT,也不调用 `solveDyn` / `routeRT` |
|
||||
| 运行方式 | **闭环滚动执行**:执行频率 50 Hz(每步 20 ms);每 60 ms 读一次当前关节角和目标,推理一段 16 步动作块,安全层认证后提交前 3 步 |
|
||||
| 碰撞安全层 | 不做搜索,只对模型给出的候选做**距离下界认证**(§8.3),不安全就换候选或沿已认证刹停段停下 |
|
||||
| 老师的角色 | 只在**离线**出现:生成预训练数据;后训练以可微碰撞代价为主(§7) |
|
||||
| 不做的事 | 不学动力学(速度/加速度上限由时间律与插补器保证);不用相机/点云(环境固定:台架 + P01 立柱,几何已知) |
|
||||
| 收益目标 | 冷启动(新目标到第一个动作)< 10 ms;每次推理 + 安全认证 < 20 ms;消除现有拖拽重规划 P99 约 321 ms 的长尾 |
|
||||
| 底线 | 实时拖拽**碰撞帧 = 0**(由安全层距离认证保证);到达率、行程在 §9 指标上不低于现有规划器 |
|
||||
|
||||
**关于"风险":** 论文证据只能说明"别人在相近条件下做成过",没有一篇论文的条件(低维状态、单根固定立柱、约 4M 参数、浏览器推理、30/30)与本项目完全一致。因此本方案中只有安全层是**硬保证**;每个设计点都已定为唯一选择(§1.1),只有 MeanFlow 头带一个预先写定触发条件的回退(碰撞与否不依赖模型好坏);其余每一项都配有证据等级、本仓库可测的**门禁**(不过不进下一步)和**回退**(§10)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 方案总览
|
||||
|
||||
| 模块 | 最终选择 | 证据等级 | 关键依据 |
|
||||
|---|---|---|---|
|
||||
| 模型规模 | 约 4.9M 参数编码器-解码器 Transformer,不用 VLA 大模型 | A | IMPACT 4.67M、300 Hz;π0 3B 单次预填充 46 ms(4090) |
|
||||
| 数据 | 规划器离线造 5 万条任务 → 约 200 万"状态→动作块"样本 | B | MπNets、DRP、ELMP 均用规划器老师 |
|
||||
| 时间律 | 匀速巡航 + 梯形加减速,只依赖"当前速度 + 剩余路程";当前速度作输入 | B | MπNets 将老师路径重定时为匀速 |
|
||||
| 动作表示 | 16 步 × 6 轴关节增量 Δq | A | DRP;Diffusion Policy(位置式优于速度式) |
|
||||
| 动作头 | **MeanFlow 一步生成头**,每次并行采 8 个噪声 → 8 条候选 | B | 沙箱双模态实验(§2.4);MP1 约 6.8 ms |
|
||||
| 块间连续 | 训练时前缀条件化:输入已提交未执行的步 | B | 2512.05964:推理零开销,d≥2 时优于推理时 RTC |
|
||||
| 碰撞修正 | 输出后 1 步解析胶囊距离梯度投影(每轴 ≤ 0.5°,仅 d < 余量时) | B | DiffusionSeeder:少步生成下在环引导收益很小;投影在已知环境下够用 |
|
||||
| 候选排序 | 与已提交块重叠步的一致性打分 + 价值头 | B | BID:一致性打分 + 多样本相对提升 32% |
|
||||
| 预训练 | 行为克隆 + FK 胶囊端点损失 | B | ELMP 式 FK 损失 |
|
||||
| 后训练(主) | APG:可微运动学展开 + 胶囊距离代价反传 | A | ELMP:碰撞率 31.3%→8.9%;1 万示范 + APG 胜 60 万纯 BC |
|
||||
| 后训练(辅) | 无老师挖错循环 + 门禁 | B | DRP 迭代微调 +45% |
|
||||
| 安全层 | 距离下界认证 + 可刹停不变式 | A | Schwarzer 2005 连续认证;Thumm & Althoff 2022 failsafe |
|
||||
| 30/30 手段 | 子目标头、价值头、冷启动/卡死时短前瞻、卡死换向 | B / C | AWE、SHAC、BID |
|
||||
| 老师 | PRM 路网 A* + 捷径平滑(确定性);A* 失败才用 `solveDyn` | B | MπNets:老师不一致碰撞率 13.78%,一致老师 0.94% |
|
||||
| 推理后端 | onnxruntime-web wasm-SIMD 多线程,推理放代理 Worker(不占主线程),线程数 min(16, ⌊0.6×逻辑核⌋);R4 探针同测 WebGPU,wasm 不达标而 WebGPU 达标则部署用 WebGPU | C | 小模型下 WebGPU 调度开销通常占主导,以 R4 实测为准 |
|
||||
|
||||
### 1.1 取舍决定(每项只有一个答案)
|
||||
|
||||
| 选项 | 利 | 弊 | 决定 |
|
||||
|---|---|---|---|
|
||||
| 动作头:MeanFlow 一步头 vs WTA K=4 | MeanFlow:连续多模态,能同时表达左/右绕行与位姿目标的多个 IK 分支;沙箱 1 步达到 10 步流匹配质量。WTA:训练稳、实现简单 | MeanFlow:训练需 JVP(训练慢约 1.5–2 倍)、原生版本在部分任务失败(K-MF 表 1)。WTA:只有 4 个固定模式,左右 × 肘/腕分支可能超过 4 个;沙箱分不出两者高下 | **上 MeanFlow**。WTA 不实现;只有 M5 门禁不过(near_column 穿柱率高于 2% 或训练发散)才换成 WTA K=8,二者共用骨干,切换约 1 天 |
|
||||
| Shortcut + 条件 OT 配对 | 无需 JVP | 本项目无任何实测,组合无论文先例 | **不上** |
|
||||
| B 样条动作表示 | 平滑由表示保证;输出维度小 | 贴柱时拐角被抹圆、局部精度下降;只有间接证据;平滑已由梯形时间律 + 平滑损失保证 | **不上**,固定 16 步 Δq |
|
||||
| 输出后梯度投影 | 修掉 1–2 mm 的轻微侵入,成本约 0.1 ms,减少刹停 | 推离数据分布、改变块间一致性;修不了"绕错侧" | **上**,限 1 步、每轴修正量 ≤ 0.5°,仅 d < 余量时触发 |
|
||||
| 短前瞻(多推一块打分) | 避开"走两步就撞墙"的分支 | 推理次数翻倍,加剧最大不确定项(浏览器延迟) | **只在冷启动和卡死换向时上**,平时不做 |
|
||||
| 拒绝采样自蒸馏 | 进一步缩短行程 | 偏向贴柱、余量变小、多样性塌缩;APG 行程项已覆盖同一目标 | **不上** |
|
||||
| ReinFlow 强化学习微调 | 能直接优化到达/卡死/刹停等离散目标 | RL 会重塑左右模式比例;奖励调参与训练多出数天;这些离散目标已由卡死换向规则 + APG 覆盖 | **不上** |
|
||||
| 挖错循环中 E2 错题请老师补路径 | 解决可微代价绕不出的局部极小 | 量小、离线 | **上** |
|
||||
| 老师:PRM 路网 A* vs `solveDyn` | A*:确定性,同类任务左右选择一致,且是路网上的最短;`solveDyn` 覆盖更全 | A*:受路网分辨率限制,个别任务失败 | **A* 为主老师**,失败的任务用 `solveDyn`(固定种子)补 |
|
||||
| 推理后端:WebGPU vs wasm | WebGPU 大模型快 | 本模型约 4M 参数,WebGPU 每次调度 33–67 μs × 150–300 次,调度开销反而大 | **wasm-SIMD 多线程**;M4 实测若 WebGPU P99 更低再换 |
|
||||
| 训练余量 | 越大越不易被认证拒绝 | 越大行程越长、贴柱越难;高于老师轨迹自身间隙(实测最小间隙 p50 4.4 / p90 5.9 mm)会与 BC 标签对抗 | 数据过滤与 APG 碰撞项 **3 mm**(认证最小间隙 < 3 mm 的老师轨迹不进训练集);运行时投影护栏独立保持 **5 mm**,护栏不迁就数据分布 |
|
||||
| 关节噪声增广 σ | 覆盖偏离状态 | 过大产生无意义状态 | **1°** |
|
||||
| 局部牛顿精修 | 末端精度到 < 1 mm | 无 | **上**,同样过安全层 |
|
||||
|
||||
证据等级:**A** 有直接先例与数字 · **B** 有相近先例,需本仓库验证 · **C** 仅推断。
|
||||
|
||||
---
|
||||
|
||||
## 2. 依据
|
||||
|
||||
### 2.1 业界神经运动规划
|
||||
|
||||
| 工作 | 年份 / 会议 | 输入 | 输出 | 老师与数据量 | 关键做法 | 运行方式 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **MπNets**(NVIDIA) | 2022 CoRL | 点云 + 关节角 + 目标位姿 | 下一步关节位置 | 规划器 300 万+ 问题 | 端到端行为克隆;老师路径重定时为匀速 | 闭环 9 Hz,1 kHz PD 跟踪 |
|
||||
| **Neural MP**(CMU) | 2024 / IROS 2025 | 点云 + 关节角 + 目标 | GMM(LSTM) | 100 万–300 万条 | 测试时采样多条,选穿透最少的(去掉后成功率 81%→31%) | 开环为主 |
|
||||
| **DRP / IMPACT**(CMU) | 2025 CoRL | 点云 + 关节角 + 目标关节角 | 10 步关节增量 | cuRobo 1000 万条 | 大规模预训练 + 迭代师生微调 + "目标被挡停在最近安全处" | 闭环;4.67M 参数,300 Hz |
|
||||
| **ELMP**(ETH) | 2026 arXiv | 点云 + 关节角 + 目标位姿 | 关节增量 | AIT* 约 60 万条 | 行为克隆后 **APG 微调**,碰撞/到达/平滑损失直接反传 | 闭环 30 Hz |
|
||||
|
||||
- ELMP:1 万条老师轨迹 + APG(成功率 72.0%)超过 60 万条纯行为克隆(57.9%);APG 把碰撞率降到 1/3 以下。
|
||||
- 最好的方法在静态场景仍有约 9–10% 碰撞(ELMP-APG 8.9%,DRP 10.16%),实际部署都配检查或反应层。
|
||||
- 共识:闭环、短动作块滚动执行、老师只离线用、必须后训练。
|
||||
|
||||
### 2.2 本项目的条件
|
||||
|
||||
| 条件 | 主流 | 本项目 | 意味着 |
|
||||
|---|---|---|---|
|
||||
| 环境 | 未知、多变,靠点云 | 固定且精确已知(台架 + 立柱,1936 个胶囊) | 不需要点云编码器;模型可以小;碰撞检查精确 |
|
||||
| 机器人 | Franka 7 轴 | 自研 6 轴,J1/J3/J6 连续转 | 需处理连续轴展开 |
|
||||
| 碰撞模型 | SDF / 球近似 | 胶囊解析距离,可微 | 直接做 APG;安全层可认证 |
|
||||
| 平台 | 台式 GPU | 浏览器(后续真机) | 模型小,ONNX + WebGPU/wasm |
|
||||
|
||||
资源应投在**闭环稳定性、左右绕行选择和安全层**上,而不是感知和泛化。
|
||||
|
||||
### 2.3 前沿生成式技术取舍
|
||||
|
||||
| 技术 | 结论 | 理由 |
|
||||
|---|---|---|
|
||||
| VLA 大模型(π0/π0.5、GR00T、OpenVLA) | 不采用 | 无图像、无语言指令;3B 参数更慢不更准 |
|
||||
| 一步生成:MeanFlow(MP1) | **采用** | 1 次前向;沙箱双模态实验通过 |
|
||||
| Consistency Policy、OneDP、朴素 1 步流匹配 | 不采用 | CP 原文承认偏向一侧;OneDP 有意取单模态;朴素 1 步 FM 等于条件均值,会穿柱 |
|
||||
| Shortcut / 条件 OT 配对 | 不采用 | 见 §1.1 |
|
||||
| OFP 自蒸馏 | 不采用 | 未找到公开代码;自引导 λ=0.5 时出现模态塌缩 |
|
||||
| 生成过程中代价引导(MPD/EDMP/GPD) | 不采用 | 无论文在 1–2 步下验证;改用输出后投影 |
|
||||
| 推理时 RTC | 不采用 | 原文只验证 5 步去噪且需反向传播;改用训练时前缀条件化 |
|
||||
| 时间集成 / 交叉淡入 | 不采用 | 执行的是未认证的混合块;左右两块平均可能穿柱;π0 弃用 |
|
||||
| FAST 动作分词 | 不采用 | 服务于语言骨干,自回归推理慢 |
|
||||
| B 样条动作表示 | 不采用 | 见 §1.1 |
|
||||
| ReinFlow / DPPO | 不采用 | 见 §1.1 |
|
||||
|
||||
### 2.4 沙箱双模态对照实验
|
||||
|
||||
2D 点从柱左侧到右侧,示教一半从上绕一半从下绕,16 步动作块、每 3 步重推理,与本方案同构。代码 `ml/toy/toy_bimodal.py`,150 个"起终点都贴近中轴"的难例:
|
||||
|
||||
| 动作头 | 单样本、无安全层:到达 / 穿柱回合 | 8 候选 + 安全层:到达 / 穿柱 / 平均刹停 | 推理(CPU,8 候选) |
|
||||
|---|---|---|---|
|
||||
| MSE 回归 | 100% / 0% | 100% / 0% / 0 | 0.13 ms |
|
||||
| WTA K=4 | 100% / 0% | 100% / 0% / 0 | 0.19 ms |
|
||||
| 流匹配 10 步 | 100% / 4.0% | 99.3% / 0% / 3.7 | 2.13 ms |
|
||||
| 朴素流匹配 1 步 | 87.3% / **23.3%** | 81.3% / 0% / 14.0 | 0.25 ms |
|
||||
| **MeanFlow 1 步** | 100% / 2.7% | 100% / 0% / 0.3 | 0.25 ms |
|
||||
|
||||
- 朴素 1 步流匹配会把模态平均进柱子;MeanFlow 1 步达到 10 步流匹配的质量,推理快约 8 倍。
|
||||
- 安全层在所有方法上把穿柱降为 0,代价是刹停。
|
||||
- 该任务过于简单(闭环中状态一偏离中轴即打破对称),**分不出一步头与 WTA 谁更好**;因此 WTA 只作为 M5 门禁不过时的预定回退(§1.1)。
|
||||
- 训练仅 300 步的 MSE 在同一难例上 100% 穿柱、加安全层后 0% 到达,说明欠拟合时平均模态的风险真实存在。
|
||||
|
||||
---
|
||||
|
||||
## 3. 训练环境
|
||||
|
||||
| 阶段 | 在哪里跑 | 说明 |
|
||||
|---|---|---|
|
||||
| 数据生成 | Hark 沙箱(2 vCPU / 7 GB,Node)或用户机器 | 纯 JS,无需 GPU |
|
||||
| 预训练 + 后训练 | 用户 RTX 3090(可用 19–20 GB) | 预训练 batch 1024(峰值 2.76 GiB);APG batch 48 × 8000 步(峰值 13.08 GiB) |
|
||||
| 部署推理 | 浏览器 onnxruntime-web wasm-SIMD 多线程(代理 Worker);ONNX 接口 feat[1,83]、z[K,16,6]:观测编码一次、K=8 条候选共享编码再解码;后续真机控制器 | 与现有查看器同页 |
|
||||
|
||||
```bash
|
||||
conda create -n armtf python=3.11 -y && conda activate armtf
|
||||
pip install torch --index-url https://download.pytorch.org/whl/cu121 # 按本机 CUDA 版本选
|
||||
pip install numpy pyarrow tqdm tensorboard onnx onnxruntime
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 离线数据(老师只在这里出现)
|
||||
|
||||
### 4.1 文件
|
||||
|
||||
| 文件 | 作用 |
|
||||
|---|---|
|
||||
| `ml/gen/gen_dataset.cjs` | 采样任务 → 调老师 → 校验 → 时间参数化 → 写分片 |
|
||||
| `ml/gen/sampler.cjs` | 起点/目标采样 |
|
||||
| `ml/gen/run_parallel.sh` | 多进程,不同种子 |
|
||||
| `ml/data/shard_XXXX.jsonl` | 原始样本(不入库) |
|
||||
|
||||
复用 `tests/route_bench_setup.cjs` 装配求解器(关节表、`docs/travel.json`、`docs/collide.json`)。
|
||||
|
||||
### 4.2 原始样本
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "s0003_000127", "mode": "pos" | "pose",
|
||||
"q0": [6], "T": [x,y,z], "R": [[3x3]] | null, "tool": 0,
|
||||
"path": [[6], ...],
|
||||
"reached": true,
|
||||
"travel_deg": 123.4, "plan_ms": 812.0,
|
||||
"fp": "collide.json 指纹"
|
||||
}
|
||||
```
|
||||
|
||||
`reached=false` 表示目标不可达,路径终点为离目标最近的安全位形("停靠")。
|
||||
|
||||
### 4.3 采样分布(5 万条任务)
|
||||
|
||||
关节限位:J1/J3/J6 连续 ±180°;J2 [−92, 9],J4 [−111, 16],J5 [−93, 34]。
|
||||
|
||||
| 类别 | 占比 | 采样方式 |
|
||||
|---|---:|---|
|
||||
| A 均匀 | 45% | q0、目标位形 b 均匀采样且无干涉;目标 = `fk(b)`(pose 模式加 `orient(b)`) |
|
||||
| B 直线被挡 | 30% | q0→b 关节直线有干涉 |
|
||||
| C 贴立柱 | 15% | b 满足 `nearColumn`,q0 在立柱另一侧 |
|
||||
| D 目标不可达 | 10% | 目标在立柱内/所有分支都撞/工作空间外;标签为停靠 |
|
||||
|
||||
- pos / pose 各半;老师为 PRM 路网 A* + 捷径平滑(确定性;复用 `routeRT` 的路网图搜索,放宽时间预算);A* 失败的任务用 `solveDyn`(固定种子)补。
|
||||
- 另造三个互不重叠的集合:**门禁集** 2000 条(挖错循环每轮用)、**held-out 验收集** 2000 条、**near_column 扩展验收集** ≥1000 条;两个验收集只在最终验收时用一次。现有三个基准不进训练集。
|
||||
|
||||
### 4.4 过滤
|
||||
|
||||
1. 整条路径用距离下界认证(与 §8.3 同口径)复核,任一段不过即丢弃。仓库 `tests/route_bench.cjs` 上现有规划器有 1/40 例路径细验不过,这类样本必须丢掉。
|
||||
2. `poseRelaxed` 样本只当 pos 模式用。
|
||||
3. 行程 > 同任务 `routeRT` 1.5 倍的先统计,再定是否丢弃;按类别统计丢弃率(C 类 > 20% 说明老师在贴柱场景不可靠)。
|
||||
|
||||
### 4.5 时间参数化 → 闭环训练样本
|
||||
|
||||
1. 时间律:按轴速比 `VREL = [0.6, 0.6, 0.9, 0.9, 1.2, 1.8]` 匀速巡航,梯形加减速,速度只取决于"当前速度 + 剩余路程"(每轴加速度上限 a_max 为待定参数,真机型号确定后设定),dt = 20 ms。
|
||||
2. 得到状态序列 q_0 … q_N,每隔 3 步取一个样本:
|
||||
- 输入:§5.1 全部字段(含当前速度、已提交前缀)
|
||||
- 标签:未来 16 步 Δq_{t+k} = q_{t+k} − q_t(到终点后补零)
|
||||
3. 每条路径约 40–50 个样本 → 约 200 万样本(以 `stats.json` 为准)。
|
||||
4. 协变量偏移对策:关节噪声增广(30% 样本,σ = 1°;噪声态须无干涉且间隙 > 1 mm,标签为 8 步内线性收回老师轨迹的增量);偏离状态的进一步覆盖靠 §7。
|
||||
|
||||
### 4.6 耗时与存储
|
||||
|
||||
- 路网 A* 每条数十毫秒,耗时主要在过滤认证;按 `solveDyn` 0.7 s/条上限估算,沙箱 2 进程不超过 5 小时。
|
||||
- 200 万样本以 float32 `.npy` 存放约 1.5 GB(训练时整体载入内存)。
|
||||
|
||||
### 4.7 检查点
|
||||
|
||||
- [ ] `ml/data/stats.json`:类别、到达率、行程、样本数、补零样本占比
|
||||
- [ ] 老师一致性:起终点扰动 ±5 mm 的邻居任务同伦类一致率 ≥ 90%
|
||||
- [ ] 抽 20 条在查看器回放(新增 `?replay=` 参数)
|
||||
- [ ] 各集合与训练集无重叠
|
||||
|
||||
---
|
||||
|
||||
## 5. 表示与预处理
|
||||
|
||||
文件 `ml/prep/build_dataset.cjs`(Node,特征直接调用部署运行时 `webapp/js/ik-policy.js` 的同一份实现)→ `ml/data/samples/*.{X,Y,V,SG,TID}.npy` + `*.tasks.jsonl`;按任务 5% 划为验证集。PyTorch 侧特征 `ml/common/features.py` 与 JS 逐项对拍(闭环仿真与 APG 使用)。
|
||||
|
||||
### 5.1 输入
|
||||
|
||||
| 量 | 维度 | 编码 |
|
||||
|---|---:|---|
|
||||
| 当前 q_t | 6 | 每轴 (sin, cos) → 12;限位轴另加归一化值 → +3 |
|
||||
| 当前关节速度 q_t − q_{t−1} | 6 | 按每轴速度上限归一化 |
|
||||
| 当前 TCP 位置与姿态 | 3 + 6 | `fk(q_t)`、`orient(q_t)` 的 6D 表示 |
|
||||
| 目标位置 T | 3 | /600 mm |
|
||||
| 目标姿态 R | 6 | 6D 表示;pos 模式填 0 |
|
||||
| 目标 − 当前 TCP | 3 | /600 mm |
|
||||
| 模式 | 2 | one-hot [pos, pose] |
|
||||
| 已提交未执行前缀 | 3×6 + 3 | 前缀 Δq + 有效长度掩码;训练时随机遮盖 0–3 步 |
|
||||
| 当前子目标 | 6 | 子目标头输出的关节途经点(归一化);无子目标时填当前 q |
|
||||
| 关键胶囊对最小距离 | 8 | 臂各段到立柱的胶囊距离 |
|
||||
|
||||
### 5.2 输出
|
||||
|
||||
- MeanFlow 一步头:每条候选 16 步 × 6 轴 Δq(按每轴最大步长归一化)。
|
||||
- 附加头:价值头(剩余时间/代价)、子目标头(2 个关节途经点 + 是否需要)。
|
||||
|
||||
### 5.3 连续轴
|
||||
|
||||
J1/J3/J6 统一用 `unwrapTo` 展开后再做增量;预处理与部署共用一份实现,对拍时一并验证。
|
||||
|
||||
---
|
||||
|
||||
## 6. 模型与预训练
|
||||
|
||||
### 6.1 结构
|
||||
|
||||
```
|
||||
条件特征(约 90 维)──MLP──▶ 8 个条件 token (d=192)
|
||||
│
|
||||
4 层 Transformer encoder
|
||||
│ (memory)
|
||||
16 个动作 token(注入噪声 z + 时间嵌入 (r, t))
|
||||
▼
|
||||
4 层 Transformer decoder(非自回归,一次前向)
|
||||
├─ 动作输出:每 token 6 维 Δq
|
||||
├─ 价值头:1
|
||||
└─ 子目标头:2 个关节途经点 + 是否需要
|
||||
```
|
||||
|
||||
| 超参 | 值 |
|
||||
|---|---|
|
||||
| d_model / 头数 / FFN | 192 / 8 / 768 |
|
||||
| encoder / decoder 层数 | 4 / 4 |
|
||||
| 参数量 | 约 4.9M |
|
||||
|
||||
子目标触发:新目标或目标跳变 > 50 mm 时输出;到达当前子目标 < 5° 后切下一个。
|
||||
|
||||
### 6.2 动作头:MeanFlow 一步生成
|
||||
|
||||
| 项 | 做法 |
|
||||
|---|---|
|
||||
| 多模态 | 连续分布;每次推理并行采 8 个噪声 → 8 条候选 |
|
||||
| 训练 | MeanFlow 平均速度场损失(MP1 配方:25% 样本 r≠t,自适应损失加权),JVP 用 `torch.func.jvp` |
|
||||
| 推理 | 1 次批量前向(r=0, t=1) |
|
||||
| 后处理 | 1 步解析胶囊距离梯度投影,每轴修正 ≤ 0.5°,仅 d < 余量时(JS 实现,不走 ONNX) |
|
||||
| APG 兼容 | 固定噪声后为确定性可微映射 |
|
||||
| 预定回退 | M5 门禁不过(near_column 穿柱率 > 2% 或训练发散)→ 同骨干换 WTA K=8 头(WTA-L1 + 模式 CE),约 1 天 |
|
||||
|
||||
### 6.3 损失
|
||||
|
||||
```
|
||||
L = MeanFlow 损失 1.0
|
||||
+ FK 损失:一步输出(固定噪声)的各连杆胶囊端点位置差 0.5
|
||||
+ 平滑:二阶差分 0.05
|
||||
+ 价值头 L1 + 子目标头 L1 0.1 / 0.1
|
||||
```
|
||||
|
||||
### 6.4 训练配置
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 优化器 | AdamW,β=(0.9, 0.95),wd 0.01 |
|
||||
| 学习率 | 1e-3,预热 1000 步,余弦退火 |
|
||||
| batch | 1024 |
|
||||
| 精度 | bf16 混合精度 |
|
||||
| 步数 | 先跑 2000 步测吞吐再定;参考 200 万样本 × 30 epoch ÷ 1024 ≈ 6 万步;验证集闭环到达率不再上升即停 |
|
||||
| 预计耗时 | RTX 3090 约 1.5–5.6 小时(按 2000 步吞吐实测重算) |
|
||||
|
||||
### 6.5 对拍与验证
|
||||
|
||||
- `ml/eval/collide_torch.py`:可微胶囊距离 PyTorch 批量版,与 JS `collideFree` 对拍 10 万位形:布尔判定 0 差异,距离误差 < 1e-4 mm,梯度与有限差分相对误差 < 1e-3。
|
||||
- 每 epoch 闭环验证:从 1000 个验证任务出发,按部署节奏(每 60 ms 推理、提交 3 步)运行到到达或超时(老师路径时长 × 2,最少 3 s),统计到达率、用时 ≤ 老师 × 1.3 的达标率、碰撞步数(关安全层)、行程比、左/右/穿柱三类比例。
|
||||
|
||||
---
|
||||
|
||||
## 7. 后训练
|
||||
|
||||
### 7.1 主力:APG 可微微调
|
||||
|
||||
```
|
||||
for 每个 batch(随机采样 q0 和目标,不需要老师):
|
||||
q = q0
|
||||
for t in 0..H-1 (H = 本批老师时长 p90 × 1.2,60–150 次推理 × 3 步 ≈ 3.6–9 s;无老师题按 6 s;每 10 次推理截断反传):
|
||||
chunk = π(obs(q, 目标, 前缀), z) # 每条展开固定一个噪声 z
|
||||
Δq = chunk[0:3] # 与部署一致
|
||||
q = clamp(q + Δq)
|
||||
累计 L_col(q), L_smooth(Δq)
|
||||
L = w_tgt·Σ_段末 L_target(q) + w_col·ΣL_col + w_smooth·ΣL_smooth (+ w_len·行程) + w_bc·L_BC
|
||||
反传,梯度裁剪 ≤ 1.0,lr 1e-4
|
||||
```
|
||||
|
||||
- 碰撞项 L_col = Σ ReLU(margin − 胶囊距离),margin = 训练余量 3 mm(间隙已扣除 `margin_mm`);每步端点与中点,两处取均值(与只查端点同量纲);含臂×立柱、臂×台架、臂×臂(非相邻段)。
|
||||
- 到达项:每个截断段末的位置误差² + λ_rot·‖R − R*‖_F²;不做到达即停,段末项在整条展开上持续给出接近梯度。
|
||||
- 平滑项:速度² + 加速度²。
|
||||
- BC 锚定项:保留预训练损失,防止多模态被压缩;每轮监控左右比例。
|
||||
- 采样分布同 §4.3,B/C/D 类提到 60%,其中贴柱类 40%。
|
||||
|
||||
### 7.2 辅助:无老师挖错循环
|
||||
|
||||
每轮:
|
||||
1. 出题:新种子 5 万任务 + 2000 段模型在环拖拽仿真。
|
||||
2. 闭环作答:完全按部署方式运行,**关闭安全层**以暴露真实错误。
|
||||
3. 验算:GPU 批量胶囊检查。
|
||||
4. 错题分类:E1 撞 / E2 没到(卡在局部极小)/ E3 绕远(> 1.2× 参考)/ E4 末端精度差 / E5 摇摆(≥ 3 次切换)/ E6 拖拽失稳。
|
||||
5. 错题及其 ±5 mm / ±3° 的 10 个邻居加入 APG 采样池(E1、E6 ×2);仅 E2 可离线请老师补少量路径。
|
||||
6. 门禁(用门禁集,不用验收集):到达率上升、A 类每轮降幅 ≤ 0.5 个百分点、上一轮修好的错题 95% 仍对;否则回滚。
|
||||
7. 停止:连续 2 轮提升 < 0.5 个百分点,或满 6 轮。
|
||||
|
||||
---
|
||||
|
||||
## 8. 部署
|
||||
|
||||
### 8.1 执行循环
|
||||
|
||||
```
|
||||
执行:50 Hz,每步 20 ms,按已提交步走
|
||||
每 60 ms:
|
||||
1. 读当前 q、当前速度、已提交未执行前缀、最新目标
|
||||
2. 推理:8 个噪声 → 8 条 16 步动作块(冷启动或卡死换向时,再各推一块做短前瞻)
|
||||
3. 每条候选做 1 步胶囊距离梯度投影(每轴 ≤ 0.5°,仅 d < 余量时)
|
||||
4. 排序(§8.2)
|
||||
5. 安全层依次认证候选前 3 步 + 刹停段(§8.3),第一个通过的提交给执行器
|
||||
6. 全部不通过 → 沿上一次已认证的刹停段停下,下次继续推理
|
||||
7. 到达:TCP 误差 < 1 mm(pose 另需 < 0.5°)且速度≈0;
|
||||
接近目标(< 5 mm)时做 1–3 次局部牛顿精修(solveTo 单步),精修段同样过安全层认证
|
||||
```
|
||||
|
||||
执行的始终是安全层认证过的那一块,不做新旧块混合。执行时钟放在 Web Worker、按实际 dt 推进,避免 `requestAnimationFrame`(60 Hz)与 20 ms 不整除及后台节流造成的抖动。
|
||||
|
||||
### 8.2 候选排序(防左右摇摆)
|
||||
|
||||
分数 = −Σ w_k‖候选第 k 步 − 已提交块第 k 步‖²(重叠步加权)− λ·价值头剩余代价。冷启动时无已提交块,只用价值头 + 短前瞻打分。
|
||||
|
||||
### 8.3 安全层(唯一的硬保证)
|
||||
|
||||
- **距离下界认证**:对一段运动 q_a → q_b,若两端到障碍的最小距离之和 d(q_a) + d(q_b) > Σ_j r_j·|Δθ_j|,则整段安全;否则二分,直到全部子段通过或判不安全。按组对分别判定:组对 (g_A, g_B) 的间隙只受关节 g_A…g_B−1 带动组 g_B 影响,右端取 Σ_{k=g_A}^{g_B−1} T[k][g_B]·|Δθ_k|,T 为分组杠杆表(组 g_B 胶囊端点到轴 k 的最大距离,随机 4000 位形采样再放大 5%;末端组对 J1…J6 分别为 9.56 / 7.37 / 4.47 / 4.48 / 2.47 / 0.69 mm/°)。间隙取全部 |Δg| ≥ 2 胶囊对的 距离 − (r_i + r_j + margin)。外加关节限位。先用离散 `collideFree` 剔除明显干涉的候选;运行时每条候选最多 40 次距离查询;墙钟截止下沉到认证内部:第 1 条候选截止 45 ms,其余候选共享到 30 ms,到点即判不过、沿已认证刹停段停。安全层耗时在几何查询而不在模型(沙箱 Node 每次 gapMatrix 3.6–5.5 ms、collideFree 1.5 ms,推理 1.5 ms)。
|
||||
- 离散采样检查(现有 `pathFine`,1.5° + 半格)只作快速预筛,不作保证依据。
|
||||
- **可刹停不变式**:只有当"候选前 3 步 + 从第 3 步末按每轴 a_max 减速到停"整段都通过认证时才提交。任何时候系统都持有一条已认证的停车路线,下一次推理即使完全失效也只会停下。
|
||||
- 耗时:每段一次距离查询覆盖整段,不通过才二分(通常 2–6 次);按排序逐个认证,第一个通过即停;包围球预筛先行。目标每次 < 5 ms,否则放 Web Worker 与推理流水线并行。
|
||||
- `ik-glue.js` 的每帧碰撞守卫保留为第二道保险。
|
||||
|
||||
### 8.4 部署包
|
||||
|
||||
| 有 | 没有 |
|
||||
|---|---|
|
||||
| `traj_policy.onnx`(fp32,约 20 MB;wasm / CPU 后端上 fp16 不保证更快)+ `traj_policy.meta.json`(指纹 / 特征版本 / TCP) | PRM 路网、IK 候选大表 |
|
||||
| 胶囊距离与认证(`collideFree`、包围球预筛、`pair2d`) | `solveDyn` / `routeRT` / RRT / A* |
|
||||
| `fk` / `orient`、局部牛顿精修 `solveTo` 单步 | 全量 IK 枚举 |
|
||||
|
||||
`collide.json` 指纹写进模型;不一致时前端拒绝运行并提示重训。
|
||||
|
||||
### 8.5 near_column 30/30 的保障手段
|
||||
|
||||
"超时"指机械臂在规定时间内没走到目标,是决策问题而非计算速度问题。典型失效:局部极小/活锁(每次都想直穿、每次被安全层挡住)、左右摇摆、绕远。对策(运行时均不需要老师):
|
||||
|
||||
1. **子目标头**:给出 1–2 个关节途经点作为绕柱方向锚点,标签取老师路径拐点(离线)。
|
||||
2. **价值头**:预测剩余时间/代价,用于候选打分与进展判断;APG 中用 TD 自举监督。
|
||||
3. **短前瞻**(仅冷启动和卡死换向时):对每条候选再推一块,用胶囊认证 + 价值头给 32 步后终态打分。
|
||||
4. **卡死检测 + 强制换向**:0.3 s 内价值不降或连续 2 次刹停 → 切到另一绕行方向并锁定 1 s。
|
||||
5. **训练侧加强**:贴柱类占 APG 采样 40%;展开覆盖整段绕行。
|
||||
|
||||
### 8.6 控制周期
|
||||
|
||||
伺服层(参考):Franka FCI 1 ms;UR e-Series 2 ms、CB3 8 ms;ABB EGM 4 ms(滞后 10–20 ms);KUKA RSI 4/12 ms;FANUC DPM 约 8 ms;MoveIt Servo 默认 10 ms;RC 舵机 20 ms。
|
||||
|
||||
策略层(参考):MπNets 9 Hz;Diffusion Policy 10 Hz、预测 16 执行 8;ELMP 30 Hz;π0 50 Hz、约每 0.5 s 推理一次;DRP 300 Hz。业界均为"策略层 10–50 Hz + 伺服层 125 Hz–1 kHz"两层结构。
|
||||
|
||||
本方案:执行 50 Hz、每 60 ms 推理一次、提交 3 步。真机上提交步数 n ≥ ⌈(通信滞后 + 一次推理最坏耗时) / 20 ms⌉ + 1,刹停段另行认证;策略输出 50 Hz 关节目标,由插补器升采样到电机控制器频率。`dt`、n、动作块长度均可配置。
|
||||
|
||||
---
|
||||
|
||||
## 9. 评估与验收
|
||||
|
||||
### 9.1 测试集合
|
||||
|
||||
| 集合 | 来源 | 用途 |
|
||||
|---|---|---|
|
||||
| route_bench | `tests/route_bench.cjs`(40 例)+ 60 例随机基准 | 验收 |
|
||||
| near_column_bench | `tests/near_column_bench.cjs`,30 例(pos / pose / rt) | 验收 |
|
||||
| near_column 扩展 | §4.3 新造 ≥1000 例 | 一次性验收 |
|
||||
| rt_drag_bench | `tests/rt_drag_bench.cjs`,12 例(模型在环) | 验收 |
|
||||
| held-out | §4.3 2000 条 | 一次性验收 |
|
||||
| 门禁集 | §4.3 2000 条 | 训练中门禁,不参与验收 |
|
||||
|
||||
新增 `tests/policy_bench.cjs`:①现有规划器 ②策略(关安全层)③策略 + 安全层,输出对比表。
|
||||
|
||||
### 9.2 验收标准(部署形态 = 策略 + 安全层)
|
||||
|
||||
| 指标 | 要求 | 现有规划器 |
|
||||
|---|---|---|
|
||||
| 实际执行的碰撞 | **= 0**(距离认证或 0.01° 审计测量) | rt_drag_bench 0 |
|
||||
| 到达率 | route 全部可达例到达;near pos **30/30**、near rt **30/30**;near_column 扩展集 **0 失败**;held-out 可达任务 ≥ 98%,D 类停靠距离 ≤ 老师 + 5 mm | `route_bench` 40 例:到达 40/40、细验有效 39/40;60 例随机基准有效 60/60;near 30/30、30/30 |
|
||||
| 到达用时 | ≤ 老师路径时长 × 1.3 | — |
|
||||
| 关节行程 | ≤ 现有规划器 × 1.10(按类别分别报告) | 60 例平均 467° |
|
||||
| 冷启动 | < 10 ms | 首次全量 IK 最差约 2 s |
|
||||
| 每次推理 + 安全认证 P99 | < 20 ms | 拖拽重规划 P99 约 321 ms |
|
||||
| 刹停发生率 | < 2% 的推理周期 | — |
|
||||
| 模式摇摆(≥3 次切换) | < 1% 的任务 | — |
|
||||
| 策略单独(关安全层)碰撞率 | < 3% | — |
|
||||
|
||||
说明:30 例全对时,失败率的 95% 置信上界约 9.5%;扩展集 1000 例 0 失败时约 0.3%,因此以扩展集作为 30/30 的统计支撑。
|
||||
|
||||
口径:本表全部按部署形态(`tests/policy_bench.cjs`,与浏览器同一份运行时)或 held-out 闭环仿真(开仿真安全层)测量,由 `ml/eval/acceptance.py` 汇总;M5 门禁与挖错用的是关安全层的纯策略口径,二者互不替代。挖错题与门禁集没有老师参考,到达按定长 6 s 判,「到达用时」「关节行程」两项只在有老师参考的任务上统计。
|
||||
|
||||
---
|
||||
|
||||
## 10. 当前存在的问题与风险
|
||||
|
||||
### 10.1 只能靠实测消除的风险(按里程碑顺序)
|
||||
|
||||
| # | 风险 | 门禁(不过不进下一步) | 回退 | 何时测 |
|
||||
|---|---|---|---|---|
|
||||
| R1 | 老师在相邻任务上左右绕行跳变,标签自相矛盾 | 起终点扰动 ±5 mm 的邻居任务同伦类一致率 ≥ 90% | 对 A* 边代价加左右偏好项,强制同区域选同侧 | M1 |
|
||||
| R2 | 时间律仍有不可观测成分,闭环起不了步 | 近邻样本标签方差 < 10% 步长(`ml/eval/check_r2.py`);起步失败率 = 0(闭环 `start_fail`) | 进一步把剩余路程估计加入输入 | M1 / M5 |
|
||||
| R3 | 距离认证过于保守,贴柱处拒绝率高,影响 30/30 | near_column 上认证拒绝率统计;离线 10⁵ 段 0.01° 审计漏检 = 0 | 训练余量提高到 4–6 mm,接受行程变长;收紧杠杆界(按位形分区) | M2 / M7 |
|
||||
| R4 | 浏览器纯推理延迟 | 训练前用随机初始化模型导出 ONNX(延迟与权重无关),`pipeline.sh r4` 自动测部署口径 K=8 P99 < 15 ms。2026-10-18 本机:编码一次 + 16 线程 + 代理 Worker 后 P99 10.2–11.3 ms,**已通过**(改前 37.0 ms) | WebGPU 达标即用 WebGPU,否则 d=128(`configs/small.json`)。K=8 不降 | **M1 前(已结)** |
|
||||
| R5 | MeanFlow 头在 6 轴、位姿目标下穿柱或训练不稳 | near_column 穿柱率(关安全层)≤ 2%;损失不发散 | 同骨干换 WTA K=8 头 | M5 |
|
||||
| R6 | 位姿目标的多 IK 分支带来额外多峰 | 不同肘/腕分支子集到达率 ≥ 主集 − 2 个百分点 | 加目标分支标志 | M5 |
|
||||
| R7 | APG 压缩多模态或拉长路径(ELMP 中路径 +23%) | 每轮门禁:B/C 类左右绕行比例变化 ≤ 5 个百分点、行程中位数比 ≤ 1.05(`ml/mining/gate.py`) | 加大 BC 锚定权重;行程项 | M6 |
|
||||
| R8 | 卡死换向活锁 | 50 个"两侧都要绕远"对抗用例(J1 起终差 ≥ 140°、终点贴立柱,部署运行时)首次换向锁后 3 s 内到达 ≥ 95%,活锁 = 0(`tests/policy_bench.cjs` r8) | 子目标改为离散绕行锚点分类 | M6 / M7 |
|
||||
| R9 | 整周期超标(推理 + 前瞻 + 安全层 > 60 ms 周期,执行时钟饥饿丢步) | 训练好的模型在**真查看器**里跑与 bench 同一批 30 道贴柱题:稳态规划 P99 < 45 ms、运动中饥饿节拍 < 1%、认证超时占比 < 5%(`r4_probe --viewer` → `r9_browser.json`;冷启动 P99、刹停率、tickGapMax 只报);同题安全层浏览器 / Node 差 ≤ 3× | 几何查询(features、投影、认证)移到 Worker,主线程只执行;前瞻只在冷启动时做 | M7 |
|
||||
| R10 | 训练耗时估计偏差 | 60000 步 ≤ 3.5 h,即 2000 步测速 ≥ 5000 样本/s(3090 实测 6.2 k/s ≈ 2.8 h) | FK 损失胶囊数 96 → 48;仍不够则步数降到 40000(MeanFlow JVP 受算力限制,增大 batch 无效) | M5 |
|
||||
|
||||
### 10.2 已知缺陷
|
||||
|
||||
1. 现有规划器(老师)在 `tests/route_bench.cjs` 上有 1/40 例到达但路径细验不过;§4.4 的认证过滤会丢弃这类样本。
|
||||
2. `ci27` 姿态根枚举漏分支(姿态偏差约 97.5°)会影响 pose 数据;APG 姿态项可部分纠正,必要时先修老师。
|
||||
3. 胶囊模型或立柱变动即需重训:数据(用户机器 16 核约 2 h)+ 预训练 1.5–6 h + 后训练数小时。
|
||||
4. 老师一致性沙箱首测 78%(100 任务、373 对邻居,门禁 90%):不一致主要来自 J6 冗余与时间预算造成的非确定性。已改为确定性老师(预算按碰撞判定次数计、每条前清空跨调用缓存)并在 pos 任务冻结 J6,待在用户机器复测;仍不达标则生成时启用 4 邻居共识过滤。
|
||||
5. 安全层耗时:未训练模型(候选为噪声)在 Node 单线程实测每次推理安全层约 20–60 ms,高于 5 ms 目标;训练后候选平滑会下降,以 `policy_bench` 与浏览器 `__POLICY.latency()` 实测为准,超标则把安全层移入 Web Worker 与推理流水线并行。
|
||||
|
||||
### 10.3 需要用户确认
|
||||
|
||||
1. **真机电机/驱动型号与通信方式**:决定每轴加速度上限 a_max、插补频率与提交步数。
|
||||
2. **运行时多候选打分是否算"运行时老师"**:本方案的理解是不算(只对模型自己的候选做距离认证和打分,不做搜索)。
|
||||
|
||||
---
|
||||
|
||||
## 11. 目录规划
|
||||
|
||||
运行步骤、命令与耗时见 `ml/README.md`。所有长步骤都能断点续跑,意外中断最多损失几分钟:生成按条续写(每条任务的随机数只由种子和序号决定),打包按 shard 跳过,预训练与 APG 每 10 分钟原子存一次 `last.pt`,停止时先存档再退出。
|
||||
|
||||
```
|
||||
ml/
|
||||
pipeline.sh 阶段脚本:setup / kin / gate1 / gen / prep / train / m5 / apg / mine / export / bench
|
||||
gen/ sampler / teacher(确定性 PRM A*)/ timelaw / gen_dataset / consistency / stats / run_parallel.sh
|
||||
prep/ export_kin.cjs → assets/kin.json;build_dataset.cjs → data/samples/*.npy;run_prep.sh
|
||||
data/ (不入库)shard_*.jsonl stats.json consistency.json samples/
|
||||
common/ kin_torch.py(可微运动学 + 胶囊间隙) features.py(83 维特征) ckpt.py(原子存档、定时存档、体面停止)
|
||||
model/ policy.py(MeanFlow 头;WTA K=8 回退头) meanflow.py
|
||||
data.py train.py 样本加载、预训练
|
||||
sim/closed_loop.py 闭环仿真(验证、挖错、门禁共用)
|
||||
apg/apg_finetune.py APG 可微微调
|
||||
mining/ tasks.py mine_round.py gate.py loop.sh
|
||||
eval/ dump_parity.cjs check_parity.py(JS ↔ PyTorch 对拍)
|
||||
configs/ base.json wta.json small.json apg.json
|
||||
export_onnx.py serve.py README.md
|
||||
webapp/js/ik-policy.js 运行时:特征、距离下界认证、刹停、投影、精修、Runner(浏览器与 Node 共用)
|
||||
webapp/js/ik-policy-glue.js 查看器接入(?policy=1)
|
||||
webapp/models/ traj_policy.onnx traj_policy.meta.json
|
||||
tests/policy_bench.cjs 部署形态评测
|
||||
```
|
||||
|
||||
## 12. 里程碑
|
||||
|
||||
| # | 内容 | 产出 | 在哪跑 |
|
||||
|---|---|---|---|
|
||||
| M1 | 数据生成器 + 时间律 + 1000 条试跑;老师一致性门禁 | `ml/gen/*`、stats.json | 沙箱 |
|
||||
| M2 | 可微胶囊距离(PyTorch)与 JS 对拍;距离认证实现与 10⁵ 段审计 | `collide_torch.py`、认证模块 | 沙箱 |
|
||||
| M3 | 5 万条老师数据 → 约 180 万样本 | `samples/*.npy` | 用户机器 |
|
||||
| M4 | 模型 + 训练工程 + 闭环仿真;小数据过拟合;**导出 ONNX 测浏览器延迟** | `train.py`、`closed_loop.py`、延迟报告 | 沙箱 + 浏览器 |
|
||||
| M5 | 预训练 + MeanFlow 头门禁 | checkpoint、门禁报告 | 用户 GPU |
|
||||
| M6 | APG 微调 + 挖错循环 3–6 轮 | 每轮 report.md | 用户 GPU |
|
||||
| M7 | `ik-policy.js` 闭环部署 + 安全层实测 + `policy_bench` + 一次性验收 | 对比表 | 沙箱 + 浏览器 |
|
||||
| M8 | 移除查看器对 `solveDyn`/`routeRT`/路网的运行时依赖(保留在 `ml/gen` 离线使用) | 部署包瘦身 | 沙箱 |
|
||||
|
||||
## 13. 参考文献
|
||||
|
||||
- MπNets:https://arxiv.org/abs/2210.12209
|
||||
- Neural MP:https://arxiv.org/abs/2409.05864
|
||||
- Deep Reactive Policy / IMPACT:https://arxiv.org/abs/2509.06953
|
||||
- ELMP:https://arxiv.org/abs/2607.00215
|
||||
- Diffusion Policy:https://arxiv.org/abs/2303.04137
|
||||
- π0:https://arxiv.org/abs/2410.24164 ;π0.5:https://arxiv.org/abs/2504.16054
|
||||
- FAST:https://arxiv.org/abs/2501.09747
|
||||
- MP1 / MeanFlow:https://arxiv.org/abs/2507.10543
|
||||
- One-Step Flow Policy:https://arxiv.org/abs/2603.12480
|
||||
- 条件 OT 流策略:https://proceedings.mlr.press/v305/sochopoulos25a.html
|
||||
- Kinematic MeanFlow:https://arxiv.org/abs/2610.00864
|
||||
- Consistency Policy:https://arxiv.org/abs/2405.07503 ;OneDP:https://arxiv.org/abs/2410.21257
|
||||
- MPD:https://arxiv.org/abs/2308.01557 ;EDMP:https://arxiv.org/abs/2309.11414 ;GPD:https://arxiv.org/abs/2501.18229 ;DiffusionSeeder:https://arxiv.org/abs/2410.16727
|
||||
- Real-Time Chunking:https://arxiv.org/abs/2506.07339 ;训练时动作条件化:https://arxiv.org/abs/2512.05964
|
||||
- Bidirectional Decoding(BID):https://arxiv.org/abs/2408.17355
|
||||
- DPPO:https://arxiv.org/abs/2409.00588 ;ReinFlow:https://arxiv.org/abs/2505.22094
|
||||
- Schwarzer, Saha, Latombe, *Adaptive Dynamic Collision Checking for Single and Multiple Articulated Robots in Complex Environments*, IEEE T-RO 2005
|
||||
- Thumm & Althoff, *Provably Safe Deep Reinforcement Learning for Robotic Manipulation in Human Environments*, ICRA 2022
|
||||
- Zhou et al., *On the Continuity of Rotation Representations in Neural Networks*, CVPR 2019
|
||||
- AWE(航点提取):https://arxiv.org/abs/2307.14326 ;SHAC:https://arxiv.org/abs/2204.07137
|
||||
- 控制周期:Franka FCI https://frankarobotics.github.io/docs/overview.html ;UR RTDE https://www.universal-robots.com/articles/ur/interface-communication/remote-control-via-tcpip ;ABB EGM 应用手册 3HAC073318;KUKA RSI https://docs.quanser.com/quarc/documentation/kuka_rsi_block.html
|
||||
@@ -1,42 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1150" height="760" viewBox="0 0 1150 760">
|
||||
<rect width="1150" height="760" fill="#15181f"/>
|
||||
<rect x="400.0" y="154.0" width="396.0" height="396.0" fill="#1d4ed8" stroke="#9dc4ff" stroke-width="2.0"/>
|
||||
<rect x="466.0" y="220.0" width="264.0" height="264.0" fill="#12161d" stroke="#6f86b8" stroke-width="1.4"/>
|
||||
<rect x="488.8" y="484.0" width="26.4" height="66.0" fill="#243043" stroke="#ffd166" stroke-width="1.4"/>
|
||||
<rect x="488.8" y="154.0" width="26.4" height="66.0" fill="#243043" stroke="#96dcff" stroke-width="1.4"/>
|
||||
<rect x="484.0" y="460.0" width="36.0" height="24.0" fill="#7d8794" stroke="#c3cad6" stroke-width="1.2"/>
|
||||
<rect x="484.0" y="220.0" width="36.0" height="24.0" fill="#7d8794" stroke="#c3cad6" stroke-width="1.2"/>
|
||||
<rect x="488.8" y="400.0" width="26.4" height="150.0" fill="#c3cad6" stroke="#ffffff" stroke-width="1.0"/>
|
||||
<rect x="488.8" y="154.0" width="26.4" height="150.0" fill="#c3cad6" stroke="#ffffff" stroke-width="1.0"/>
|
||||
<rect x="481.0" y="550.0" width="42.0" height="24.0" fill="#c3cad6" stroke="#ffffff" stroke-width="1.2"/>
|
||||
<rect x="481.0" y="130.0" width="42.0" height="24.0" fill="#c3cad6" stroke="#ffffff" stroke-width="1.2"/>
|
||||
<rect x="716.8" y="484.0" width="26.4" height="66.0" fill="#243043" stroke="#ffd166" stroke-width="1.4"/>
|
||||
<rect x="716.8" y="154.0" width="26.4" height="66.0" fill="#243043" stroke="#96dcff" stroke-width="1.4"/>
|
||||
<rect x="712.0" y="460.0" width="36.0" height="24.0" fill="#7d8794" stroke="#c3cad6" stroke-width="1.2"/>
|
||||
<rect x="712.0" y="220.0" width="36.0" height="24.0" fill="#7d8794" stroke="#c3cad6" stroke-width="1.2"/>
|
||||
<rect x="716.8" y="400.0" width="26.4" height="150.0" fill="#c3cad6" stroke="#ffffff" stroke-width="1.0"/>
|
||||
<rect x="716.8" y="154.0" width="26.4" height="150.0" fill="#c3cad6" stroke="#ffffff" stroke-width="1.0"/>
|
||||
<rect x="709.0" y="550.0" width="42.0" height="24.0" fill="#c3cad6" stroke="#ffffff" stroke-width="1.2"/>
|
||||
<rect x="709.0" y="130.0" width="42.0" height="24.0" fill="#c3cad6" stroke="#ffffff" stroke-width="1.2"/>
|
||||
<text x="598.0" y="360.0" font-size="22" fill="#5f7396" text-anchor="middle" font-weight="700" font-family="Consolas,Microsoft YaHei,monospace">内腔 44x44</text>
|
||||
<text x="598.0" y="558.0" font-size="17" fill="#ffd166" text-anchor="middle" font-weight="700" font-family="Consolas,Microsoft YaHei,monospace">前 -Y y=95</text>
|
||||
<text x="598.0" y="158.0" font-size="17" fill="#96dcff" text-anchor="middle" font-weight="700" font-family="Consolas,Microsoft YaHei,monospace">后 +Y y=161</text>
|
||||
<path d="M481.0,562.0 L150.0,562.0" stroke="#8fa4c8" stroke-width="1.2"/>
|
||||
<text x="10.0" y="554.0" font-size="16" fill="#d9e4f7" text-anchor="start" font-weight="700" font-family="Consolas,Microsoft YaHei,monospace">M4x25 前面一颗</text>
|
||||
<text x="10.0" y="574.0" font-size="13" fill="#8fa4c8" text-anchor="start" font-weight="400" font-family="Consolas,Microsoft YaHei,monospace">头 y=91..95 外露 4mm · 杆到 y=120</text>
|
||||
<path d="M484.0,472.0 L150.0,472.0" stroke="#8fa4c8" stroke-width="1.2"/>
|
||||
<text x="10.0" y="464.0" font-size="16" fill="#d9e4f7" text-anchor="start" font-weight="700" font-family="Consolas,Microsoft YaHei,monospace">前螺母贴前壁内侧</text>
|
||||
<text x="10.0" y="484.0" font-size="13" fill="#8fa4c8" text-anchor="start" font-weight="400" font-family="Consolas,Microsoft YaHei,monospace">y=106..110 · 压紧前侧 10.5mm 搭接壁</text>
|
||||
<path d="M515.2,352.0 L150.0,352.0" stroke="#8fa4c8" stroke-width="1.2"/>
|
||||
<text x="10.0" y="344.0" font-size="16" fill="#96dcff" text-anchor="start" font-weight="700" font-family="Consolas,Microsoft YaHei,monospace">两端杆头净空 16.2</text>
|
||||
<text x="10.0" y="364.0" font-size="13" fill="#8fa4c8" text-anchor="start" font-weight="400" font-family="Consolas,Microsoft YaHei,monospace">前 120 / 后 136 · 互不碰头</text>
|
||||
<path d="M484.0,232.0 L150.0,232.0" stroke="#8fa4c8" stroke-width="1.2"/>
|
||||
<text x="10.0" y="224.0" font-size="16" fill="#d9e4f7" text-anchor="start" font-weight="700" font-family="Consolas,Microsoft YaHei,monospace">后螺母贴后壁内侧</text>
|
||||
<text x="10.0" y="244.0" font-size="13" fill="#8fa4c8" text-anchor="start" font-weight="400" font-family="Consolas,Microsoft YaHei,monospace">y=146..150 · 压紧后侧 10.5mm 搭接壁</text>
|
||||
<path d="M481.0,142.0 L150.0,142.0" stroke="#8fa4c8" stroke-width="1.2"/>
|
||||
<text x="10.0" y="134.0" font-size="16" fill="#d9e4f7" text-anchor="start" font-weight="700" font-family="Consolas,Microsoft YaHei,monospace">M4x25 背面一颗</text>
|
||||
<text x="10.0" y="154.0" font-size="13" fill="#8fa4c8" text-anchor="start" font-weight="400" font-family="Consolas,Microsoft YaHei,monospace">头 y=161..165 · 杆到 y=136</text>
|
||||
<text x="12.0" y="30.0" font-size="21" fill="#e8eefc" text-anchor="start" font-weight="700" font-family="Consolas,Microsoft YaHei,monospace">P01 立柱分段缝 · 两侧对锁(M4x25 前后各一颗,共 8 颗)· 俯视剖面</text>
|
||||
<text x="12.0" y="52.0" font-size="14" fill="#9fb4d8" text-anchor="start" font-weight="400" font-family="Consolas,Microsoft YaHei,monospace">孔位 x=-131/-93 与 Ø4.4 通孔均未改动,与已打印的 A/B 段完全一致;偏心差 6mm 仍作防反装防呆</text>
|
||||
<text x="1138.0" y="746.0" font-size="13" fill="#6f86b8" text-anchor="end" font-weight="400" font-family="Consolas,Microsoft YaHei,monospace">单位 mm · 6px/mm · 数据取自 web/mesh_arm.json 的 S05 实例</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 275 KiB |
|
Before Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 206 KiB |