Files
chenshijue 4240bb889a 初始提交:关节模组仿真平台
- 三接口契约:自包含 MJCF / 配置 schema / 报告计算规范
- Python 流水线:urdf_to_mjcf → generate_schema → simulate_report(validate_module 一键编排)
- 输入案例 urdf + 生成产物 output(自包含 MJCF/schema/报告/网格副本)
- 详细架构说明 docs/architecture.md
2026-08-28 11:37:47 +08:00

601 lines
25 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
关节模组 URDF → MJCF 转换小工具(专用版)
=====================================================================
把一个「关节模组」的 URDF 转成 MuJoCo 可用的 MJCF.xml),**不改变任何
运动学 / 动力学 / 传动关系**,只补上原 URDF 里缺失、但仿真必需的部分:
1. 质量 / 重心 / 惯性张量 —— 从每个 link 的 STL 网格做体积分(trimesh),
按材料密度(脚本顶部的常数表)算出,多网格用平行轴定理合成。
2. <actuator> —— 输入电机 + 输出负载(原 URDF 没有)。
3. 渲染网格覆盖 —— 个别 STL 面数超过 MuJoCo 上限(20万),渲染时换降采样版。
已严格保真的部分(直接照搬 URDF,绝不动):
- 关节层级:<joint><parent>/<child> 决定 body 的父子关系;
<joint><origin xyz> → 子 <body pos>(不是 <joint pos>!)
<joint><origin rpy> → 子 <body euler>rpy 反转,z-y-x
- 转轴:<axis> → <joint axis>(子 link 系内,直接映射)
- 限位:<limit lower/upper> → <joint range>
- 减速比:<mimic> → <equality> polycoefjoint1 = offset + multiplier·joint2
- 视觉:<visual><origin xyz> → <geom pos>(仅视觉,contype/conaffinity=0
关键 MuJoCo 语义(易错点,务必保持):
- MuJoCo 的 <joint pos> 是转轴相对 **body 帧** 的偏移,默认 0 即轴过 body 原点。
URDF 的 <joint><origin> 是「子 link 系相对父系」的位姿,对应子 <body pos>。
两者不是一回事 —— 填错会把行星轮挂到体外轴上公转。
- <mimic multiplier=M> 等价 <equality> joint1=本关节 joint2=被 mimic 关节
polycoef="offset M 0 0 0"。
用法(在 scripts/ 目录下运行):
python3 urdf_to_mjcf.py # 默认转换本案例
python3 urdf_to_mjcf.py --urdf ../urdf/foo.urdf --out foo.xml
python3 urdf_to_mjcf.py --input <关节名> --output <关节名> # 手动指定输入/输出端
依赖:numpy, trimeshpip install trimesh
"""
import argparse
import os
import shutil
import xml.etree.ElementTree as ET
import numpy as np
try:
import trimesh
except ImportError as e: # pragma: no cover
raise SystemExit("缺少依赖 trimesh,请先 `pip install trimesh`") from e
# =============================================================================
# 材料密度常数 (kg/m³) —— 在这里给定 / 修改
# =============================================================================
# 默认按钢处理;个别混合/轻质零件单独覆盖。这些就是「关节模组」需要标定的常数。
DEFAULT_DENSITY = 7850.0 # 钢(太阳轮、行星架、行星轮)
DENSITIES = {
"fixed_structure": 2700.0, # 固定结构 —— 铝合金
"motor_stator": 7200.0, # 定子 —— 硅钢+铜绕组,工程估算等效密度
"motor_rotor": 7600.0, # 转子 —— 硅钢+磁钢,工程估算等效密度
# 其余(sun_drive / carrier_link / planet_*_link)走默认钢 7850
}
# STL 单位换算:URDF 里 mesh 的 scale 通常是 0.001(毫米 → 米)。
# 质量/惯量按「缩放后(米)」的体积分计算,保证 SI 单位(kg / kg·m²);
# 渲染 scale 逐 mesh 从 URDF <mesh scale> 读取(见 build_mjcf 的 asset 生成)。
# 渲染网格覆盖:某 STL 原始面数超过 MuJoCo 顶点上限(20万),渲染时用降采样版;
# 但质量/惯量仍按 **原始网格** 计算,不改变动力学。{mesh 名: 渲染用文件名}
# 不在此表里的超面数网格,由 render_file_for() 在转换时自动降采样。
MESH_ASSET_OVERRIDE = {
"motor_stator": "motor_stator_decimated.stl",
}
# MuJoCo 单个 STL 网格的面数上限(超出会报错);自动降采样的目标面数。
MAX_MJC_FACES = 200000
DECIMATE_TARGET_FACES = 100000
# =============================================================================
# 仿真参数(关节模组默认;不影响运动学/传动,仅数值/安全相关)
# =============================================================================
TIMESTEP = 0.001 # [s]
GRAVITY = (0.0, 0.0, -9.81)
JOINT_DAMPING = 0.01 # 数值阻尼(避免刚性齿轮约束震荡)
JOINT_ARMATURE = 0.0005 # 电枢惯量
TORQUE_LIMIT = 10.0 # 输入力矩限位 [N·m](占位,应填真实电机额定值)
LOAD_CTRLRANGE = 1.0e6 # 负载电机 ctrlrange:测试边界条件,不限流(可施加任意负载做过载仿真)
SOLREF = (0.002, 1.0) # 齿轮约束软约束 solref
SOLIMP = (0.9, 0.95, 0.0001) # 齿轮约束 solimp
# =============================================================================
# 小工具函数
# =============================================================================
def _vec(el, attr, default):
if el is None or attr not in el.attrib:
return np.array(default, dtype=float)
return np.array([float(x) for x in el.get(attr).split()], dtype=float)
def parse_origin(origin_el):
"""返回 (xyz, rpy),缺省为零。"""
if origin_el is None:
return np.zeros(3), np.zeros(3)
xyz = _vec(origin_el, "xyz", [0, 0, 0])
rpy = _vec(origin_el, "rpy", [0, 0, 0])
return xyz, rpy
def parse_axis(axis_el):
"""URDF 转轴(子 link 系内),缺省 (1,0,0)。"""
return _vec(axis_el, "xyz", [1, 0, 0])
def parse_limit(limit_el):
"""返回 (lower, upper),无 <limit> 返回 None。"""
if limit_el is None:
return None
return (float(limit_el.get("lower")), float(limit_el.get("upper")))
def parse_mimic(mimic_el):
"""返回 dict(joint, multiplier, offset),无 <mimic> 返回 None。"""
if mimic_el is None:
return None
return {
"joint": mimic_el.get("joint"),
"multiplier": float(mimic_el.get("multiplier", "1")),
"offset": float(mimic_el.get("offset", "0")),
}
def parse_mesh_scale(mesh_el):
"""返回 mesh 的 scale(默认 1 1 1)。"""
if mesh_el is None:
return np.array([1.0, 1.0, 1.0])
return _vec(mesh_el, "scale", [1, 1, 1])
def parse_color(material_el, global_materials):
"""提取材质 RGBA(前 3 通道),找不到用默认灰。"""
rgba = None
if material_el is not None:
color_el = material_el.find("color")
if color_el is not None:
rgba = color_el.get("rgba")
elif material_el.get("name") in global_materials:
rgba = global_materials[material_el.get("name")]
if rgba is None:
return (0.7, 0.7, 0.7)
vals = [float(x) for x in rgba.split()]
return tuple(vals[:3])
def rpy_to_euler(rpy):
"""URDF rpy(固定轴 x-y-z, R=Rz·Ry·Rx) → MuJoCo euler(体轴 x-y-z, R=Rx·Ry·Rz)。
两者不是简单重排(Rx·Ry·Rz ≠ Rz·Ry·Rx),必须由旋转矩阵解出 MuJoCo 的 x-y-z 欧拉角。"""
R = rpy_to_mat(rpy)
ey = np.arcsin(np.clip(R[0, 2], -1.0, 1.0))
ez = np.arctan2(-R[0, 1], R[0, 0])
ex = np.arctan2(-R[1, 2], R[2, 2])
return np.array([ex, ey, ez])
def rpy_to_mat(rpy):
"""rpy → 旋转矩阵 R = Rz(rz)·Ry(ry)·Rx(rx)。"""
cx, sx = np.cos(rpy[0]), np.sin(rpy[0])
cy, sy = np.cos(rpy[1]), np.sin(rpy[1])
cz, sz = np.cos(rpy[2]), np.sin(rpy[2])
Rx = np.array([[1, 0, 0], [0, cx, -sx], [0, sx, cx]])
Ry = np.array([[cy, 0, sy], [0, 1, 0], [-sy, 0, cy]])
Rz = np.array([[cz, -sz, 0], [sz, cz, 0], [0, 0, 1]])
return Rz @ Ry @ Rx
def fmt(x):
"""浮点 → 字符串(6 位有效数字,紧凑科学计数)。"""
return f"{float(x):.6g}"
def fmt_vec(v):
return " ".join(fmt(x) for x in v)
def fmt_urdf(x):
"""URDF 原值:最短精确表示(整数值去 .0),保证与源 URDF 逐位一致。
用于「照搬不改」的字段:限位、减速比、原点、轴、mesh scale。"""
v = float(x)
if v == int(v) and abs(v) < 1e15:
return str(int(v))
return repr(v)
def fmt_vec_urdf(v):
return " ".join(fmt_urdf(x) for x in v)
# =============================================================================
# 质量 / 重心 / 惯性 计算(STL 体积分)
# =============================================================================
def mesh_mass_props(path, density, scale):
"""加载 STL,返回 (mass, com, inertia)
mass [kg]、com [m,网格自身坐标系]、inertia [kg·m²,关于自身质心,网格系]。"""
mesh = trimesh.load(path, force="mesh")
if isinstance(mesh, trimesh.Scene):
# 多物体场景:合并几何
mesh = trimesh.util.concatenate(list(mesh.geometry.values()))
# 均匀缩放(毫米→米)。非均匀 scale 这里按体积近似,误差可忽略(本案例均 0.001)
s = float(scale[0])
mesh.apply_scale(s)
mass = density * abs(mesh.volume)
com = np.asarray(mesh.center_mass, dtype=float)
inertia = np.asarray(mesh.moment_inertia, dtype=float) * density
return mass, com, inertia
def link_mass_props(visuals, mesh_dir):
"""把一个 link 的多个视觉网格,按各自 origin 合成质量/重心/惯性(link 系)。"""
items = []
for vis in visuals:
if vis["mesh_filename"] is None:
continue # 非 mesh 几何(本案例没有)跳过
base = vis["mesh_base"]
src = os.path.join(mesh_dir, base + ".stl") # 原始网格算质量
origin, rpy = vis["origin"]
density = DENSITIES.get(base, DEFAULT_DENSITY)
mass, com_m, inertia_m = mesh_mass_props(src, density, vis["scale"])
R = rpy_to_mat(rpy)
com_link = origin + R @ com_m # 网格质心 → link 系
inertia_link = R @ inertia_m @ R.T # 惯性张量旋到 link 系
items.append((mass, com_link, inertia_link))
if not items:
return 0.0, np.zeros(3), np.zeros((3, 3))
total_mass = sum(m for m, _, _ in items)
com = sum(m * c for m, c, _ in items) / total_mass
inertia = np.zeros((3, 3))
for mass, c, I in items:
d = c - com
inertia += I + mass * (np.dot(d, d) * np.eye(3) - np.outer(d, d))
return total_mass, com, inertia
# =============================================================================
# 网格降采样(仅用于渲染;质量/惯量始终按原始网格,见 link_mass_props
# =============================================================================
def stl_face_count(path):
"""读二进制 STL 的三角面数(offset 80 的 uint32)。非二进制返回 0。"""
with open(path, "rb") as f:
if f.read(5).lower().startswith(b"solid"):
return 0
f.seek(80)
return int(np.frombuffer(f.read(4), dtype="<u4")[0])
def decimate_stl(src, dst, target_faces):
"""把 STL 降采样到约 target_faces 个三角面,写出二进制 STL。"""
try:
import fast_simplification
except ImportError:
raise SystemExit(
f"网格 {os.path.basename(src)} 面数超 MuJoCo 上限,且缺少 fast_simplification"
"请 `pip install fast_simplification`")
mesh = trimesh.load(src, force="mesh")
if isinstance(mesh, trimesh.Scene):
mesh = trimesh.util.concatenate(list(mesh.geometry.values()))
faces = mesh.faces.astype("int64")
if len(faces) <= MAX_MJC_FACES:
shutil.copy2(src, dst)
return
reduction = max(0.0, 1.0 - target_faces / len(faces))
v, f = fast_simplification.simplify(
mesh.vertices.astype("float64"), faces, target_reduction=reduction, agg=7.0)
trimesh.Trimesh(vertices=v, faces=f, process=False).export(dst)
def render_file_for(base, src_mesh_dir, out_mesh_dir):
"""返回 base 网格用于渲染的文件名:显式覆盖 > 自动降采样 > 原文件。"""
if base in MESH_ASSET_OVERRIDE:
# 显式覆盖仅当覆盖文件真实存在于源网格目录时才生效;否则退回通用降采样路径,
# 让新模组里同样超面数的同名网格自动生成 *_decimated.stl(否则会引用一个不存在的文件)。
override = MESH_ASSET_OVERRIDE[base]
if os.path.isfile(os.path.join(src_mesh_dir, override)):
return override
src = os.path.join(src_mesh_dir, base + ".stl")
if os.path.isfile(src) and stl_face_count(src) > MAX_MJC_FACES:
dec = base + "_decimated.stl"
dst = os.path.join(out_mesh_dir, dec)
if not os.path.isfile(dst):
os.makedirs(out_mesh_dir, exist_ok=True)
decimate_stl(src, dst, DECIMATE_TARGET_FACES)
return dec
return base + ".stl"
# =============================================================================
# URDF 解析
# =============================================================================
def parse_urdf(path):
tree = ET.parse(path)
robot = tree.getroot()
# 顶层 <material>(本案例用的是视觉内联 <material>,这里留作兜底)
global_materials = {}
for mat in robot.findall("material"):
color_el = mat.find("color")
if color_el is not None:
global_materials[mat.get("name")] = color_el.get("rgba")
links = {}
for link in robot.findall("link"):
name = link.get("name")
visuals = []
for vis in link.findall("visual"):
origin = parse_origin(vis.find("origin"))
geom_el = vis.find("geometry")
mesh_el = geom_el.find("mesh") if geom_el is not None else None
mesh_filename = mesh_el.get("filename") if mesh_el is not None else None
mesh_base = (os.path.splitext(os.path.basename(mesh_filename))[0]
if mesh_filename else None)
visuals.append({
"origin": origin,
"mesh_filename": mesh_filename,
"mesh_base": mesh_base,
"scale": parse_mesh_scale(mesh_el),
"color": parse_color(vis.find("material"), global_materials),
})
links[name] = {"name": name, "visuals": visuals}
joints = []
for j in robot.findall("joint"):
joints.append({
"name": j.get("name"),
"type": j.get("type", "revolute"),
"parent": j.find("parent").get("link"),
"child": j.find("child").get("link"),
"origin": parse_origin(j.find("origin")),
"axis": parse_axis(j.find("axis")),
"limit": parse_limit(j.find("limit")),
"mimic": parse_mimic(j.find("mimic")),
})
return robot.get("name"), links, joints
def build_tree(links, joints):
"""由关节 parent/child 建立 body 树,返回 (roots, children_by_parent)。"""
parent_of = {} # child link -> joint
children = {} # parent link -> [joints]
for link in links:
children[link] = []
for j in joints:
parent_of[j["child"]] = j
children.setdefault(j["parent"], []).append(j)
roots = [name for name in links if name not in parent_of]
return roots, children
def detect_input_output(joints):
"""关节模组自动识别输入/输出端:
输入 = 唯一没有 <mimic> 的关节(独立驱动源);
输出 = 以正 multiplier 跟随输入的关节(减速输出)。
也可用 --input/--output 手动指定。"""
no_mimic = [j for j in joints if j["mimic"] is None]
input_j = no_mimic[0] if len(no_mimic) == 1 else None
output_j = None
if input_j is not None:
for j in joints:
m = j["mimic"]
if m is not None and m["joint"] == input_j["name"] and m["multiplier"] > 0:
output_j = j
break
return input_j, output_j
# =============================================================================
# MJCF 生成
# =============================================================================
# URDF 关节类型 → MuJoCo 关节类型。URDF 的 fixed 关节(0 自由度,刚体固连)在
# MJCF 里用「子 body 不写 <joint>」表达(子 body 的 pos/euler 已含其位姿),故映射为 None。
_URDF_TO_MJC_JOINT = {
"revolute": "hinge",
"continuous": "hinge",
"prismatic": "slide",
"floating": "free",
"fixed": None,
}
def joint_xml(j):
jtype = _URDF_TO_MJC_JOINT.get(j["type"], j["type"])
if jtype is None: # fixed:固连,不生成 <joint>
return None
attrs = [f'name="{j["name"]}"', f'type="{jtype}"', f'axis="{fmt_vec_urdf(j["axis"])}"']
if j["limit"] is not None:
attrs.append(f'range="{fmt_urdf(j["limit"][0])} {fmt_urdf(j["limit"][1])}"')
return " ".join(attrs)
def inertial_xml(mass, com, inertia):
if mass <= 0:
return None
# fullinertia 顺序:ixx iyy izz ixy ixz iyz
fi = (inertia[0, 0], inertia[1, 1], inertia[2, 2],
inertia[0, 1], inertia[0, 2], inertia[1, 2])
return (f'<inertial pos="{fmt_vec(com)}" mass="{fmt(mass)}" '
f'fullinertia="{fmt_vec(fi)}"/>')
def geom_xml(vis, asset_scales):
base = vis["mesh_base"]
if base is None:
return None
# 记录该 mesh 的渲染 scale(来自 URDF <mesh scale>)。同一 mesh 若被多处引用且 scale
# 不同,以最后一次为准——本语料库所有 mesh scale 统一为 0.001,不涉及此边界情况。
asset_scales[base] = vis["scale"]
parts = [f'type="mesh"', f'mesh="{base}"',
f'rgba="{fmt(vis["color"][0])} {fmt(vis["color"][1])} {fmt(vis["color"][2])} 1"']
origin, rpy = vis["origin"]
if np.any(np.abs(origin) > 1e-12):
parts.append(f'pos="{fmt_vec_urdf(origin)}"')
if np.any(np.abs(rpy) > 1e-12):
parts.append(f'euler="{fmt_vec_urdf(rpy_to_euler(rpy))}"')
return "<geom " + " ".join(parts) + "/>"
def emit_body(lines, link, links, joints, children, joint_map, mesh_dir, asset_scales, depth):
ind = " " * depth
link_name = link["name"]
# 该 link 作为子 body 的关节(非根)
j = joint_map.get(link_name)
pos_attr = ""
if j is not None:
xyz, rpy = j["origin"]
if np.any(np.abs(xyz) > 1e-12):
pos_attr = f' pos="{fmt_vec_urdf(xyz)}"'
euler = rpy_to_euler(rpy)
if np.any(np.abs(euler) > 1e-12):
pos_attr += f' euler="{fmt_vec_urdf(euler)}"'
lines.append(f'{ind}<body name="{link_name}"{pos_attr}>')
# 惯性(link 系内)
mass, com, inertia = link_mass_props(link["visuals"], mesh_dir)
iner = inertial_xml(mass, com, inertia)
if iner is not None:
lines.append(ind + " " + iner)
# 关节(fixed 关节固连不生成 <joint>,仅靠 body 的 pos/euler 定位)
if j is not None:
jxml = joint_xml(j)
if jxml is not None:
lines.append(ind + " <joint " + jxml + "/>")
# 视觉几何
for vis in link["visuals"]:
g = geom_xml(vis, asset_scales)
if g is not None:
lines.append(ind + " " + g)
# 子 body
for cj in children.get(link_name, []):
child_link = links[cj["child"]]
emit_body(lines, child_link, links, joints, children, joint_map,
mesh_dir, asset_scales, depth + 1)
lines.append(f"{ind}</body>")
def build_mjcf(robot_name, links, joints, mesh_dir, out_meshdir, input_j, output_j, damping, torque_limit):
roots, children = build_tree(links, joints)
joint_map = {j["child"]: j for j in joints}
asset_scales = {}
lines = []
lines.append(f'<mujoco model="{robot_name}">')
lines.append(' <!-- 由 urdf_to_mjcf.py 自动生成:只补质量/惯量/作动器,'
'不改变运动学/动力学/传动关系 -->')
lines.append(' <compiler angle="radian" meshdir="meshes"/>')
lines.append('')
lines.append(f' <option timestep="{fmt(TIMESTEP)}" gravity="{fmt_vec(GRAVITY)}"/>')
lines.append('')
lines.append(' <default>')
lines.append(f' <joint damping="{fmt(damping)}" armature="{fmt(JOINT_ARMATURE)}"/>')
lines.append(' <geom contype="0" conaffinity="0"/>')
lines.append(' </default>')
lines.append('')
lines.append(' <asset>')
# 先收集所有 mesh 引用(base 名 → 渲染 scale),再确定渲染文件名(覆盖 / 自动降采样 / 原文件)
body_lines = []
for root in roots:
emit_body(body_lines, links[root], links, joints, children, joint_map,
mesh_dir, asset_scales, 2)
render_file = {base: render_file_for(base, mesh_dir, out_meshdir)
for base in sorted(asset_scales)}
for base, file in sorted(render_file.items()):
scale = asset_scales[base]
lines.append(f' <mesh name="{base}" file="{file}" scale="{fmt_vec(scale)}"/>')
lines.append(' </asset>')
lines.append('')
lines.append(' <worldbody>')
lines.extend(body_lines)
lines.append(' </worldbody>')
lines.append('')
# mimic → equality
lines.append(' <equality>')
for j in joints:
m = j["mimic"]
if m is not None:
poly = f'{fmt_urdf(m["offset"])} {fmt_urdf(m["multiplier"])} 0 0 0'
lines.append(f' <joint joint1="{j["name"]}" joint2="{m["joint"]}" '
f'polycoef="{poly}" '
f'solref="{fmt(SOLREF[0])} {fmt(SOLREF[1])}" '
f'solimp="{fmt(SOLIMP[0])} {fmt(SOLIMP[1])} {fmt(SOLIMP[2])}"/>')
lines.append(' </equality>')
lines.append('')
# actuator
lines.append(' <actuator>')
if input_j is not None:
lines.append(f' <motor name="input_motor" joint="{input_j["name"]}" '
f'gear="1" ctrlrange="-{fmt(torque_limit)} {fmt(torque_limit)}"/>')
if output_j is not None:
lines.append(f' <motor name="load_motor" joint="{output_j["name"]}" '
f'gear="1" ctrlrange="-{fmt(LOAD_CTRLRANGE)} {fmt(LOAD_CTRLRANGE)}"/>')
lines.append(' </actuator>')
lines.append('</mujoco>')
return "\n".join(lines) + "\n", render_file
# =============================================================================
# 主流程
# =============================================================================
def main():
here = os.path.dirname(os.path.abspath(__file__))
ap = argparse.ArgumentParser(description="关节模组 URDF → MJCF 转换")
ap.add_argument("--urdf", default=os.path.join(here, "..", "urdf",
"planetary_joint_split_motor_demo.urdf"))
ap.add_argument("--out", default=os.path.join(here,
"planetary_joint_split_motor_demo_generated.xml"))
ap.add_argument("--meshdir", default=os.path.join(here, "meshes"),
help="网格输出目录(把 URDF 引用的 STL 拷进来,默认 urdf/meshes 即 URDF 同目录)")
ap.add_argument("--input", default=None, help="手动指定输入关节名")
ap.add_argument("--output", default=None, help="手动指定输出关节名")
ap.add_argument("--damping", type=float, default=JOINT_DAMPING,
help=f"关节粘性阻尼(默认 {JOINT_DAMPING}")
ap.add_argument("--torque-limit", type=float, default=TORQUE_LIMIT,
help=f"输入力矩限位 [N·m](默认 {TORQUE_LIMIT}")
ap.add_argument("--no-copy", action="store_true", help="不拷贝网格文件")
args = ap.parse_args()
urdf_path = os.path.abspath(args.urdf)
robot_name, links, joints = parse_urdf(urdf_path)
mesh_dir = os.path.join(os.path.dirname(urdf_path), "meshes")
input_j, output_j = detect_input_output(joints)
if args.input:
input_j = next((j for j in joints if j["name"] == args.input), None) or input_j
if args.output:
output_j = next((j for j in joints if j["name"] == args.output), None) or output_j
os.makedirs(args.meshdir, exist_ok=True)
xml_text, render_file = build_mjcf(robot_name, links, joints, mesh_dir,
args.meshdir, input_j, output_j, args.damping,
args.torque_limit)
with open(args.out, "w", encoding="utf-8") as f:
f.write(xml_text)
# 拷贝网格(降采样网格已由 render_file_for 直接写进 meshdir,这里只拷原文件/覆盖文件)
if not args.no_copy:
for base, file in render_file.items():
src = os.path.join(mesh_dir, file)
dst = os.path.join(args.meshdir, file)
if os.path.exists(src):
shutil.copy2(src, dst)
# 汇总
print(f"已生成 {os.path.abspath(args.out)}")
print(f" links : {len(links)} joints: {len(joints)}")
print(f" 阻尼 : {args.damping}")
if input_j is not None:
print(f" 输入端 : {input_j['name']}")
if output_j is not None:
m = output_j["mimic"]
ratio = 1.0 / m["multiplier"] if m and m["multiplier"] != 0 else float("nan")
print(f" 输出端 : {output_j['name']} 减速比 ≈ 1:{ratio:.4f}")
for link_name, link in sorted(links.items()):
mass, com, _ = link_mass_props(link["visuals"], mesh_dir)
print(f" [{link_name}] 质量 {mass*1000:.1f} g 重心 {fmt_vec(com)}")
if __name__ == "__main__":
main()