按关节槽位驱动 13 个五轴案例的三维模型

关节契约扩为槽位制:以 LinuxCNC 联合号标识关节,轴字母降为槽位属性,
重复轴(bridge-mill 的两个 Y)、并联杆长(hexapod/pentapod)和被动槽位
(max5 第 6 关节)都能表达。v1 规则保持不变并合成 jointOrder,已保存
GLB 与既有测试不受影响。

13 个结构 GLB 由 scripts/generate_physical_case_glbs.mjs 统一重建,
可动关节均为按槽位命名的 Group 并写 userData.linuxcncJoint,共 71 个
关节,槽位数与运行目录的 joint_count 逐条一致。

新增 machine-visual-bindings.json 与纯函数模块,以 machine id 为键:
坐标字母不能作键,因为三个案例共用 XYZAB、三个共用 XYZABC。主界面切换
案例时自动装载对应模型,失败时保留内置模型并显式提示。

消除前端五关节截断:visual 适配器的 joints 改为数组并新增
jointsAuthoritative;allHomed 按 joint_count 判断;面板按 worker 断言的
machine id 查找机型。

管理器解锁:坐标与案例下拉以"存在完整视觉绑定"为启用条件,13 案例全部
可选;machine-urdf.ts 新增 generic-wasm/1 profile,AC/BC 严格校验路径
不变;新增 machine-kinematics-specs.json 保存逐关节限位,INI 未声明的
限位保持 null 而不编造。

修复三个真实缺陷:构建漏发 woodpecker/gmoccapy/qtdragon 配置目录(
DEFECT-02 根因);案例切换沿用"装载需要已上电"的门控,导致关机状态下
第二次切换被静默拒绝;重复轴机型上 React 列表键冲突。

验收:13 案例逐个切换装载成功、槽位键与绑定定义逐一一致、浏览器错误 0;
typecheck 与生产构建通过;视觉绑定、GLB 命名、关节契约、投影、store、
双画布、运动学规格漂移、11 案例 Worker 闭环、管理器浏览器回归全部通过。
证据在 coverage/glb-machine-manager/physical-drive/。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-30 06:09:12 +08:00
parent af65b69b2c
commit db29ae625f
157 changed files with 11936 additions and 885 deletions

View File

