关节契约扩为槽位制:以 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>
166 lines
10 KiB
Python
166 lines
10 KiB
Python
"""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)
|