@@ -0,0 +1,165 @@
"""Generate detailed Blender GLB design references for the 13 LinuxCNC cases.
The meshes are procedural design references. Dimensions are plausible layout
values in metres and are carried as editable GLB geometry, while kinematics and
calibration remain the responsibility of the machine manager metadata.
"""
import bpy, json, math, os, hashlib, sys
from mathutils import Vector
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
OUT = os.path.join(ROOT, "web", "react", "public", "machine-cases", "physical")
os.makedirs(OUT, exist_ok=True)
COLORS = {
"cast": (0.16, 0.22, 0.25, 1), "paint": (0.08, 0.35, 0.42, 1),
"light": (0.48, 0.58, 0.61, 1), "steel": (0.20, 0.27, 0.30, 1),
"rail": (0.68, 0.76, 0.78, 1), "black": (0.015, 0.022, 0.025, 1),
"yellow": (0.86, 0.48, 0.08, 1), "orange": (0.75, 0.23, 0.05, 1),
"glass": (0.03, 0.18, 0.21, 1), "rubber": (0.02, 0.025, 0.027, 1),
"joint": (0.78, 0.28, 0.05, 1),
}
MATS = {}
def mat(name, metallic=0.35, rough=0.32):
if name in MATS: return MATS[name]
m = bpy.data.materials.new("M_" + name)
m.diffuse_color = COLORS[name]
m.use_nodes = True
bsdf = m.node_tree.nodes.get("Principled BSDF")
bsdf.inputs["Base Color"].default_value = COLORS[name]
bsdf.inputs["Metallic"].default_value = metallic
bsdf.inputs["Roughness"].default_value = rough
MATS[name] = m
return m
def collection(name):
c = bpy.data.collections.new(name); bpy.context.scene.collection.children.link(c); return c
def link(obj, parent):
for c in list(obj.users_collection): c.objects.unlink(obj)
parent.objects.link(obj); return obj
def group(parent, name, position=(0, 0, 0)):
g = bpy.data.objects.new(name, None)
if hasattr(parent, "objects"):
parent.objects.link(g)
else:
g.parent = parent
g.location = position
return g
def root_object(scene):
root = bpy.data.objects.new("machine_root", None)
scene.collection.objects.link(root)
return root
def box(name, size, loc, material="steel", parent=None, bevel=0.012):
bpy.ops.mesh.primitive_cube_add(location=loc)
o=bpy.context.object; o.name=name; o.dimensions=size; bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
o.data.materials.append(mat(material))
if bevel:
mod=o.modifiers.new("edge_radius", "BEVEL"); mod.width=bevel; mod.segments=3
if parent: o.parent=parent
return o
def cyl(name, radius, depth, loc, material="steel", parent=None, axis="z", vertices=32):
bpy.ops.mesh.primitive_cylinder_add(vertices=vertices, radius=radius, depth=depth, location=loc)
o=bpy.context.object; o.name=name; o.data.materials.append(mat(material))
if axis=="x": o.rotation_euler[1]=math.pi/2
elif axis=="y": o.rotation_euler[0]=math.pi/2
if parent: o.parent=parent
return o
def beam(name, a, b, radius, material="steel", parent=None):
a,b=Vector(a),Vector(b); d=b-a
o=cyl(name,radius,d.length/1.0,a+d/2,material,parent,"z",24)
o.rotation_mode="QUATERNION"; o.rotation_quaternion=Vector((0,0,1)).rotation_difference(d.normalized()); return o
def bolt_row(parent, start, count, step, axis="x", radius=0.006):
for i in range(count):
p=list(start); p["xyz".index(axis)] += i*step; cyl(f"bolt_{axis}_{i+1}",radius,0.012,p,"black",parent,axis)
def rail(parent, name, loc, length, axis="x"):
box(name+"_guide", (length,.026,.026) if axis=="x" else (.026,length,.026) if axis=="y" else (.026,.026,length), loc, "rail", parent, .004)
bolt_row(parent, [loc[0]-length/2+0.04,loc[1],loc[2]], max(2,int(length/.16)), .16, axis, .004)
def rotary_group(parent,name,axis,loc,radius=0.22,thickness=.08):
g=group(parent,name,loc)
cyl(name+"_bearing",radius*.38,thickness*1.8,(0,0,0),"black",g,axis)
cyl(name+"_axis",radius*.25,thickness*2.1,(0,0,0),"yellow",g,axis)
return g
def industrial_mill(id, coords, title, rotary):
scene=base_scene(id,coords,"design-reference" if id not in ("xyzac-trt","xyzbc-trt") else "real-reference",title)
root=root_object(scene)
base=group(root,"base"); box("bed_casting",(1.45,.95,.18),(0,0,.09),"cast",base); box("chip_tray",(1.38,.82,.035),(0,0,.20),"black",base)
for x in (-.62,.62): box("column",(.14,.20,1.15),(x,.22,.78),"cast",base); rail(base,"vertical_rail",(x, .105, .78),.86,"z")
box("bridge_crossbeam",(1.28,.20,.16),(0,.22,1.27),"paint",base)
x=group(root,"x_slide"); box("x_carriage",(.30,.22,.18),(0,.10,1.15),"paint",x); rail(x,"x_linear_rail",(0,.04,1.26),1.05,"x")
y=group(x,"y_saddle"); box("y_saddle_casting",(.30,.33,.14),(0,0,1.12),"cast",y); rail(y,"y_linear_rail",(0,-.13,1.02),.62,"y")
z=group(y,"z_slide"); box("z_ram",(.22,.22,.56),(0,.0,.80),"paint",z); rail(z,"z_linear_rail",(.12,0,.80),.48,"z")
tool=group(z,"tool"); cyl("spindle_motor",.075,.24,(0,0,.45),"black",tool); cyl("spindle_quill",.048,.23,(0,0,.22),"rail",tool); cyl("tool_holder",.024,.11,(0,0,.04),"yellow",tool)
tilt=group(base, "a_tilt_frame" if rotary=="AC" else "b_trunnion" if rotary=="BC" else "a_outer_table", [0,-.03,.27])
axis="x" if rotary in ("AC","AB") else "y"; rotary_group(tilt,"tilt_axis",axis,(0,0,0),.32,.10)
work=group(tilt,"c_table" if rotary in ("AC","BC") else "b_inner_table")
rotary_group(work,"rotary_axis","z",(0,0,.08),.27,.10); cyl("work_platter",.24,.06,(0,0,.15),"rail",work); box("fixture_plate",(.28,.18,.04),(0,0,.20),"yellow",work)
scene["linuxcnc.parts"]=["base","x_slide","y_saddle","z_slide","tool",tilt.name,work.name]
return scene
def bridge_scene():
scene=base_scene("bridge-mill-5axiskins","XYZBCWY","real-reference","Bridge Mill bi-rotary head")
root=root_object(scene); base=group(root,"base")
box("foundation",(2.4,1.4,.22),(0,0,.11),"cast",base); box("moving_x_table",(2.05,1.05,.11),(0,0,.28),"steel",base)
for x in (-.95,.95): box("portal_column",(.20,.42,1.75),(x,.18,1.0),"cast",base)
box("portal_beam",(2.15,.42,.22),(0,.18,1.82),"paint",base); x=group(root,"x_slide"); box("bridge_carriage",(.38,.36,.24),(0,.18,1.63),"steel",x); y=group(x,"y_slide"); box("cross_carriage",(.38,.50,.20),(0,0,1.62),"paint",y); z=group(y,"z_slide"); box("ram",(.22,.22,.65),(0,0,1.32),"steel",z)
b=group(z,"b_head"); rotary_group(b,"b_axis","y",(0,0,.95),.23,.10); c=group(b,"c_head"); rotary_group(c,"c_axis","z",(0,0,.8),.17,.10); cyl("spindle",.055,.25,(0,0,.58),"black",c); cyl("tool",.022,.18,(0,0,.36),"yellow",c); box("y_left_drive",(.10,.20,.18),(-1.04,.18,1.72),"orange",base); box("y_right_drive",(.10,.20,.18),(1.04,.18,1.72),"orange",base); scene["linuxcnc.parts"]=["base","x_slide","y_slide","z_slide","b_head","c_head","y_left_drive","y_right_drive"]; return scene
def parallel_scene(id,count,title):
scene=base_scene(id,"XYZABC" if count==6 else "XYZAB","real-reference",title); root=root_object(scene); base=group(root,"base"); box("base_plate",(1.15,.90,.10),(0,0,.05),"cast",base); plat=group(root,"moving_platform",[0,0,.75]); box("platform_plate",(.46,.46,.07),(0,0,0),"paint",plat)
for i in range(count):
a=2*math.pi*i/count; b=2*math.pi*i/count+.4; beam(f"strut_{i+1}_lower",[.43*math.cos(a),.43*math.sin(a),.13],[.20*math.cos(b),.20*math.sin(b),.67],.027,"steel",base); beam(f"strut_{i+1}_upper",[.20*math.cos(b),.20*math.sin(b),.67],[.0,.0,.75],.014,"yellow",plat)
cyl("spindle",.07,.22,(0,0,-.16),"black",plat); cyl("tool",.025,.18,(0,0,-.36),"yellow",plat); scene["linuxcnc.parts"]=["base"]+[f"strut_{i+1}" for i in range(count)]+["moving_platform","spindle","tool"]; return scene
def robot_scene(id, five=False):
coords="XYZAB" if five else "XYZABC"; scene=base_scene(id,coords,"real-reference","SCORBOT ER-4U" if five else "PUMA serial robot"); root=root_object(scene); base=group(root,"base"); box("robot_pedestal",(.55,.55,.18),(0,0,.09),"cast",base)
parents=[base]; names=["j1_base","j2_shoulder","j3_elbow","j4_wrist","j5_pitch"]+([] if five else ["j6_wrist"])
heights=[.25,.32,.27,.14,.10,.08]
for i,n in enumerate(names):
g=group(parents[-1],n,[0,0,heights[i] if i<len(heights) else .1]); cyl(n+"_bearing",.11 if i<3 else .06,.08,(0,0,0),"joint",g,"z"); linkpart=box(n+"_link",(.16,.16,.28 if i<3 else .14),(0,0,.16 if i<3 else .08),"paint" if i%2 else "steel",g); parents.append(g)
tool=group(parents[-1],"tool"); cyl("tool_holder",.025,.18,(0,0,-.10),"yellow",tool); scene["linuxcnc.parts"]=["base"]+names+["tool"]; return scene
def baseline_scene(id,coords):
scene=industrial_mill(id,coords,id,"AB"); scene["linuxcnc.baseline"] = True; return scene
def base_scene(id,coords,status,title):
bpy.ops.wm.read_factory_settings(use_empty=True); MATS.clear(); scene=bpy.context.scene; scene.name=id; scene.unit_settings.system="METRIC"; scene.unit_settings.length_unit="MILLIMETERS"; scene["linuxcncPhysicalStructure"]={"schema":"linuxcnc.physical-machine/1","caseId":id,"coordinates":coords,"kinematics":id if id not in ("trivkins-xyzab","trivkins-xyzcw","trivkins-xyzya") else "trivkins","referenceStatus":status,"title":title,"gltfUnit":"m","license":"CC0-1.0 procedural design reference"}; return scene
specs=[
('xyzac-trt',lambda:industrial_mill('xyzac-trt','XYZAC','XYZAC TRT','AC')),
('xyzbc-trt',lambda:industrial_mill('xyzbc-trt','XYZBC','XYZBC TRT','BC')),
('xyzab-tdr',lambda:industrial_mill('xyzab-tdr','XYZAB','XYZAB TDR','AB')),
('bridge-mill-5axiskins',bridge_scene),
('max5-maxkins',lambda:industrial_mill('max5-maxkins','XYZBC','MAX5 maxkins','BC')),
('hexapod-genhexkins',lambda:parallel_scene('hexapod-genhexkins',6,'Variax Hexapod')),
('pentapod-pentakins',lambda:parallel_scene('pentapod-pentakins',5,'METROM Pentapod')),
('puma-pumakins',lambda:robot_scene('puma-pumakins')),
('puma-genserkins',lambda:robot_scene('puma-genserkins')),
('trivkins-xyzab',lambda:baseline_scene('trivkins-xyzab','XYZAB')),
('trivkins-xyzcw',lambda:baseline_scene('trivkins-xyzcw','XYZCW')),
('trivkins-xyzya',lambda:baseline_scene('trivkins-xyzya','XYZYA')),
('scorbot-kins',lambda:robot_scene('scorbot-kins',True)),
]
only = None
if "--" in sys.argv:
argv = sys.argv[sys.argv.index("--") + 1:]
if "--only" in argv and argv.index("--only") + 1 < len(argv): only = argv[argv.index("--only") + 1]
for id,make in specs:
if only and id != only: continue
scene=make(); out=os.path.join(OUT,f'{id}.glb')
bpy.ops.wm.save_as_mainfile(filepath=os.path.join('/tmp',f'{id}.blend'))
bpy.ops.export_scene.gltf(filepath=out, export_format='GLB', export_apply=True, use_selection=False)
for o in list(scene.objects):
for d in list(o.data.materials) if hasattr(o.data,'materials') and o.data else []: pass
print('BLENDER_GLB_OK',id,out)