Compare commits

..

13 Commits

Author SHA1 Message Date
zhangshun
9192ccbe83 拆分SPC直方图和正态曲线页面 2026-06-29 09:37:23 +08:00
zhangshun
78c80cf5f7 新增SPC基本趋势图页面 2026-06-29 09:29:25 +08:00
zhangshun
5ca2657536 优化SPC直方图坐标范围 2026-06-29 09:23:34 +08:00
zhangshun
37517d5279 新增SPC Vue控制图项目 2026-06-29 09:18:24 +08:00
zhangshun
856a7250f2 屏蔽算法运行时冗余日志 2026-06-16 16:00:36 +08:00
zhangshun
5aa855d7aa 补充机器人算法接口文档 2026-06-16 15:51:52 +08:00
zhangshun
0c7dc9e05b 增加机器人奇异点检测 2026-06-16 12:56:26 +08:00
zhangshun
a100e44ac0 增加机器人运动学能力说明文档 2026-06-16 12:45:27 +08:00
zhangshun
e26b04e915 启用机器人关节上下限检查 2026-06-16 12:41:36 +08:00
zhangshun
c2d79a74b9 自动推导URDF运动学链 2026-06-16 12:34:17 +08:00
zhangshun
a3efcd66a5 固定threejs测试页场景高度 2026-06-16 12:23:13 +08:00
zhangshun
aaf2c0cc41 增加URDF文件注册测试入口 2026-06-16 12:20:15 +08:00
zhangshun
eeef7594fd 调整threejs测试页相机坐标系 2026-06-16 12:14:06 +08:00
44 changed files with 5998 additions and 79 deletions

View File

@@ -0,0 +1,496 @@
# URDF/Orocos KDL 机器人算法接口文档
本文档说明当前机器人运动学模块对外暴露的业务接口,包括功能、参数、返回值和单位约定。接口定义以 `src/api/KinematicsWebAPI.RobotCommands.cpp` 当前实现为准。
## 1. 通用调用格式
所有业务命令都通过统一 JSON 请求进入 `KinematicsWebAPI::func`
```json
{
"msg": "request message",
"req_code": "REQ_001",
"req_from": "client",
"req_cmd": "Cmd_Name",
"req_param": {}
}
```
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `msg` | string | 否 | 调用说明。失败时如果业务层没有更具体错误,可能回传为响应 `msg`。 |
| `req_code` | string | 否 | 调用方请求编号,响应中原样返回。 |
| `req_from` | string | 否 | 调用来源,响应中原样返回。 |
| `req_cmd` | string | 是 | 命令名称。 |
| `req_param` | object | 否 | 命令参数。 |
统一响应外层格式如下:
```json
{
"success": true,
"code": 0,
"msg": "request message",
"req_code": "REQ_001",
"req_from": "client",
"req_cmd": "Cmd_Name",
"timestamp": 1792137600,
"res_data": {}
}
```
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `success` | boolean | 外层调用是否成功。业务层返回 `success:false``error` 时为 `false`。 |
| `code` | number | `0` 表示成功,`1000` 表示业务失败,`400` 表示 JSON 格式错误,`500` 表示处理异常。 |
| `msg` | string | 响应消息。失败时优先取业务层 `message``error`。 |
| `res_data` | object | 业务命令返回体。本文后续“返回值”均指 `res_data` 内部结构。 |
## 2. 单位和格式约定
| 数据 | 格式 | 单位 |
| --- | --- | --- |
| 关节角 | `j1,j2,j3,j4,j5,j6` | 弧度 rad |
| 关节角序列 | `j1,j2,j3,j4,j5,j6;j1,j2,j3,j4,j5,j6` | 弧度 rad |
| TCP 位姿 | `x,y,z,qx,qy,qz,qw` | 位置为米 m姿态为四元数 |
| TCP 位姿序列 | `x,y,z,qx,qy,qz,qw;x,y,z,qx,qy,qz,qw` | 位置为米 m姿态为四元数 |
| URDF 关节上下限 | URDF `<limit lower upper>` | 旋转关节为弧度 rad |
| `objStates` 位姿 | `tx,ty,tz,qx,qy,qz,qw` | 位置为米 m姿态为四元数 |
注意WASM/KDL 侧返回的 IK 关节结果是弧度。如果前端工艺数据使用角度,需要在前端显式做 `rad -> deg` 转换。
## 3. 机器人管理接口
### 3.1 `Cmd_InitRobot`
功能:注册或更新机器人 URDF并初始化 KDL Tree、6 轴运动学链、FK/IK 求解器、关节上下限和关节 child link 映射。
参数:
| 字段 | 类型 | 必填 | 默认值 | 说明 |
| --- | --- | --- | --- | --- |
| `robot_uuid` | string | 否 | `""` | 机器人实例 ID。为空时内部使用 URDF 内容哈希作为 ID。 |
| `urdf_base64` | string | 是 | `""` | URDF 文件内容的 base64 字符串。 |
| `force_update` | boolean | 否 | `true` | 是否强制更新。为 `false` 且内容未变化时跳过更新。 |
返回值:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `success` | boolean | 初始化是否成功。 |
| `message` | string | 初始化结果说明。 |
| `timestamp` | string | 当前时间字符串。 |
示例:
```json
{
"req_cmd": "Cmd_InitRobot",
"req_param": {
"robot_uuid": "abb_irb120_3_58",
"urdf_base64": "PD94bWwgdmVyc2lvbj0iMS4wIj8+...",
"force_update": true
}
}
```
### 3.2 `Cmd_GetRobot`
功能:查询指定机器人实例是否存在、是否已初始化以及关节数量。
参数:
| 字段 | 类型 | 必填 | 默认值 | 说明 |
| --- | --- | --- | --- | --- |
| `uuid` | string | 是 | `""` | 机器人实例 ID。注意该接口当前字段名为 `uuid`,不是 `robot_uuid`。 |
返回值:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `success` | boolean | 查询是否成功。 |
| `uuid` | string | 机器人实例 ID。 |
| `initialized` | boolean | 机器人是否已初始化。 |
| `joints_count` | number | 当前运动学链关节数量。 |
| `timestamp` | string | 当前时间字符串。 |
失败时返回:
```json
{
"error": "Robot not found"
}
```
### 3.3 `Cmd_RemoveRobot`
功能:删除指定机器人实例和对应 URDF 哈希记录。
参数:
| 字段 | 类型 | 必填 | 默认值 | 说明 |
| --- | --- | --- | --- | --- |
| `uuid` | string | 是 | `""` | 机器人实例 ID。注意该接口当前字段名为 `uuid`,不是 `robot_uuid`。 |
返回值:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `success` | boolean | 删除是否成功。 |
| `message` | string | 删除结果说明。 |
| `timestamp` | string | 当前时间字符串。 |
### 3.4 `Cmd_ListRobots`
功能:列出当前已注册的机器人实例。
参数:
| 字段 | 类型 | 必填 | 默认值 | 说明 |
| --- | --- | --- | --- | --- |
| `detail` | boolean | 否 | `false` | 是否返回关节数量、校验状态和 URDF 哈希摘要。 |
返回值:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `success` | boolean | 是否查询成功。 |
| `robots` | object | 机器人字典。键为机器人 ID值为实例说明或详细信息字符串。 |
| `count` | number | 当前机器人数量。 |
| `timestamp` | string | 当前时间字符串。 |
## 4. 正运动学接口
### 4.1 `Cmd_Kinematics_forward_pose_str`
功能:输入 6 个关节角,计算 TCP 位姿。
参数:
| 字段 | 类型 | 必填 | 默认值 | 说明 |
| --- | --- | --- | --- | --- |
| `robot_uuid` | string | 否 | `"default"` | 机器人实例 ID。 |
| `q_init_str` | string | 否 | `"0,0,0,0,0,0"` | 6 个关节角,单位为弧度。该字段名为历史命名,实际表示 FK 输入关节。 |
返回值:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `position` | number[] | TCP 位置 `[x,y,z]`,单位为米。 |
| `orientation` | number[] | TCP 四元数 `[qx,qy,qz,qw]`。 |
| `joints` | number[] | 输入关节角,单位为弧度。 |
| `success` | boolean | 正解是否成功。 |
失败时常见错误:
| 错误 | 说明 |
| --- | --- |
| `Robot not found or not initialized` | 机器人未注册或初始化失败。 |
| `Invalid joints format` | 关节字符串不是 6 个数值。 |
| `Forward kinematics calculation failed` | FK 失败,常见原因包括关节超限。 |
示例:
```json
{
"req_cmd": "Cmd_Kinematics_forward_pose_str",
"req_param": {
"robot_uuid": "abb_irb120_3_58",
"q_init_str": "0.15,-0.25,0.35,0.1,-0.2,0.3"
}
}
```
### 4.2 `Cmd_Kinematics_forward_all_joints`
功能:输入一帧或多帧 6 轴关节角,计算每一帧中各关节节点位姿,并按前端 `OPERATION.frames.objStates` 格式输出。
参数:
| 字段 | 类型 | 必填 | 默认值 | 说明 |
| --- | --- | --- | --- | --- |
| `robot_uuid` | string | 否 | `"default"` | 机器人实例 ID。 |
| `joints_str` | string | 否 | `"0,0,0,0,0,0"` | 关节角序列,格式为 `j1,j2,j3,j4,j5,j6;...`,单位为弧度。 |
返回值:
当前返回存在一层历史嵌套,结构如下:
```json
{
"success": true,
"OPERATION": {
"success": true,
"count": 2,
"OPERATION": {
"frames": [
{
"time": "0.020000",
"objStates": [
{
"i": "child_link_uuid",
"tx": 0,
"ty": 0,
"tz": 0,
"qx": 0,
"qy": 0,
"qz": 0,
"qw": 1
}
]
}
]
}
}
}
```
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `OPERATION.success` | boolean | 批量 FK 是否成功。 |
| `OPERATION.count` | number | 输入关节帧数量。 |
| `OPERATION.OPERATION.frames` | array | 前端播放帧。 |
| `frames[].time` | string | 当前实现固定为 `"0.020000"`。 |
| `frames[].objStates` | array | 每个关节 child link 对应的对象位姿。 |
| `objStates[].i` | string | URDF 中解析出的 child link UUID。 |
| `objStates[].tx/ty/tz` | number | link 位置,单位为米。 |
| `objStates[].qx/qy/qz/qw` | number | link 姿态四元数。 |
失败时常见错误:
| 错误 | 说明 |
| --- | --- |
| `Empty joints input` | 未解析到有效关节帧。 |
| `每组关节角都必须包含 6 个值` | 某一帧关节数量不是 6。 |
| `Forward kinematics calculation for all joints failed` | FK 失败,常见原因包括关节超限。 |
## 5. 逆运动学接口
### 5.1 `Cmd_Kinematics_inverse_pose_str`
功能:输入一个或多个 TCP 位姿,执行逆运动学。单点时直接求解;多点时对相邻位姿自动估算步数并插补,再逐点求 IK。
参数:
| 字段 | 类型 | 必填 | 默认值 | 说明 |
| --- | --- | --- | --- | --- |
| `robot_uuid` | string | 否 | `"default"` | 机器人实例 ID。 |
| `pose_str` | string | 是 | `""` | 位姿或位姿序列,格式为 `x,y,z,qx,qy,qz,qw;...`。位置单位为米。 |
| `q_init_str` | string | 否 | `"0,0,0,0,0,0"` | IK 初始关节角,单位为弧度。用于影响多解选择和求解连续性。 |
返回值:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `joints` | number[][] | 逆解关节序列,每组 6 个关节角,单位为弧度。 |
| `success` | boolean | IK 是否成功。 |
实现说明:
- 单点输入返回 1 组关节解。
- 多点输入会对相邻点自动插补,自动步数范围当前为 10 到 100。
- 逐点 IK 成功后,会把上一点结果作为下一点初值。
- 逐点 IK 失败时,若已有成功结果则复用上一组结果,否则复用初始关节角,避免轨迹数组中断。
示例:
```json
{
"req_cmd": "Cmd_Kinematics_inverse_pose_str",
"req_param": {
"robot_uuid": "abb_irb120_3_58",
"pose_str": "0.374,0,0.63,0,0,0,1",
"q_init_str": "0,0,0,0,0,0"
}
}
```
### 5.2 `Cmd_Kinematics_inverse_pose_str_2PSteps`
功能:输入两个 TCP 位姿,按调用方指定步数进行插补,并对插补点逐点求 IK。主要用于 MoveL 直线路径的离散关节解生成。
参数:
| 字段 | 类型 | 必填 | 默认值 | 说明 |
| --- | --- | --- | --- | --- |
| `robot_uuid` | string | 否 | `"default"` | 机器人实例 ID。 |
| `pose_str` | string | 是 | `""` | 起点和终点位姿,格式为 `pose1;pose2`。如果只传 1 个点,则直接求单点 IK。 |
| `q_init_str` | string | 否 | `"0,0,0,0,0,0"` | IK 初始关节角,单位为弧度。 |
| `steps_str` | string | 是 | `"default"` | 插补点数量字符串,例如 `"30"`。当前实现会调用 `stoi` 转整数,不能传非数字。 |
返回值:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `joints` | number[][] | 逆解关节序列,每组 6 个关节角,单位为弧度。 |
| `success` | boolean | IK 是否成功。 |
实现说明:
- 返回值是弧度,不是角度。
- 插补过程中每一点 IK 成功后,会更新下一点的初值。
- 如果某个插补点 IK 失败,会复用上一组成功结果;若还没有成功结果,则复用初始关节角。
示例:
```json
{
"req_cmd": "Cmd_Kinematics_inverse_pose_str_2PSteps",
"req_param": {
"robot_uuid": "abb_irb120_3_58",
"pose_str": "0.374,0,0.63,0,0,0,1;0.4626,0.2209,0.334,0.148283,0.435718,0.079062,0.884258",
"q_init_str": "0,0,0,0,0,0",
"steps_str": "30"
}
}
```
### 5.3 `Cmd_Kinematics_inverse_pose_str_NoDifference`
功能:直接对输入位姿点执行 IK不做相邻点插补。适用于调用方已经生成了离散轨迹点只需要逐点求关节解的场景。
参数:
| 字段 | 类型 | 必填 | 默认值 | 说明 |
| --- | --- | --- | --- | --- |
| `robot_uuid` | string | 否 | `"default"` | 机器人实例 ID。 |
| `pose_str` | string | 是 | `""` | 位姿或位姿序列,格式为 `x,y,z,qx,qy,qz,qw;...`。 |
| `q_init_str` | string | 否 | `"0,0,0,0,0,0"` | IK 初始关节角,单位为弧度。 |
返回值:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `joints` | number[][] | 逆解关节序列,每组 6 个关节角,单位为弧度。 |
| `success` | boolean | IK 是否成功。 |
实现说明:
- 单点输入返回 1 组关节解。
- 多点输入不插补,逐点求解,并用上一点成功结果作为下一点初值。
- 当前多点实现循环到 `posePoints.size() - 1`,最后一个输入位姿不会被求解;如果业务需要完整多点结果,建议后续修正。
## 6. 奇异点检测接口
### 6.1 `Cmd_Kinematics_check_singularity`
功能:基于当前关节姿态计算 TCP 雅可比矩阵,对奇异值、条件数和可操作度进行评估,返回 `normal``warning``singular` 风险等级。
参数:
| 字段 | 类型 | 必填 | 默认值 | 说明 |
| --- | --- | --- | --- | --- |
| `robot_uuid` | string | 否 | `"default"` | 机器人实例 ID。 |
| `joints_str` | string | 否 | `q_init_str``"0,0,0,0,0,0"` | 6 个关节角,单位为弧度。 |
| `q_init_str` | string | 否 | `"0,0,0,0,0,0"` | 兼容字段。未传 `joints_str` 时使用。 |
| `singular_threshold` | number | 否 | `1e-4` | 最小奇异值低于或等于该阈值时判定为奇异。 |
| `warning_threshold` | number | 否 | `1e-2` | 最小奇异值低于或等于该阈值时判定为接近奇异。 |
| `condition_threshold` | number | 否 | `1e6` | 条件数大于或等于该阈值时判定为奇异。 |
| `condition_warning_threshold` | number | 否 | `1e4` | 条件数大于或等于该阈值时判定为接近奇异。 |
返回值:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `success` | boolean | 检测是否成功。 |
| `is_singular` | boolean | 是否判定为奇异。 |
| `is_near_singular` | boolean | 是否判定为接近奇异。 |
| `risk_level` | string | 风险等级:`normal``warning``singular`。 |
| `rank` | number | 雅可比矩阵秩。 |
| `joint_count` | number | 运动学链关节数量。 |
| `min_singular_value` | number | 最小奇异值。 |
| `max_singular_value` | number | 最大奇异值。 |
| `condition_number` | number | 条件数。最小奇异值为 0 时为无穷大。 |
| `manipulability` | number | 可操作度指标,当前为全部奇异值乘积。 |
| `singular_values` | number[] | 雅可比矩阵奇异值。 |
| `thresholds` | object | 本次检测使用的阈值。 |
| `joints` | number[] | 输入关节角,单位为弧度。 |
| `jacobian` | number[][] | TCP 雅可比矩阵。 |
示例:
```json
{
"req_cmd": "Cmd_Kinematics_check_singularity",
"req_param": {
"robot_uuid": "abb_irb120_3_58",
"joints_str": "0.15,-0.25,0.35,0.1,-0.2,0.3"
}
}
```
风险判断逻辑:
- `singular``min_singular_value <= singular_threshold`,或 `condition_number >= condition_threshold`,或 `rank < joint_count`
- `warning`:未达到 `singular`,但 `min_singular_value <= warning_threshold``condition_number >= condition_warning_threshold`
- `normal`:不满足以上风险条件。
失败时常见错误:
| 错误 | 说明 |
| --- | --- |
| `Invalid joints format` | 关节字符串不是 6 个数值。 |
| `Joint value out of limits` | 输入关节超出 URDF 上下限。 |
| `Jacobian calculation failed` | 雅可比矩阵计算失败。 |
## 7. 非运动学占位接口
以下命令目前也由机器人命令处理器识别,但不是机器人运动学算法接口,当前仅返回固定成功结构。
### 7.1 `Cmd_SelectCraftTree`
功能:占位接口,表示选择工艺树。
参数:
| 字段 | 类型 | 必填 | 默认值 | 说明 |
| --- | --- | --- | --- | --- |
| `tree_id` | string | 否 | `""` | 工艺树 ID。 |
返回值:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `success` | boolean | 固定为 `true`。 |
| `tree_id` | string | 输入工艺树 ID。 |
| `message` | string | 固定成功消息。 |
| `timestamp` | string | 当前时间字符串。 |
### 7.2 `Cmd_AddOperationTree`
功能:占位接口,表示新增操作树。
参数:
| 字段 | 类型 | 必填 | 默认值 | 说明 |
| --- | --- | --- | --- | --- |
| `name` | string | 否 | `""` | 操作树名称。 |
| `operations` | array | 否 | `[]` | 操作列表。 |
返回值:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `success` | boolean | 固定为 `true`。 |
| `tree_name` | string | 输入操作树名称。 |
| `operations_count` | number | 输入操作数量。 |
| `message` | string | 固定成功消息。 |
| `timestamp` | string | 当前时间字符串。 |
## 8. 关节上下限和初始化约束
初始化机器人时会执行以下检查和准备:
- URDF 必须能解析为 KDL Tree。
- 模块会自动推导一条 6 轴串联运动学链,不再固定依赖 `base_link``base``tool0` 等名称。
- 当前只支持 6 轴机器人链,非 6 轴会初始化失败。
- 会从 URDF `<limit lower="..." upper="...">` 读取关节上下限。
- FK 输入、全关节 FK 输入、IK 初值、IK 结果、奇异点检测输入都会执行关节上下限检查。
## 9. 常见接入建议
- 前端 MoveJ 工艺通常使用角度数据做关节空间插值,调用 FK 前需要统一转为弧度。
- 前端 MoveL 工艺应把 TCP 位姿传给 IK 接口IK 返回弧度后如需存入角度工艺数据,需要转为角度。
- 连续 MoveL 或 MoveJ 后接 MoveL 时,建议把上一段末尾关节作为下一段 `q_init_str`,保证 IK 多解选择更连续。
- 奇异点检测只能发现风险,不会自动规避路径;规避需要结合多解选择、路径调整、姿态微调或工艺点重规划。
- `Cmd_Kinematics_forward_pose_str` 的入参字段名 `q_init_str` 属于历史命名,实际含义是 FK 输入关节角。

View File

@@ -0,0 +1,314 @@
# URDF/Orocos KDL 工业机器人运动学能力说明
本文档说明当前 URDF/Orocos KDL 机器人运动学模块已经支持的能力,以及距离完整工业级机器人控制器还需要补齐的能力。
## 当前已支持能力
### 多机器人 URDF 注册
模块支持通过 `Cmd_InitRobot` 动态注册 URDF并用 `robot_uuid` 管理不同机器人实例。当前也支持查询机器人、列出机器人和删除机器人。
相关能力:
- `Cmd_InitRobot`:注册或更新机器人 URDF。
- `Cmd_GetRobot`:查询指定机器人实例。
- `Cmd_ListRobots`:列出当前机器人实例。
- `Cmd_RemoveRobot`:删除机器人实例。
### 自动推导运动学链
初始化时不再固定依赖 `base``tool0``base_link` 等特定 link 名称。模块会基于 KDL Tree 自动推导 6 轴串联运动学链。
当前策略:
- 优先兼容旧命名链路 `base -> tool0`
- 如果旧链路不存在,则从 URDF 的真实根节点出发,扫描叶子 link。
- 选择一条正好包含 6 个活动关节的串联链。
### 正运动学 FK
模块支持输入 6 个关节角,计算 TCP 位姿。
输出内容:
- TCP 位置:`x, y, z`
- TCP 姿态:四元数 `qx, qy, qz, qw`
对应接口:
- `Cmd_Kinematics_forward_pose_str`
### 全关节位姿 FK
模块支持计算每个关节节点的位姿,并按前端需要的 `OPERATION.frames.objStates` 格式输出。
该能力适用于:
- three.js 机器人显示。
- 关节动画回放。
- 数字孪生场景姿态同步。
- 简单离线仿真预览。
对应接口:
- `Cmd_Kinematics_forward_all_joints`
### 逆运动学 IK
模块支持输入目标 TCP 位姿和初始关节角,计算对应关节解。
当前主路径使用 KDL LMA 求解器,另保留 NR 求解接口。
对应接口:
- `Cmd_Kinematics_inverse_pose_str`
- `Cmd_Kinematics_inverse_pose_str_2PSteps`
- `Cmd_Kinematics_inverse_pose_str_NoDifference`
### 姿态轨迹插补和逐点 IK
模块支持对两个姿态点之间进行插补,然后逐点执行 IK生成一串关节解。
当前支持两种方式:
- 自动估算插补步数。
- 由调用方指定插补步数。
适用场景:
- 前端拖动目标位姿后的简单路径预览。
- 离线验证 TCP 从起点到终点的可达性。
- 生成用于动画显示的关节序列。
### 不插补的多点 IK
模块支持直接对输入的多个目标姿态点逐点逆解,不在相邻点之间插补。
适用场景:
- 已有外部轨迹点。
- 只需要验证离散点位是否可达。
- 调试 IK 解算稳定性。
对应接口:
- `Cmd_Kinematics_inverse_pose_str_NoDifference`
### 关节上下限检查
模块已支持从 URDF 的 `<limit lower="..." upper="...">` 中读取关节上下限。
当前检查范围:
- FK 输入关节值。
- 全关节 FK 输入关节值。
- IK 初始关节值。
- IK 求解结果。
如果关节值超出 URDF 定义范围,计算会返回失败,不会继续输出不可用结果。
### three.js 前端测试页面
当前已有 three.js 测试页面,用于验证 URDF 注册、机器人显示、FK、IK 和姿态回放。
页面能力包括:
- 加载并注册 URDF。
- 显示 3D 机器人模型。
- 测试 FK。
- 测试 IK。
- 应用 IK 结果到关节状态。
- 查看 JSON 请求和响应。
## 当前定位
当前模块更接近“工业机器人运动学仿真和前端验证内核”。
它已经具备工业机器人常见的基础运动学能力:
- URDF 模型注册。
- 运动学链自动推导。
- FK。
- 全关节 FK。
- IK。
- 简单轨迹插补。
- 关节上下限约束。
- 前端 3D 可视化测试。
这些能力适合用于:
- 机器人模型可用性检查。
- 前端 3D 调试。
- 数字孪生姿态同步。
- 离线点位可达性验证。
- 简单路径和动画预览。
## 尚未支持的完整工业级能力
以下能力目前尚未完整支持,如果要接近真实工业机器人控制器,需要继续补齐。
### 碰撞检测
当前不处理碰撞相关内容。
尚未支持:
- 机器人自碰撞检测。
- 机器人与环境碰撞检测。
- 工具与工件碰撞检测。
- 基于 URDF collision 几何的碰撞模型构建。
### 奇异点检测
模块已支持基于 TCP 雅可比矩阵的奇异点检测。
当前支持:
- 计算 6x6 TCP 雅可比矩阵。
- 计算雅可比矩阵奇异值。
- 输出最小奇异值、最大奇异值、条件数和 manipulability。
- 输出 rank。
- 根据阈值给出 `normal``warning``singular` 风险等级。
- 在 three.js 测试页面中通过“奇异点”按钮直接查看当前关节姿态风险。
对应接口:
- `Cmd_Kinematics_check_singularity`
请求示例:
```json
{
"msg": "singularity check",
"req_code": "CHECK_SINGULARITY_001",
"req_from": "client",
"req_cmd": "Cmd_Kinematics_check_singularity",
"req_param": {
"robot_uuid": "abb_irb120_3_58",
"joints_str": "0.15,-0.25,0.35,0.1,-0.2,0.3"
}
}
```
可选阈值参数:
```json
{
"singular_threshold": 0.0001,
"warning_threshold": 0.01,
"condition_threshold": 1000000,
"condition_warning_threshold": 10000
}
```
响应中的关键字段:
- `risk_level``normal``warning``singular`
- `is_singular`:是否已判定为奇异。
- `is_near_singular`:是否接近奇异。
- `rank`:雅可比矩阵秩。
- `singular_values`:奇异值数组。
- `min_singular_value`:最小奇异值。
- `condition_number`:条件数。
- `manipulability`:可操作度指标。
- `jacobian`6x6 TCP 雅可比矩阵。
注意:当前检测给出数值风险等级,尚未进一步分类为腕部奇异、肩部奇异或肘部奇异。
### 速度、加速度和 jerk 约束
当前主要约束位置上下限,尚未完整处理工业轨迹中的速度、加速度和 jerk 约束。
尚未支持:
- 关节速度限制。
- 关节加速度限制。
- 笛卡尔速度限制。
- 笛卡尔加速度限制。
- jerk 限制。
- 时间最优轨迹规划。
### 多 IK 解管理
工业机器人通常存在多个 IK 分支。当前模块没有显式管理多解分支。
尚未支持:
- 肘上/肘下选择。
- 腕翻转/不翻转选择。
- 肩部左右构型选择。
- 最接近当前姿态的解选择。
- 解连续性筛选。
### 轨迹连续性和最短路径
当前插补和 IK 可以生成关节序列,但还不是完整工业级轨迹规划。
尚未支持:
- 关节空间最短路径选择。
- 关节跨越 `+-pi` 时的连续性处理。
- 多点轨迹平滑。
- 速度连续和加速度连续约束。
- 轨迹失败点定位和恢复。
### 工具坐标系和工件坐标系
当前主要使用 URDF 链路末端作为 TCP尚未形成完整坐标系管理能力。
尚未支持:
- 工具坐标系 TCP 管理。
- 工件坐标系/用户坐标系管理。
- 基坐标、世界坐标、工具坐标之间的统一转换。
- 多 TCP 切换。
### 工业机器人指令语义
当前支持基础运动学计算,但还没有完整工业机器人指令层。
尚未支持:
- PTP 指令。
- LIN 指令。
- CIRC 指令。
- Blend/Zone 过渡。
- 等待、IO、夹具动作与轨迹同步。
- 程序段级离线编程。
### 动力学和负载
当前模块不处理动力学。
尚未支持:
- 质量和惯量参与计算。
- 关节力矩计算。
- 重力补偿。
- 负载模型。
- 动态可达性判断。
### 控制器通讯和实时控制
当前模块是 WASM 运动学计算内核,不是实时控制器。
尚未支持:
- 与真实机器人控制器通讯。
- 实时伺服控制。
- 关节反馈闭环。
- 安全互锁。
- 急停、安全区、限位开关等控制器级安全能力。
## 建议后续增强顺序
如果继续按工业机器人实际使用场景增强,建议优先级如下:
1. 增加多 IK 解和构型选择。
2. 增加关节连续性和最短路径处理。
3. 增加速度、加速度和轨迹时间参数化。
4. 增加工具坐标系和工件坐标系管理。
5. 增加 PTP、LIN、CIRC 等工业运动指令。
6. 增加碰撞检测。
7. 增加奇异类型分类,例如腕部、肩部、肘部奇异。
8. 增加控制器通讯和实时执行相关能力。

309
docs/urdf.xml Normal file
View File

@@ -0,0 +1,309 @@
<robot
xmlns:xacro="http://ros.org/wiki/xacro" name="ABB_IRB_120" uuid="8bc48ac0-6570-4232-aa91-3367771c1069">
<link name="base_link" uuid="9c88ec70-a337-4d16-94c5-92ccff72c1a5">
<inertial>
<mass value="1.0"/>
<origin rpy="0 0 0" xyz="0 0 0"/>
<inertia ixx="0.001" ixy="0" ixz="0" iyy="0.001" iyz="0" izz="0.001"/>
</inertial>
<visual>
<origin rpy="0 0 0" xyz="0 0 0"/>
<geometry>
<mesh filename="package://abb_irb120_support/meshes/irb120_3_58/visual/link_1.stl"/>
</geometry>
<material name="">
<color rgba="0.7372549 0.3490196 0.1607843 1"/>
</material>
</visual>
<collision>
<origin rpy="0 0 0" xyz="0 0 0"/>
<geometry>
<mesh filename="package://abb_irb120_support/meshes/irb120_3_58/collision/link_1.stl"/>
</geometry>
<material name="">
<color rgba="1 1 0 1"/>
</material>
</collision>
</link>
<link name="link_1" uuid="09059ca4-6c99-4a5f-a474-8ba964e612c4">
<inertial>
<mass value="1.0"/>
<origin rpy="0 0 0" xyz="0 0 0"/>
<inertia ixx="0.001" ixy="0" ixz="0" iyy="0.001" iyz="0" izz="0.001"/>
</inertial>
<visual>
<origin rpy="0 0 0" xyz="0 0 0"/>
<geometry>
<mesh filename="package://abb_irb120_support/meshes/irb120_3_58/visual/link_2.stl"/>
</geometry>
<material name="">
<color rgba="0.7372549 0.3490196 0.1607843 1"/>
</material>
</visual>
<collision>
<origin rpy="0 0 0" xyz="0 0 0"/>
<geometry>
<mesh filename="package://abb_irb120_support/meshes/irb120_3_58/collision/link_2.stl"/>
</geometry>
<material name="">
<color rgba="1 1 0 1"/>
</material>
</collision>
</link>
<link name="link_2" uuid="3f38944a-9e19-4aba-a46e-2cc2d2611b91">
<inertial>
<mass value="1.0"/>
<origin rpy="0 0 0" xyz="0 0 0"/>
<inertia ixx="0.001" ixy="0" ixz="0" iyy="0.001" iyz="0" izz="0.001"/>
</inertial>
<visual>
<origin rpy="0 0 0" xyz="0 0 0"/>
<geometry>
<mesh filename="package://abb_irb120_support/meshes/irb120_3_58/visual/link_3.stl"/>
</geometry>
<material name="">
<color rgba="0.7372549 0.3490196 0.1607843 1"/>
</material>
</visual>
<collision>
<origin rpy="0 0 0" xyz="0 0 0"/>
<geometry>
<mesh filename="package://abb_irb120_support/meshes/irb120_3_58/collision/link_3.stl"/>
</geometry>
<material name="">
<color rgba="1 1 0 1"/>
</material>
</collision>
</link>
<link name="link_3" uuid="801f8e99-88d8-4a92-b848-3c573c5e6ddb">
<inertial>
<mass value="1.0"/>
<origin rpy="0 0 0" xyz="0 0 0"/>
<inertia ixx="0.001" ixy="0" ixz="0" iyy="0.001" iyz="0" izz="0.001"/>
</inertial>
<visual>
<origin rpy="0 0 0" xyz="0 0 0"/>
<geometry>
<mesh filename="package://abb_irb120_support/meshes/irb120_3_58/visual/link_4.stl"/>
</geometry>
<material name="">
<color rgba="0.7372549 0.3490196 0.1607843 1"/>
</material>
</visual>
<collision>
<origin rpy="0 0 0" xyz="0 0 0"/>
<geometry>
<mesh filename="package://abb_irb120_support/meshes/irb120_3_58/collision/link_4.stl"/>
</geometry>
<material name="">
<color rgba="1 1 0 1"/>
</material>
</collision>
</link>
<link name="link_4" uuid="c0146e6e-b9c7-4ada-9e96-b7bb141f6feb">
<inertial>
<mass value="1.0"/>
<origin rpy="0 0 0" xyz="0 0 0"/>
<inertia ixx="0.001" ixy="0" ixz="0" iyy="0.001" iyz="0" izz="0.001"/>
</inertial>
<visual>
<origin rpy="0 0 0" xyz="0 0 0"/>
<geometry>
<mesh filename="package://abb_irb120_support/meshes/irb120_3_58/visual/link_5.stl"/>
</geometry>
<material name="">
<color rgba="0.7372549 0.3490196 0.1607843 1"/>
</material>
</visual>
<collision>
<origin rpy="0 0 0" xyz="0 0 0"/>
<geometry>
<mesh filename="package://abb_irb120_support/meshes/irb120_3_58/collision/link_5.stl"/>
</geometry>
<material name="">
<color rgba="1 1 0 1"/>
</material>
</collision>
</link>
<link name="link_5" uuid="5a650225-e5cf-44ae-b2fc-aad215668177">
<inertial>
<mass value="1.0"/>
<origin rpy="0 0 0" xyz="0 0 0"/>
<inertia ixx="0.001" ixy="0" ixz="0" iyy="0.001" iyz="0" izz="0.001"/>
</inertial>
<visual>
<origin rpy="0 0 0" xyz="0 0 0"/>
<geometry>
<mesh filename="package://abb_irb120_support/meshes/irb120_3_58/visual/link_6.stl"/>
</geometry>
<material name="">
<color rgba="0.7372549 0.3490196 0.1607843 1"/>
</material>
</visual>
<collision>
<origin rpy="0 0 0" xyz="0 0 0"/>
<geometry>
<mesh filename="package://abb_irb120_support/meshes/irb120_3_58/collision/link_6.stl"/>
</geometry>
<material name="">
<color rgba="1 1 0 1"/>
</material>
</collision>
</link>
<link name="link_6" uuid="4d20b9a3-23a5-48d8-bed6-b50439da66c0">
<inertial>
<mass value="1.0"/>
<origin rpy="0 0 0" xyz="0 0 0"/>
<inertia ixx="0.001" ixy="0" ixz="0" iyy="0.001" iyz="0" izz="0.001"/>
</inertial>
<visual>
<origin rpy="0 0 0" xyz="0 0 0"/>
<geometry>
<mesh filename="package://abb_irb120_support/meshes/irb120_3_58/visual/link_7.stl"/>
</geometry>
<material name="">
<color rgba="0.7372549 0.3490196 0.1607843 1"/>
</material>
</visual>
<collision>
<origin rpy="0 0 0" xyz="0 0 0"/>
<geometry>
<mesh filename="package://abb_irb120_support/meshes/irb120_3_58/collision/link_7.stl"/>
</geometry>
<material name="">
<color rgba="1 1 0 1"/>
</material>
</collision>
</link>
<joint name="joint_1" innerId="D0F7B07F-0438-4392-B775-42512D10BBD4" type="revolute">
<origin rpy="0.000000 0.000000 0.000000" xyz="0.000000 0.000000 0.000000"/>
<parent link="base_link" uuid="9c88ec70-a337-4d16-94c5-92ccff72c1a5"/>
<child link="link_1" uuid="09059ca4-6c99-4a5f-a474-8ba964e612c4"/>
<axis xyz="0 0 1"/>
<limit effort="0" lower="-6.283185" upper="6.283185" velocity="1.570796"/>
<dynamics damping="0.0" friction="0.0"/>
</joint>
<joint name="joint_2" innerId="3D80EDAC-92C7-492C-91FD-8108A298E175" type="revolute">
<origin rpy="0.000000 0.000000 0.000000" xyz="0.000000 0.000000 0.290000"/>
<parent link="link_1" uuid="09059ca4-6c99-4a5f-a474-8ba964e612c4"/>
<child link="link_2" uuid="3f38944a-9e19-4aba-a46e-2cc2d2611b91"/>
<axis xyz="1 0 0"/>
<limit effort="0" lower="-6.283185" upper="6.283185" velocity="1.570796"/>
<dynamics damping="0.0" friction="0.0"/>
</joint>
<joint name="joint_3" innerId="0A098978-9EAC-4F7B-B8DB-A1FFF640B6FF" type="revolute">
<origin rpy="0.000000 0.000000 0.000000" xyz="0.000000 0.000000 0.270000"/>
<parent link="link_2" uuid="3f38944a-9e19-4aba-a46e-2cc2d2611b91"/>
<child link="link_3" uuid="801f8e99-88d8-4a92-b848-3c573c5e6ddb"/>
<axis xyz="1 0 0"/>
<limit effort="0" lower="-6.283185" upper="6.283185" velocity="1.570796"/>
<dynamics damping="0.0" friction="0.0"/>
</joint>
<joint name="joint_4" innerId="823DDDE0-DBFA-4F5E-B3A6-9A87C06C8DB9" type="revolute">
<origin rpy="0.000000 0.000000 0.000000" xyz="0.000000 -0.134000 0.070000"/>
<parent link="link_3" uuid="801f8e99-88d8-4a92-b848-3c573c5e6ddb"/>
<child link="link_4" uuid="c0146e6e-b9c7-4ada-9e96-b7bb141f6feb"/>
<axis xyz="0 1 0"/>
<limit effort="0" lower="-6.283185" upper="6.283185" velocity="1.570796"/>
<dynamics damping="0.0" friction="0.0"/>
</joint>
<joint name="joint_5" innerId="A3DD936A-C88D-4267-8926-1C29C144D52E" type="revolute">
<origin rpy="0.000000 0.000000 0.000000" xyz="0.000000 -0.168000 0.000000"/>
<parent link="link_4" uuid="c0146e6e-b9c7-4ada-9e96-b7bb141f6feb"/>
<child link="link_5" uuid="5a650225-e5cf-44ae-b2fc-aad215668177"/>
<axis xyz="1 0 0"/>
<limit effort="0" lower="-6.283185" upper="6.283185" velocity="1.570796"/>
<dynamics damping="0.0" friction="0.0"/>
</joint>
<joint name="joint_6" innerId="85949D90-0C7B-463A-A18D-281A71FA4C19" type="revolute">
<origin rpy="0.000000 0.000000 0.000000" xyz="0.000000 -0.072000 0.000000"/>
<parent link="link_5" uuid="5a650225-e5cf-44ae-b2fc-aad215668177"/>
<child link="link_6" uuid="4d20b9a3-23a5-48d8-bed6-b50439da66c0"/>
<axis xyz="0 1 0"/>
<limit effort="0" lower="-6.283185" upper="6.283185" velocity="1.570796"/>
<dynamics damping="0.0" friction="0.0"/>
</joint>
<link name="base" uuidBase="f25bddf0-6533-448a-a1f5-b1ebbabe7d3c"/>
<joint name="base_link-base" type="fixed">
<origin xyz="0.000000 0.000000 0.000000" rpy="0.000000 0.000000 0.000000"/>
<parent link="base"/>
<child link="base_link"/>
</joint>
<link name="flange"/>
<joint name="joint_6-flange" type="fixed">
<origin rpy="0.000000 0.000000 0.000000" xyz="0.000000 0.000000 0.000000"/>
<parent link="link_6"/>
<child link="flange"/>
</joint>
<link name="tool0" uuidTool="5272238a-a622-412b-aa83-3e748fe57798"/>
<joint name="link_6-tool0" type="fixed">
<origin rpy="0.000000 0.000000 0.000000" xyz="0.000000 0.000000 0.000000"/>
<parent link="flange"/>
<child link="tool0"/>
</joint>
<transmission name="joint_1">
<type>transmission_interface/SimpleTransmission</type>
<joint>
<hardwareInterface>hardware_interface/EffortJointInterface</hardwareInterface>
</joint>
<actuator name="joint_1_motor">
<hardwareInterface>1</hardwareInterface>
<mechanicalReduction/>
</actuator>
</transmission>
<transmission name="joint_2">
<type>transmission_interface/SimpleTransmission</type>
<joint>
<hardwareInterface>hardware_interface/EffortJointInterface</hardwareInterface>
</joint>
<actuator name="joint_2_motor">
<hardwareInterface>1</hardwareInterface>
<mechanicalReduction/>
</actuator>
</transmission>
<transmission name="joint_3">
<type>transmission_interface/SimpleTransmission</type>
<joint>
<hardwareInterface>hardware_interface/EffortJointInterface</hardwareInterface>
</joint>
<actuator name="joint_3_motor">
<hardwareInterface>1</hardwareInterface>
<mechanicalReduction/>
</actuator>
</transmission>
<transmission name="joint_4">
<type>transmission_interface/SimpleTransmission</type>
<joint>
<hardwareInterface>hardware_interface/EffortJointInterface</hardwareInterface>
</joint>
<actuator name="joint_4_motor">
<hardwareInterface>1</hardwareInterface>
<mechanicalReduction/>
</actuator>
</transmission>
<transmission name="joint_5">
<type>transmission_interface/SimpleTransmission</type>
<joint>
<hardwareInterface>hardware_interface/EffortJointInterface</hardwareInterface>
</joint>
<actuator name="joint_5_motor">
<hardwareInterface>1</hardwareInterface>
<mechanicalReduction/>
</actuator>
</transmission>
<transmission name="joint_6">
<type>transmission_interface/SimpleTransmission</type>
<joint>
<hardwareInterface>hardware_interface/EffortJointInterface</hardwareInterface>
</joint>
<actuator name="joint_6_motor">
<hardwareInterface>1</hardwareInterface>
<mechanicalReduction/>
</actuator>
</transmission>
<gazebo>
<plugin name="gazebo_ros_control" filename="libgazebo_ros_control.so">
<robotNamespace>/</robotNamespace>
</plugin>
</gazebo>
</robot>

View File

@@ -5,9 +5,12 @@
#include <kdl/chainfksolverpos_recursive.hpp>
#include <kdl/chainiksolverpos_lma.hpp>
#include <kdl/chainiksolverpos_nr.hpp>
#include <kdl/chainiksolverpos_nr_jl.hpp>
#include <kdl/chainjnttojacsolver.hpp>
#include <kdl/chainiksolvervel_pinv.hpp>
#include <kdl/frames.hpp>
#include <kdl/jntarray.hpp>
#include <kdl/tree.hpp>
#include <string>
#include <unordered_map>
@@ -21,9 +24,13 @@ private:
KDL::Chain kinematicChain;
KDL::ChainFkSolverPos_recursive *fkSolver;
KDL::ChainIkSolverVel_pinv *ikVelSolver;
KDL::ChainIkSolverPos_NR *ikSolverNR;
KDL::ChainIkSolverPos_NR_JL *ikSolverNR;
KDL::ChainIkSolverPos_LMA *ikSolverLMA;
bool m_initialized;
KDL::JntArray jointLowerLimits;
KDL::JntArray jointUpperLimits;
std::vector<std::string> activeJointNames;
bool m_hasJointLimits;
// 记录 joint 名称到 child link UUID 的映射,便于前端回写对象姿态。
std::unordered_map<std::string, std::string> jointChildLinkUuidMap;
@@ -34,6 +41,16 @@ private:
int minSteps, int maxSteps,
double positionResolution, double orientationResolution);
bool parseJointChildLinkUuidsFromUrdf(const std::string &urdfString);
// 自动收集 KDL Tree 中没有子节点的末端 link 名称。
std::vector<std::string> collectLeafSegmentNames(const KDL::Tree &tree) const;
// 自动选择可用于正逆运动学的 6 轴串联链,避免固定依赖 base/tool0 命名。
bool selectKinematicChain(const KDL::Tree &tree, KDL::Chain &selectedChain) const;
// 从 URDF 中提取当前运动学链的关节上下限。
bool parseJointLimitsFromUrdf(const std::string &urdfString);
// 检查关节数组是否处于 URDF 定义的上下限范围内。
bool validateJointLimits(const double joints[6], const std::string &context) const;
// 检查关节向量是否处于 URDF 定义的上下限范围内。
bool validateJointLimits(const std::vector<double> &joints, const std::string &context) const;
public:
/**
@@ -177,6 +194,21 @@ public:
bool calculateFK_AllJointsforwardKinematics(const double joints[6], double jointPoses[42]);
bool forwardKinematics(const double joints[6], double jointPoses[42]);
/**
* @brief 检查当前关节姿态是否接近奇异点。
* @param joints_str 输入关节角,格式为 `j1,j2,j3,j4,j5,j6`。
* @param singularThreshold 最小奇异值低于该阈值时判定为奇异。
* @param warningThreshold 最小奇异值低于该阈值时判定为接近奇异。
* @param conditionThreshold 条件数超过该阈值时判定为奇异。
* @param conditionWarningThreshold 条件数超过该阈值时判定为接近奇异。
* @return 奇异点检测结果 JSON。
*/
json checkSingularity(const std::string &joints_str,
double singularThreshold = 1e-4,
double warningThreshold = 1e-2,
double conditionThreshold = 1e6,
double conditionWarningThreshold = 1e4);
/**
* @brief 获取当前 KDL 运动学链。
*/

View File

@@ -27,7 +27,7 @@
html,
body {
width: 100%;
min-height: 100%;
height: 100%;
}
body {
@@ -39,6 +39,7 @@
sans-serif;
color: var(--text);
background: var(--bg);
overflow: hidden;
}
button,
@@ -129,17 +130,24 @@
.app {
display: grid;
grid-template-columns: minmax(336px, 420px) minmax(0, 1fr);
min-height: 100dvh;
height: 100dvh;
min-height: 0;
overflow: hidden;
}
.side {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 0;
height: 100dvh;
overflow-x: hidden;
overflow-y: auto;
padding: 18px;
border-right: 1px solid var(--line);
background: var(--surface);
box-shadow: var(--shadow);
overscroll-behavior: contain;
z-index: 2;
}
@@ -233,7 +241,8 @@
.scene-wrap {
position: relative;
min-width: 0;
min-height: 100dvh;
min-height: 0;
height: 100dvh;
overflow: hidden;
background: #dfe7ec;
}
@@ -242,7 +251,6 @@
display: block;
width: 100%;
height: 100%;
min-height: 100dvh;
}
.scene-toolbar {
@@ -318,11 +326,21 @@
}
@media (max-width: 940px) {
body {
overflow: auto;
}
.app {
grid-template-columns: 1fr;
height: auto;
min-height: 100dvh;
overflow: visible;
}
.side {
height: auto;
max-height: none;
overflow: visible;
border-right: 0;
border-bottom: 1px solid var(--line);
box-shadow: none;
@@ -330,7 +348,8 @@
.scene-wrap,
#scene {
min-height: 58dvh;
height: 58dvh;
min-height: 360px;
}
}
@@ -382,9 +401,16 @@
<label for="robot-uuid">robot_uuid</label>
<input id="robot-uuid" value="abb_irb120_3_58" />
</div>
<div class="field full">
<label for="urdf-file">URDF 文件</label>
<input id="urdf-file" type="file" accept=".xml,.urdf" />
</div>
</div>
<div class="actions">
<button id="init-btn" type="button">初始化</button>
<button id="register-urdf-btn" class="secondary" type="button">
注册 URDF
</button>
<button id="reset-btn" class="secondary" type="button">零位</button>
</div>
</section>
@@ -394,6 +420,9 @@
<div id="joint-controls"></div>
<div class="actions">
<button id="fk-btn" type="button">正解</button>
<button id="singularity-btn" class="secondary" type="button">
奇异点
</button>
<button id="target-current-btn" class="secondary" type="button">
取当前位姿
</button>
@@ -466,6 +495,10 @@
<span class="metric-label">关节数</span>
<span class="metric-value" id="joint-count-value">0</span>
</div>
<div class="metric">
<span class="metric-label">奇异风险</span>
<span class="metric-value" id="singularity-value">-</span>
</div>
<div class="metric">
<span class="metric-label">three.js</span>
<span class="metric-value">0.184.0</span>
@@ -490,6 +523,8 @@
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import SmartWasmAPI from "./wasm/smart_api_wrapper.js";
THREE.Object3D.DEFAULT_UP.set(0, 0, 1);
const api = new SmartWasmAPI();
const jointCount = 6;
const state = {
@@ -505,6 +540,7 @@
wasmStatus: document.getElementById("wasm-status"),
solverStatus: document.getElementById("solver-status"),
robotUuid: document.getElementById("robot-uuid"),
urdfFile: document.getElementById("urdf-file"),
jointControls: document.getElementById("joint-controls"),
requestJson: document.getElementById("request-json"),
responseJson: document.getElementById("response-json"),
@@ -517,10 +553,13 @@
targetQw: document.getElementById("target-qw"),
tcpValue: document.getElementById("tcp-value"),
jointCountValue: document.getElementById("joint-count-value"),
singularityValue: document.getElementById("singularity-value"),
toast: document.getElementById("toast"),
initBtn: document.getElementById("init-btn"),
registerUrdfBtn: document.getElementById("register-urdf-btn"),
resetBtn: document.getElementById("reset-btn"),
fkBtn: document.getElementById("fk-btn"),
singularityBtn: document.getElementById("singularity-btn"),
ikBtn: document.getElementById("ik-btn"),
applyIkBtn: document.getElementById("apply-ik-btn"),
targetCurrentBtn: document.getElementById("target-current-btn"),
@@ -530,7 +569,8 @@
scene.background = new THREE.Color(0xdfe7ec);
const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 80);
camera.position.set(1.1, -1.75, 1.05);
camera.up.set(0, 0, 1);
camera.position.set(1.18, -1.68, 0.92);
const renderer = new THREE.WebGLRenderer({
canvas: elements.canvas,
@@ -542,7 +582,24 @@
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.screenSpacePanning = false;
controls.target.set(0.18, 0, 0.34);
controls.update();
window.__robotKinematicsDebug = {
// 获取当前相机状态,用于验证按钮操作不会改变视角。
getCameraState() {
return {
position: camera.position.toArray(),
up: camera.up.toArray(),
target: controls.target.toArray(),
};
},
// 获取 three.js 默认上方向,用于验证场景为 Z 轴向上。
getDefaultUp() {
return THREE.Object3D.DEFAULT_UP.toArray();
},
};
const robotGroup = new THREE.Group();
const targetGroup = new THREE.Group();
@@ -683,6 +740,20 @@
};
}
// 生成奇异点检测请求。
function buildSingularityRequest() {
return {
msg: "threejs singularity check",
req_code: `THREE_SINGULARITY_${Date.now()}`,
req_from: "threejs_test",
req_cmd: "Cmd_Kinematics_check_singularity",
req_param: {
robot_uuid: elements.robotUuid.value.trim(),
joints_str: buildJointString(),
},
};
}
// 生成逆解请求。
function buildInverseRequest() {
const pose = [
@@ -736,6 +807,67 @@
}
}
// 读取本地 URDF 文件并注册为当前 robot_uuid。
async function registerUrdfFromFile() {
await ensureInitialized();
const file = elements.urdfFile.files?.[0];
const robotUuid = elements.robotUuid.value.trim();
if (!file) {
showToast("请选择 URDF 文件");
return;
}
if (!robotUuid) {
showToast("请先填写 robot_uuid");
return;
}
setBusy(true, "正在注册 URDF");
try {
const urdfText = await file.text();
const request = {
msg: "threejs init robot from urdf",
req_code: `THREE_INIT_URDF_${Date.now()}`,
req_from: "threejs_test",
req_cmd: "Cmd_InitRobot",
req_param: {
robot_uuid: robotUuid,
force_update: true,
urdf_base64: encodeUtf8Base64(urdfText),
},
};
renderJson(request, null);
const response = await api.processBusinessRequest(request);
renderJson(request, response);
if (response.success && response.res_data?.success) {
state.latestIkJoints = null;
elements.applyIkBtn.disabled = true;
setStatus("URDF 注册完成", true);
await runForwardKinematics();
} else {
setStatus("URDF 注册失败", false);
showToast(response.res_data?.message || response.msg || "URDF 注册失败");
}
} catch (error) {
showError(error);
} finally {
setBusy(false);
}
}
// 将 URDF 文本编码成接口需要的 UTF-8 Base64 字符串。
function encodeUtf8Base64(value) {
const bytes = new TextEncoder().encode(value);
let binary = "";
bytes.forEach((byte) => {
binary += String.fromCharCode(byte);
});
return btoa(binary);
}
// 确保调用算法前已经完成初始化。
async function ensureInitialized() {
if (!state.initialized) {
@@ -774,6 +906,27 @@
}
}
// 执行奇异点检测并显示风险等级。
async function runSingularityCheck() {
await ensureInitialized();
setBusy(true, "奇异点检测中");
const request = buildSingularityRequest();
try {
renderJson(request, null);
const response = await api.processBusinessRequest(request);
renderJson(request, response);
updateSingularityMetric(response?.res_data);
const ok = response.success && response.res_data?.success;
setStatus(ok ? "奇异点检测完成" : "奇异点检测失败", Boolean(ok));
} catch (error) {
showError(error);
} finally {
setBusy(false);
}
}
// 执行逆解并保存待应用的关节解。
async function runInverseKinematics() {
await ensureInitialized();
@@ -906,7 +1059,6 @@
.join(", ");
}
fitCamera(points);
}
// 创建两个关节点之间的连杆圆柱。
@@ -930,23 +1082,6 @@
return mesh;
}
// 根据当前骨架范围调整相机目标。
function fitCamera(points) {
if (!points.length) {
return;
}
const box = new THREE.Box3().setFromPoints(points);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3()).length();
controls.target.copy(center);
if (size > 0) {
camera.near = 0.01;
camera.far = Math.max(20, size * 18);
camera.updateProjectionMatrix();
}
}
// 清空三维分组。
function clearGroup(group) {
while (group.children.length > 0) {
@@ -1002,7 +1137,6 @@
targetMaterial,
);
marker.position.fromArray(targetPose.position);
marker.rotation.x = Math.PI / 2;
targetGroup.add(marker);
const axes = new THREE.AxesHelper(0.18);
@@ -1024,12 +1158,30 @@
}
}
// 更新奇异点风险指标。
function updateSingularityMetric(result) {
if (!result?.success) {
elements.singularityValue.textContent = "-";
return;
}
const riskText = {
normal: "正常",
warning: "接近",
singular: "奇异",
}[result.risk_level] ?? result.risk_level;
const minSv = Number(result.min_singular_value);
elements.singularityValue.textContent = `${riskText} / ${Number.isFinite(minSv) ? minSv.toExponential(2) : "-"}`;
}
// 设置按钮忙碌状态。
function setBusy(isBusy, label) {
[
elements.initBtn,
elements.registerUrdfBtn,
elements.resetBtn,
elements.fkBtn,
elements.singularityBtn,
elements.ikBtn,
elements.targetCurrentBtn,
].forEach((button) => {
@@ -1094,8 +1246,10 @@
}
elements.initBtn.addEventListener("click", initializeWasm);
elements.registerUrdfBtn.addEventListener("click", registerUrdfFromFile);
elements.resetBtn.addEventListener("click", resetJoints);
elements.fkBtn.addEventListener("click", runForwardKinematics);
elements.singularityBtn.addEventListener("click", runSingularityCheck);
elements.ikBtn.addEventListener("click", runInverseKinematics);
elements.applyIkBtn.addEventListener("click", applyInverseSolution);
elements.targetCurrentBtn.addEventListener("click", updateTcpTargetFromCurrent);

View File

@@ -133,6 +133,7 @@ $compileResult = emcc `
-s USE_PTHREADS=0 `
-O3 `
-Wno-deprecated-literal-operator `
-Wno-deprecated-declarations `
-o public/wasm/smart_math.js 2>&1
$endTime = Get-Date

View File

@@ -142,6 +142,91 @@ function buildInverseRequest(command, robotUuid, poseStr, qInitStr, extra = {})
};
}
function buildSingularityRequest(robotUuid, joints) {
return {
msg: "singularity check",
req_code: "AUTO_SINGULARITY",
req_from: "wasm_test",
req_cmd: "Cmd_Kinematics_check_singularity",
req_param: {
robot_uuid: robotUuid,
joints_str: jointArrayToString(joints),
},
};
}
// 构造 link 名称被整体替换的 URDF用于验证初始化能自动推导运动学链。
function buildRenamedUrdfFromDocs() {
const urdfPath = path.join(rootDir, "docs", "urdf.xml");
return fs
.readFileSync(urdfPath, "utf8")
.replaceAll("base_link-base", "renamed_root_link-renamed_world")
.replaceAll("base_link", "renamed_root_link")
.replaceAll("tool0", "renamed_tcp")
.replaceAll('link name="base"', 'link name="renamed_world"')
.replaceAll('parent link="base"', 'parent link="renamed_world"');
}
// 注册改名后的 URDF 并执行一次 FK确认底层不再依赖固定 base/tool0。
async function runRenamedUrdfChainCase(module) {
const robotUuid = "renamed_chain_robot";
const urdfBase64 = Buffer.from(buildRenamedUrdfFromDocs(), "utf8").toString("base64");
const initResponse = callBusinessApi(module, {
msg: "init renamed urdf",
req_code: "AUTO_RENAMED_INIT",
req_from: "wasm_test",
req_cmd: "Cmd_InitRobot",
req_param: {
robot_uuid: robotUuid,
urdf_base64: urdfBase64,
force_update: true,
},
});
ensure(initResponse.success === true, "renamed URDF init request failed");
ensure(getByPath(initResponse, "res_data.success") === true, "renamed URDF init result is not successful");
const forwardResponse = callBusinessApi(module, buildForwardRequest(robotUuid, [0, 0, 0, 0, 0, 0]));
ensure(forwardResponse.success === true, "renamed URDF forward request failed");
ensure(getByPath(forwardResponse, "res_data.success") === true, "renamed URDF forward result is not successful");
ensure(Array.isArray(getByPath(forwardResponse, "res_data.position")), "renamed URDF forward position is missing");
return {
details: {
position: getByPath(forwardResponse, "res_data.position"),
orientation: getByPath(forwardResponse, "res_data.orientation"),
},
};
}
// 发送超过 URDF 关节上下限的 FK 请求,确认底层会拒绝计算。
async function runJointLimitCase(module) {
const response = callBusinessApi(module, buildForwardRequest("abb_irb120_3_58", [7, 0, 0, 0, 0, 0]));
ensure(response.success === false, "joint limit case should fail at response level");
ensure(String(getByPath(response, "res_data.error")).includes("Forward kinematics calculation failed"), "joint limit error is missing");
return response;
}
// 检查奇异点分析接口会返回完整的雅可比 SVD 指标。
async function runSingularityCheckCase(module) {
const response = callBusinessApi(module, buildSingularityRequest("abb_irb120_3_58", [0.15, -0.25, 0.35, 0.1, -0.2, 0.3]));
ensure(response.success === true, "singularity check request failed");
ensure(getByPath(response, "res_data.success") === true, "singularity check result is not successful");
ensure(Array.isArray(getByPath(response, "res_data.singular_values")), "singular values are missing");
ensure(getByPath(response, "res_data.singular_values").length === 6, "singular value count should be 6");
ensure(typeof getByPath(response, "res_data.condition_number") === "number", "condition number is missing");
ensure(typeof getByPath(response, "res_data.risk_level") === "string", "risk level is missing");
return {
details: {
riskLevel: getByPath(response, "res_data.risk_level"),
rank: getByPath(response, "res_data.rank"),
minSingularValue: getByPath(response, "res_data.min_singular_value"),
conditionNumber: getByPath(response, "res_data.condition_number"),
},
};
}
function assertStaticCase(response, assertions) {
for (const assertion of assertions) {
const actual = getByPath(response, assertion.path);
@@ -353,6 +438,18 @@ const suite = [
{ path: "res_data.OPERATION.OPERATION.frames", lengthEquals: 2 },
],
},
{
id: "kinematics_renamed_urdf_chain",
type: "renamed_urdf_chain",
},
{
id: "kinematics_joint_limit_rejects_fk",
type: "joint_limit",
},
{
id: "kinematics_singularity_check",
type: "singularity_check",
},
{
id: "spc_basic_5x30",
type: "static",
@@ -522,6 +619,12 @@ async function runCase(module, testCase) {
return runRoundtripSingleCase(module, testCase);
case "roundtrip_path":
return runRoundtripPathCase(module, testCase);
case "renamed_urdf_chain":
return runRenamedUrdfChainCase(module, testCase);
case "joint_limit":
return runJointLimitCase(module, testCase);
case "singularity_check":
return runSingularityCheckCase(module, testCase);
default:
throw new Error(`Unknown test type: ${testCase.type}`);
}

3
spc-vue/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules
dist
*.local

34
spc-vue/README.md Normal file
View File

@@ -0,0 +1,34 @@
# SPC 控制图 Vue 验证项目
这个目录是一个独立的 Vue 3 + Vite + ECharts 项目,用于验证 `Cmd_Spc` 的 WASM 计算结果。每一种控制图都拆成独立路由,方便后续把单个页面或组件集成到其他项目。
## 本地运行
```powershell
cd spc-vue
npm install
npm run dev
```
开发环境会通过 Vite 插件把主项目的 `../public/wasm` 映射到 `/wasm`。执行 `npm run build` 时会自动把 `smart_math.js``smart_math.wasm` 复制到 `dist/wasm`
## 页面路由
- `/overview`SPC 总览和关键指标
- `/run-chart`:基本趋势图,显示原始序列、均值线、中位线、规格限和移动平均线
- `/histogram`:频数直方图,只显示分箱频数和规格限,不叠加正态曲线
- `/normal-curve`:正态曲线,单独显示拟合分布和规格限
- `/xbar-r`Xbar 控制图,控制限来自 XR 结果
- `/r-chart`R 极差控制图
- `/xbar-s`Xbar 控制图,控制限来自 XS 结果
- `/s-chart`S 标准差控制图
- `/cpk`:过程能力指标
## 接口校验重点
`KinematicsWebAPI::func` 会把业务返回统一包装成顶层 `success: true`,所以页面不会只判断顶层 `success`。SPC 调用成功必须同时满足:
- 顶层 `success !== false`
- `res_data` 存在
- `res_data.error` 不存在
- `res_data.XR``res_data.XS``res_data.Cpk` 都存在

13
spc-vue/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:," />
<title>SPC 控制图 Vue 验证项目</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2182
spc-vue/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
spc-vue/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "spc-control-chart-vue",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"echarts": "^5.5.1",
"vue": "^3.4.38",
"vue-router": "^4.4.3"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.2",
"playwright": "^1.61.1",
"typescript": "^5.5.4",
"vite": "^5.4.2",
"vue-tsc": "^2.0.29"
}
}

39
spc-vue/src/App.vue Normal file
View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import { computed, onMounted } from "vue";
import { RouterLink, RouterView } from "vue-router";
import SpcInputPanel from "./components/SpcInputPanel.vue";
import { useSpc } from "./composables/useSpc";
import { routes } from "./router";
const { result, runSpc } = useSpc();
const navRoutes = computed(() => routes.filter((route) => route.path !== "/" && route.meta?.title));
onMounted(() => {
if (!result.value) {
void runSpc();
}
});
</script>
<template>
<div class="app-shell">
<header class="topbar">
<div>
<p class="eyebrow">SPC Vue</p>
<h1>SPC 控制图验证</h1>
</div>
<nav class="nav-tabs" aria-label="控制图页面">
<RouterLink v-for="route in navRoutes" :key="route.path" :to="route.path">
{{ route.meta?.title }}
</RouterLink>
</nav>
</header>
<main class="workspace">
<SpcInputPanel />
<section class="content-pane">
<RouterView />
</section>
</main>
</div>
</template>

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
import * as echarts from "echarts";
import type { EChartsOption } from "echarts";
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
const props = defineProps<{
title: string;
eyebrow: string;
description: string;
option: EChartsOption | null;
emptyText?: string;
}>();
const chartElement = ref<HTMLDivElement | null>(null);
let chart: echarts.ECharts | null = null;
let resizeObserver: ResizeObserver | null = null;
// 初始化图表实例,并监听容器尺寸变化保持 ECharts 自适应。
function ensureChart() {
if (!chartElement.value || chart) return;
chart = echarts.init(chartElement.value);
resizeObserver = new ResizeObserver(() => chart?.resize());
resizeObserver.observe(chartElement.value);
}
// 按当前 option 刷新图表,等待 v-if 容器挂载后再初始化,支持直接进入子路由。
async function renderChart() {
if (!props.option) {
chart?.dispose();
chart = null;
return;
}
await nextTick();
ensureChart();
chart?.setOption(props.option, true);
}
onMounted(renderChart);
watch(() => props.option, renderChart, { deep: true });
onBeforeUnmount(() => {
resizeObserver?.disconnect();
chart?.dispose();
});
</script>
<template>
<section class="chart-card">
<header class="chart-card__header">
<div>
<p class="eyebrow">{{ eyebrow }}</p>
<h2>{{ title }}</h2>
</div>
</header>
<div v-if="option" ref="chartElement" class="chart-card__canvas" />
<div v-else class="chart-card__empty">{{ emptyText || "等待计算" }}</div>
<p class="chart-card__description">{{ description }}</p>
</section>
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
defineProps<{
value: unknown;
}>();
</script>
<template>
<details class="json-preview">
<summary>查看原始接口响应</summary>
<pre>{{ JSON.stringify(value || {}, null, 2) }}</pre>
</details>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
defineProps<{
metrics: Array<{ label: string; value: string | number; tone?: "ok" | "warn" | "neutral" }>;
}>();
</script>
<template>
<div class="metric-strip">
<div v-for="metric in metrics" :key="metric.label" class="metric-tile">
<span>{{ metric.label }}</span>
<strong :class="metric.tone || 'neutral'">{{ metric.value }}</strong>
</div>
</div>
</template>

View File

@@ -0,0 +1,54 @@
<script setup lang="ts">
import { useSpc } from "../composables/useSpc";
const { form, parsedCount, loading, statusText, errorMessage, formatValues, loadSample, runSpc } = useSpc();
</script>
<template>
<aside class="input-panel" aria-label="SPC 输入">
<div class="panel-title-row">
<div>
<p class="eyebrow">Cmd_Spc</p>
<h2>计算输入</h2>
</div>
<span class="status-pill">{{ statusText }}</span>
</div>
<div class="form-grid">
<label>
<span>n 子组容量</span>
<input v-model.number="form.n" type="number" min="2" step="1" />
</label>
<label>
<span>k 子组数</span>
<input v-model.number="form.k" type="number" min="1" step="1" />
</label>
<label>
<span>USL 上规格限</span>
<input v-model.number="form.usl" type="number" step="0.001" />
</label>
<label>
<span>LSL 下规格限</span>
<input v-model.number="form.lsl" type="number" step="0.001" />
</label>
</div>
<label class="data-field">
<span>测量数据 x</span>
<textarea v-model="form.valuesText" spellcheck="false" />
</label>
<div class="input-panel__footer">
<span class="count-text">当前 {{ parsedCount }} 个数据</span>
<div class="button-row">
<button type="button" class="secondary-button" @click="loadSample">载入样例</button>
<button type="button" class="secondary-button" @click="formatValues">格式化</button>
<button type="button" class="primary-button" :disabled="loading" @click="runSpc">
{{ loading ? "计算中" : "调用接口" }}
</button>
</div>
</div>
<p v-if="errorMessage" class="error-message" role="alert">{{ errorMessage }}</p>
</aside>
</template>

View File

@@ -0,0 +1,133 @@
import { computed, reactive, readonly, ref } from "vue";
import { sampleSpcInput } from "../data/sampleSpc";
import { callSpc } from "../services/wasmSpcClient";
import type { SpcInput, SpcResult, WebApiResponse } from "../types/spc";
const form = reactive({
n: sampleSpcInput.n,
k: sampleSpcInput.k,
usl: sampleSpcInput.usl,
lsl: sampleSpcInput.lsl,
valuesText: sampleSpcInput.x.join(", ")
});
const result = ref<SpcResult | null>(null);
const rawResponse = ref<WebApiResponse | null>(null);
const errorMessage = ref("");
const statusText = ref("未执行");
const loading = ref(false);
// 将文本框中的数字解析成数组,支持逗号、空格和换行分隔。
function parseValues(text: string): number[] {
const values = text
.split(/[\s,;]+/)
.map((item) => item.trim())
.filter(Boolean)
.map(Number);
if (values.some((value) => !Number.isFinite(value))) {
throw new Error("测量数据中存在非数字内容");
}
return values;
}
// 从表单生成 SPC 输入,并校验 n、k、规格限和数据量是否匹配。
function readInput(): SpcInput {
const input: SpcInput = {
n: Number(form.n),
k: Number(form.k),
usl: Number(form.usl),
lsl: Number(form.lsl),
x: parseValues(form.valuesText)
};
if (!Number.isInteger(input.n) || input.n < 2) {
throw new Error("n 必须是大于等于 2 的整数");
}
if (!Number.isInteger(input.k) || input.k < 1) {
throw new Error("k 必须是大于等于 1 的整数");
}
if (!Number.isFinite(input.usl) || !Number.isFinite(input.lsl) || input.usl <= input.lsl) {
throw new Error("USL 必须大于 LSL");
}
if (input.x.length !== input.n * input.k) {
throw new Error(`数据量应为 n*k=${input.n * input.k},当前为 ${input.x.length}`);
}
return input;
}
// 格式化输入数据,按子组容量 n 分行,方便核对原始数据。
function formatValues() {
const values = parseValues(form.valuesText);
const lines: string[] = [];
for (let index = 0; index < values.length; index += Number(form.n) || 5) {
lines.push(values.slice(index, index + (Number(form.n) || 5)).join(", "));
}
form.valuesText = lines.join("\n");
}
// 恢复内置样例数据。
function loadSample() {
form.n = sampleSpcInput.n;
form.k = sampleSpcInput.k;
form.usl = sampleSpcInput.usl;
form.lsl = sampleSpcInput.lsl;
form.valuesText = sampleSpcInput.x.join(", ");
errorMessage.value = "";
statusText.value = "样例已载入";
}
// 执行 SPC 接口调用,并保存原始响应和解包后的业务结果。
async function runSpc() {
loading.value = true;
errorMessage.value = "";
statusText.value = "计算中";
try {
const input = readInput();
const response = await callSpc(input);
rawResponse.value = response.response;
result.value = response.result;
statusText.value = "计算完成";
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
errorMessage.value = message;
statusText.value = "计算失败";
} finally {
loading.value = false;
}
}
export function useSpc() {
const parsedCount = computed(() => {
try {
return parseValues(form.valuesText).length;
} catch {
return 0;
}
});
const currentInput = computed(() => {
try {
return readInput();
} catch {
return null;
}
});
return {
form,
result: readonly(result),
rawResponse: readonly(rawResponse),
errorMessage: readonly(errorMessage),
statusText: readonly(statusText),
loading: readonly(loading),
parsedCount,
currentInput,
formatValues,
loadSample,
runSpc
};
}

3
spc-vue/src/config.ts Normal file
View File

@@ -0,0 +1,3 @@
export const appConfig = {
wasmScriptUrl: "/wasm/smart_math.js"
};

View File

@@ -0,0 +1,25 @@
import type { SpcInput } from "../types/spc";
export const sampleSpcInput: SpcInput = {
n: 5,
k: 30,
usl: 1.7,
lsl: 1.5,
x: [
1.55, 1.58, 1.61, 1.6, 1.6, 1.58, 1.63, 1.63, 1.62, 1.63,
1.62, 1.63, 1.62, 1.59, 1.58, 1.58, 1.6, 1.61, 1.62, 1.63,
1.58, 1.64, 1.63, 1.62, 1.62, 1.62, 1.62, 1.63, 1.61, 1.57,
1.64, 1.62, 1.61, 1.6, 1.58, 1.57, 1.59, 1.61, 1.62, 1.63,
1.58, 1.61, 1.6, 1.62, 1.63, 1.6, 1.61, 1.64, 1.64, 1.63,
1.58, 1.6, 1.62, 1.63, 1.65, 1.62, 1.58, 1.59, 1.57, 1.58,
1.57, 1.57, 1.58, 1.59, 1.64, 1.61, 1.64, 1.62, 1.6, 1.59,
1.65, 1.62, 1.62, 1.6, 1.58, 1.57, 1.59, 1.57, 1.59, 1.62,
1.56, 1.57, 1.57, 1.61, 1.62, 1.56, 1.58, 1.59, 1.6, 1.62,
1.58, 1.6, 1.6, 1.62, 1.63, 1.58, 1.59, 1.6, 1.63, 1.62,
1.58, 1.59, 1.62, 1.63, 1.64, 1.58, 1.59, 1.62, 1.63, 1.61,
1.58, 1.59, 1.6, 1.61, 1.63, 1.57, 1.59, 1.61, 1.61, 1.62,
1.58, 1.58, 1.6, 1.61, 1.63, 1.62, 1.58, 1.58, 1.58, 1.57,
1.63, 1.59, 1.57, 1.58, 1.57, 1.58, 1.62, 1.61, 1.63, 1.61,
1.58, 1.57, 1.59, 1.6, 1.62, 1.62, 1.6, 1.6, 1.57, 1.57
]
};

6
spc-vue/src/main.ts Normal file
View File

@@ -0,0 +1,6 @@
import { createApp } from "vue";
import App from "./App.vue";
import { router } from "./router";
import "./styles.css";
createApp(App).use(router).mount("#app");

28
spc-vue/src/router.ts Normal file
View File

@@ -0,0 +1,28 @@
import { createRouter, createWebHashHistory } from "vue-router";
import OverviewView from "./views/OverviewView.vue";
import XBarRView from "./views/XBarRView.vue";
import RChartView from "./views/RChartView.vue";
import XBarSView from "./views/XBarSView.vue";
import SChartView from "./views/SChartView.vue";
import CpkView from "./views/CpkView.vue";
import RunChartView from "./views/RunChartView.vue";
import HistogramView from "./views/HistogramView.vue";
import NormalCurveView from "./views/NormalCurveView.vue";
export const routes = [
{ path: "/", redirect: "/overview" },
{ path: "/overview", component: OverviewView, meta: { title: "总览" } },
{ path: "/run-chart", component: RunChartView, meta: { title: "趋势图" } },
{ path: "/histogram", component: HistogramView, meta: { title: "直方图" } },
{ path: "/normal-curve", component: NormalCurveView, meta: { title: "正态曲线" } },
{ path: "/xbar-r", component: XBarRView, meta: { title: "Xbar-R" } },
{ path: "/r-chart", component: RChartView, meta: { title: "R 图" } },
{ path: "/xbar-s", component: XBarSView, meta: { title: "Xbar-S" } },
{ path: "/s-chart", component: SChartView, meta: { title: "S 图" } },
{ path: "/cpk", component: CpkView, meta: { title: "Cpk" } }
];
export const router = createRouter({
history: createWebHashHistory(),
routes
});

View File

@@ -0,0 +1,136 @@
import { appConfig } from "../config";
import type { SpcInput, SpcRequest, SpcResult, WebApiResponse } from "../types/spc";
type SmartMathModule = {
_init_func: () => number;
_func: (requestPtr: number) => number;
_smart_free_string: (ptr: number) => void;
_malloc: (size: number) => number;
_free: (ptr: number) => void;
lengthBytesUTF8: (value: string) => number;
stringToUTF8: (value: string, ptr: number, size: number) => void;
UTF8ToString: (ptr: number) => string;
};
declare global {
interface Window {
createSmartMathModule?: (options?: {
locateFile?: (fileName: string) => string;
noInitialRun?: boolean;
}) => Promise<SmartMathModule>;
}
}
let scriptPromise: Promise<void> | null = null;
let modulePromise: Promise<SmartMathModule> | null = null;
// 动态加载 Emscripten 生成的 JS 包,避免 Vue 项目直接绑定全局 script 标签。
function loadWasmScript(scriptUrl: string): Promise<void> {
if (window.createSmartMathModule) {
return Promise.resolve();
}
if (!scriptPromise) {
scriptPromise = new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = scriptUrl;
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error(`WASM 脚本加载失败: ${scriptUrl}`));
document.head.appendChild(script);
});
}
return scriptPromise;
}
// 初始化 WASM 模块,内部只执行一次 _init_func。
async function getWasmModule(): Promise<SmartMathModule> {
if (!modulePromise) {
modulePromise = (async () => {
await loadWasmScript(appConfig.wasmScriptUrl);
if (!window.createSmartMathModule) {
throw new Error("WASM 工厂函数 createSmartMathModule 不存在");
}
const wasmDir = appConfig.wasmScriptUrl.replace(/\/[^/]*$/, "");
const module = await window.createSmartMathModule({
noInitialRun: true,
locateFile: (fileName) => `${wasmDir}/${fileName}`
});
const initPtr = module._init_func();
try {
module.UTF8ToString(initPtr);
} finally {
module._smart_free_string(initPtr);
}
return module;
})();
}
return modulePromise;
}
// 在 WASM 线性内存中写入 UTF-8 字符串,并返回指针。
function allocString(module: SmartMathModule, value: string): number {
const size = module.lengthBytesUTF8(value) + 1;
const ptr = module._malloc(size);
module.stringToUTF8(value, ptr, size);
return ptr;
}
// 构建 SPC 业务请求,保持与现有 Cmd_Spc 测试数据一致。
export function buildSpcRequest(input: SpcInput): SpcRequest {
return {
msg: "spc vue validation",
req_code: "SPC_VUE_VALIDATE",
req_from: "spc_vue",
req_cmd: "Cmd_Spc",
req_param: input
};
}
// 校验业务层 SPC 返回,不能只看顶层 success要检查 res_data 的错误和完整字段。
export function unwrapSpcResponse(response: WebApiResponse): SpcResult {
if (response.success === false) {
throw new Error(typeof response.error === "string" ? response.error : "接口顶层 success=false");
}
const data = response.res_data;
if (!data) {
throw new Error("接口缺少 res_data");
}
if (data.error) {
throw new Error(data.error);
}
if (!data.XR || !data.XS || !data.Cpk) {
throw new Error("SPC 结果不完整,需要同时包含 res_data.XR、res_data.XS、res_data.Cpk");
}
return data;
}
// 调用 WASM _func 并返回经过业务完整性校验的 SPC 结果。
export async function callSpc(input: SpcInput): Promise<{ response: WebApiResponse; result: SpcResult }> {
const module = await getWasmModule();
const payload = JSON.stringify(buildSpcRequest(input));
let requestPtr = 0;
let responsePtr = 0;
try {
requestPtr = allocString(module, payload);
responsePtr = module._func(requestPtr);
const response = JSON.parse(module.UTF8ToString(responsePtr)) as WebApiResponse;
return {
response,
result: unwrapSpcResponse(response)
};
} finally {
if (responsePtr) {
module._smart_free_string(responsePtr);
}
if (requestPtr) {
module._free(requestPtr);
}
}
}

478
spc-vue/src/styles.css Normal file
View File

@@ -0,0 +1,478 @@
:root {
color-scheme: light;
--bg: #f4f6f8;
--surface: #ffffff;
--surface-soft: #f8fafc;
--line: #d7dde5;
--line-strong: #b8c2cf;
--text: #172033;
--muted: #667085;
--primary: #0f766e;
--primary-strong: #115e59;
--danger: #b42318;
--warning: #b45309;
--ok: #15803d;
--code-bg: #111827;
--code-text: #edf2f7;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: "Segoe UI", "Microsoft YaHei", Arial, sans-serif;
}
button,
input,
textarea {
font: inherit;
}
button {
min-height: 44px;
border: 1px solid transparent;
border-radius: 8px;
padding: 9px 14px;
font-weight: 700;
cursor: pointer;
transition:
background-color 160ms ease,
border-color 160ms ease,
color 160ms ease;
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
button:focus-visible,
a:focus-visible,
input:focus-visible,
textarea:focus-visible,
summary:focus-visible {
outline: 3px solid rgba(15, 118, 110, 0.24);
outline-offset: 2px;
}
h1,
h2,
h3,
p {
margin: 0;
}
h1 {
font-size: 24px;
line-height: 1.2;
}
h2 {
font-size: 19px;
line-height: 1.3;
}
h3 {
font-size: 16px;
line-height: 1.35;
}
.app-shell {
width: min(1480px, 100%);
margin: 0 auto;
padding: 20px;
}
.topbar {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 18px;
margin-bottom: 16px;
}
.eyebrow {
color: var(--primary);
font-size: 12px;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
}
.nav-tabs {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.nav-tabs a {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 40px;
border: 1px solid var(--line);
border-radius: 8px;
padding: 8px 12px;
background: var(--surface);
color: var(--muted);
text-decoration: none;
font-size: 14px;
font-weight: 700;
}
.nav-tabs a.router-link-active {
border-color: var(--primary);
background: var(--primary);
color: #ffffff;
}
.workspace {
display: grid;
grid-template-columns: minmax(340px, 420px) minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.input-panel,
.content-pane,
.chart-card,
.info-panel,
.json-preview {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface);
}
.input-panel {
position: sticky;
top: 16px;
padding: 16px;
}
.panel-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 28px;
border: 1px solid var(--line);
border-radius: 999px;
padding: 4px 10px;
color: var(--muted);
background: var(--surface-soft);
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
label {
display: grid;
gap: 6px;
color: var(--muted);
font-size: 13px;
font-weight: 700;
}
input,
textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: 8px;
background: #ffffff;
color: var(--text);
}
input {
min-height: 44px;
padding: 9px 11px;
}
textarea {
min-height: 300px;
resize: vertical;
padding: 12px;
font:
13px/1.55 Consolas,
"Courier New",
monospace;
}
.data-field {
margin-top: 12px;
}
.input-panel__footer {
display: grid;
gap: 10px;
margin-top: 12px;
}
.count-text {
color: var(--muted);
font-size: 13px;
font-weight: 700;
}
.button-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.primary-button {
background: var(--primary);
color: #ffffff;
}
.primary-button:hover:not(:disabled) {
background: var(--primary-strong);
}
.secondary-button {
border-color: var(--line);
background: #ffffff;
color: var(--text);
}
.secondary-button:hover:not(:disabled) {
border-color: var(--primary);
color: var(--primary);
}
.error-message {
margin-top: 12px;
color: var(--danger);
font-size: 13px;
line-height: 1.5;
}
.content-pane {
min-width: 0;
padding: 16px;
}
.page-stack {
display: grid;
gap: 14px;
}
.page-intro {
display: grid;
gap: 6px;
}
.page-intro p:last-child {
max-width: 920px;
color: var(--muted);
font-size: 14px;
line-height: 1.65;
}
.metric-strip {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 10px;
}
.metric-tile {
min-width: 0;
border: 1px solid var(--line);
border-radius: 8px;
padding: 11px 12px;
background: var(--surface-soft);
}
.metric-tile span {
display: block;
color: var(--muted);
font-size: 12px;
font-weight: 800;
}
.metric-tile strong {
display: block;
margin-top: 5px;
overflow-wrap: anywhere;
font-size: 20px;
font-variant-numeric: tabular-nums;
line-height: 1.2;
}
.metric-tile strong.ok {
color: var(--ok);
}
.metric-tile strong.warn {
color: var(--warning);
}
.metric-tile strong.neutral {
color: var(--text);
}
.chart-card {
overflow: hidden;
}
.chart-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 62px;
padding: 14px 16px;
border-bottom: 1px solid var(--line);
background: var(--surface-soft);
}
.chart-card__canvas,
.chart-card__empty {
width: 100%;
height: 460px;
}
.chart-card__empty {
display: grid;
place-items: center;
color: var(--muted);
font-size: 14px;
}
.chart-card__description {
padding: 0 16px 16px;
color: var(--muted);
font-size: 14px;
line-height: 1.65;
}
.info-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.info-panel {
padding: 14px;
}
.info-panel p {
margin-top: 8px;
color: var(--muted);
font-size: 14px;
line-height: 1.6;
}
.two-column {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.two-column .chart-card__canvas,
.two-column .chart-card__empty {
height: 390px;
}
.json-preview {
overflow: hidden;
}
.json-preview summary {
min-height: 44px;
padding: 12px 14px;
cursor: pointer;
color: var(--muted);
font-weight: 800;
}
.json-preview pre {
max-height: 460px;
margin: 0;
overflow: auto;
padding: 14px;
background: var(--code-bg);
color: var(--code-text);
font:
13px/1.55 Consolas,
"Courier New",
monospace;
}
.empty-state {
display: grid;
place-items: center;
min-height: 160px;
border: 1px dashed var(--line-strong);
border-radius: 8px;
color: var(--muted);
background: var(--surface-soft);
}
@media (max-width: 1180px) {
.workspace,
.two-column {
grid-template-columns: 1fr;
}
.input-panel {
position: static;
}
.metric-strip,
.info-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.app-shell {
padding: 14px;
}
.topbar {
align-items: stretch;
flex-direction: column;
}
.nav-tabs {
justify-content: flex-start;
}
.nav-tabs a {
flex: 1 1 92px;
}
.form-grid,
.metric-strip,
.info-grid {
grid-template-columns: 1fr;
}
.chart-card__canvas,
.chart-card__empty,
.two-column .chart-card__canvas,
.two-column .chart-card__empty {
height: 340px;
}
}

96
spc-vue/src/types/spc.ts Normal file
View File

@@ -0,0 +1,96 @@
export interface SpcInput {
n: number;
k: number;
usl: number;
lsl: number;
x: number[];
}
export interface SpcRequest {
msg: string;
req_code: string;
req_from: string;
req_cmd: "Cmd_Spc";
req_param: SpcInput;
}
export interface XrResult {
n: number;
k: number;
CL_X: number;
UCL_X: number;
LCL_X: number;
CL_R: number;
UCL_R: number;
LCL_R: number;
CL_Xk: readonly number[];
CL_Rk: readonly number[];
}
export interface XsResult {
n: number;
k: number;
CL_X: number;
UCL_X: number;
LCL_X: number;
CL_S: number;
UCL_S: number;
LCL_S: number;
CL_Xk: readonly number[];
CL_Sk: readonly number[];
}
export interface CpkResult {
n: number;
k: number;
SL: number;
USL: number;
LSL: number;
Singma: number;
SingmaS: number;
Ca: number;
Cp: number;
CPU: number;
CPL: number;
CR: number;
Cpk: number;
Pp: number;
PPU: number;
PPL: number;
PR: number;
Ppk: number;
ProcessSpread: number;
GroupWidth: number;
GroupCount: number;
ValueMax: number;
ValueMin: number;
Xk: readonly number[];
XkUp: readonly number[];
XkDown: readonly number[];
Yk: readonly number[];
YkCount: readonly number[];
NormalDistributionX: readonly number[];
NormalDistributionY: readonly number[];
}
export interface SpcResult {
XR: XrResult;
XS: XsResult;
Cpk: CpkResult;
}
export interface WebApiResponse {
success?: boolean;
res_data?: SpcResult & { error?: string };
error?: string;
[key: string]: unknown;
}
export interface ControlLimitChart {
title: string;
valueName: string;
values: readonly number[];
center: number;
upper: number;
lower: number;
}

View File

@@ -0,0 +1,376 @@
import type { EChartsOption } from "echarts";
import type { ControlLimitChart, CpkResult } from "../types/spc";
const chartColors = ["#0f766e", "#b45309", "#991b1b", "#2563eb"];
export interface RunChartInput {
values: readonly number[];
usl: number;
lsl: number;
target: number;
movingAverageWindow?: number;
}
// 生成从 1 开始的子组序号,用于控制图横轴。
export function sequenceLabels(length: number): string[] {
return Array.from({ length }, (_, index) => String(index + 1));
}
// 生成固定值线,用于 CL、UCL、LCL。
function constantSeries(length: number, value: number): number[] {
return Array.from({ length }, () => value);
}
// 计算算术平均值,用于基本趋势图的均值线。
function mean(values: readonly number[]): number {
if (!values.length) return 0;
return values.reduce((sum, value) => sum + value, 0) / values.length;
}
// 计算中位数,用于基本趋势图的中心参考线。
function median(values: readonly number[]): number {
if (!values.length) return 0;
const sorted = [...values].sort((a, b) => a - b);
const middle = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle];
}
// 计算移动平均线,窗口不足时使用当前已有数据,避免前几项为空。
function movingAverage(values: readonly number[], windowSize: number): number[] {
return values.map((_, index) => {
const start = Math.max(0, index - windowSize + 1);
return mean(values.slice(start, index + 1));
});
}
// 找出超出规格限的原始数据点,用于基本趋势图标红。
function outOfSpecPoints(values: readonly number[], usl: number, lsl: number): Array<[number, number]> {
return values
.map((value, index) => ({ value, index }))
.filter((point) => point.value > usl || point.value < lsl)
.map((point) => [point.index, point.value]);
}
// 计算直方图 X 轴范围,覆盖分箱、规格限、正态曲线和原始极值,并保留少量边距。
function calculateHistogramXAxisRange(cpk: CpkResult): { min: number; max: number } {
const values = [
...cpk.Xk,
...cpk.XkUp,
...cpk.XkDown,
...cpk.NormalDistributionX,
cpk.LSL,
cpk.USL,
cpk.SL,
cpk.ValueMin,
cpk.ValueMax
].filter((value) => Number.isFinite(value));
if (!values.length) {
return { min: 0, max: 1 };
}
const rawMin = Math.min(...values);
const rawMax = Math.max(...values);
const span = rawMax - rawMin;
const padding = span > 0 ? Math.max(span * 0.08, cpk.GroupWidth || 0) : Math.max(Math.abs(rawMin) * 0.08, 0.1);
return {
min: rawMin - padding,
max: rawMax + padding
};
}
// 找出越过控制限的点,作为红色散点叠加在控制图上。
function outOfLimitPoints(values: readonly number[], upper: number, lower: number): Array<[number, number]> {
return values
.map((value, index) => ({ value, index }))
.filter((point) => point.value > upper || point.value < lower)
.map((point) => [point.index, point.value]);
}
// 创建单张控制图配置,包含数据线、中心线、上下控制限和越界点。
export function createControlLimitOption(chart: ControlLimitChart): EChartsOption {
const labels = sequenceLabels(chart.values.length);
const outliers = outOfLimitPoints(chart.values, chart.upper, chart.lower);
return {
color: chartColors,
tooltip: { trigger: "axis" },
legend: { top: 0, right: 0 },
grid: { left: 56, right: 28, top: 46, bottom: 44 },
xAxis: {
type: "category",
name: "子组",
data: labels,
boundaryGap: false,
axisLabel: { color: "#667085" }
},
yAxis: {
type: "value",
name: chart.valueName,
scale: true,
axisLabel: { color: "#667085" },
splitLine: { lineStyle: { color: "#e5e7eb" } }
},
series: [
{
name: chart.valueName,
type: "line",
data: [...chart.values],
symbolSize: 6,
lineStyle: { width: 2 }
},
{
name: "CL",
type: "line",
data: constantSeries(chart.values.length, chart.center),
symbol: "none",
lineStyle: { type: "dashed", width: 1.5 }
},
{
name: "UCL",
type: "line",
data: constantSeries(chart.values.length, chart.upper),
symbol: "none",
lineStyle: { type: "dotted", width: 1.5 }
},
{
name: "LCL",
type: "line",
data: constantSeries(chart.values.length, chart.lower),
symbol: "none",
lineStyle: { type: "dotted", width: 1.5 }
},
{
name: "越界点",
type: "scatter",
data: outliers,
symbolSize: 10,
itemStyle: { color: "#dc2626" }
}
]
};
}
// 创建基本趋势图配置,按原始顺序展示数据并叠加规格限、均值线、中位线和移动平均线。
export function createRunChartOption(input: RunChartInput): EChartsOption {
const labels = sequenceLabels(input.values.length);
const average = mean(input.values);
const middle = median(input.values);
const windowSize = Math.max(2, Math.floor(input.movingAverageWindow || 5));
const outliers = outOfSpecPoints(input.values, input.usl, input.lsl);
return {
color: chartColors,
tooltip: { trigger: "axis" },
legend: { top: 0, right: 0 },
grid: { left: 56, right: 34, top: 46, bottom: 44 },
xAxis: {
type: "category",
name: "样本序号",
data: labels,
boundaryGap: false,
axisLabel: { color: "#667085" }
},
yAxis: {
type: "value",
name: "测量值",
scale: true,
axisLabel: { color: "#667085" },
splitLine: { lineStyle: { color: "#e5e7eb" } }
},
series: [
{
name: "原始数据",
type: "line",
data: [...input.values],
symbolSize: 4,
lineStyle: { width: 1.8 }
},
{
name: `${windowSize}点移动平均`,
type: "line",
data: movingAverage(input.values, windowSize),
smooth: true,
symbol: "none",
lineStyle: { width: 2 }
},
{
name: "均值",
type: "line",
data: constantSeries(input.values.length, average),
symbol: "none",
lineStyle: { type: "dashed", width: 1.4 }
},
{
name: "中位线",
type: "line",
data: constantSeries(input.values.length, middle),
symbol: "none",
lineStyle: { type: "dashed", width: 1.4 }
},
{
name: "超规格点",
type: "scatter",
data: outliers,
symbolSize: 10,
itemStyle: { color: "#dc2626" }
},
{
name: "USL",
type: "line",
data: constantSeries(input.values.length, input.usl),
symbol: "none",
lineStyle: { type: "dotted", width: 1.5 }
},
{
name: "LSL",
type: "line",
data: constantSeries(input.values.length, input.lsl),
symbol: "none",
lineStyle: { type: "dotted", width: 1.5 }
},
{
name: "目标值",
type: "line",
data: constantSeries(input.values.length, input.target),
symbol: "none",
lineStyle: { type: "dotted", width: 1.5 }
}
]
};
}
// 创建过程能力指标柱状图,便于快速查看 Cp/Cpk/Pp/Ppk 门槛。
export function createCapabilityBarOption(cpk: CpkResult): EChartsOption {
const labels = ["Cp", "Cpk", "Pp", "Ppk", "CPU", "CPL"];
return {
color: ["#0f766e"],
tooltip: { trigger: "axis" },
grid: { left: 48, right: 24, top: 36, bottom: 42 },
xAxis: {
type: "category",
data: labels,
axisLabel: { color: "#667085" }
},
yAxis: {
type: "value",
axisLabel: { color: "#667085" },
splitLine: { lineStyle: { color: "#e5e7eb" } }
},
series: [
{
name: "能力指标",
type: "bar",
data: labels.map((label) => cpk[label as keyof CpkResult] as number),
barMaxWidth: 34,
itemStyle: {
color: (params) => {
const value = Number(params.value);
if (value >= 1.33) return "#15803d";
if (value >= 1) return "#b45309";
return "#b91c1c";
}
},
markLine: {
symbol: "none",
data: [
{ yAxis: 1, name: "1.00" },
{ yAxis: 1.33, name: "1.33" }
],
label: { color: "#667085" },
lineStyle: { color: "#b45309", type: "dashed" }
}
}
]
};
}
// 创建单独直方图配置,只展示频数分布和规格限,不叠加正态曲线。
export function createFrequencyHistogramOption(cpk: CpkResult): EChartsOption {
const xRange = calculateHistogramXAxisRange(cpk);
return {
color: ["#0f766e"],
tooltip: { trigger: "axis" },
legend: { top: 0, right: 0 },
grid: { left: 52, right: 48, top: 46, bottom: 42 },
xAxis: {
type: "value",
name: "测量值",
min: xRange.min,
max: xRange.max,
axisLabel: { color: "#667085" }
},
yAxis: {
type: "value",
name: "频数",
minInterval: 1,
axisLabel: { color: "#667085" },
splitLine: { lineStyle: { color: "#e5e7eb" } }
},
series: [
{
name: "频数",
type: "bar",
data: cpk.Xk.map((value, index) => [value, cpk.YkCount[index]]),
barMaxWidth: 34,
markLine: {
symbol: "none",
data: [
{ xAxis: cpk.LSL, name: "LSL" },
{ xAxis: cpk.USL, name: "USL" },
{ xAxis: cpk.SL, name: "目标值" }
],
label: { color: "#667085" },
lineStyle: { color: "#991b1b", type: "dashed" }
}
}
]
};
}
// 创建正态曲线配置,单独查看拟合曲线与规格限关系。
export function createNormalCurveOption(cpk: CpkResult): EChartsOption {
const xRange = calculateHistogramXAxisRange(cpk);
return {
color: ["#b45309"],
tooltip: { trigger: "axis" },
legend: { top: 0, right: 0 },
grid: { left: 52, right: 48, top: 46, bottom: 42 },
xAxis: {
type: "value",
name: "测量值",
min: xRange.min,
max: xRange.max,
axisLabel: { color: "#667085" }
},
yAxis: {
type: "value",
name: "概率密度",
axisLabel: { color: "#667085" },
splitLine: { lineStyle: { color: "#e5e7eb" } }
},
series: [
{
name: "正态曲线",
type: "line",
smooth: true,
symbolSize: 4,
data: cpk.NormalDistributionX.map((value, index) => [value, cpk.NormalDistributionY[index]]),
markLine: {
symbol: "none",
data: [
{ xAxis: cpk.LSL, name: "LSL" },
{ xAxis: cpk.USL, name: "USL" },
{ xAxis: cpk.SL, name: "目标值" }
],
label: { color: "#667085" },
lineStyle: { color: "#991b1b", type: "dashed" }
}
}
]
};
}

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import { computed } from "vue";
import EChartPanel from "../components/EChartPanel.vue";
import MetricStrip from "../components/MetricStrip.vue";
import { useSpc } from "../composables/useSpc";
import { createCapabilityBarOption } from "../utils/chartOptions";
const { result } = useSpc();
const metrics = computed(() => {
const cpk = result.value?.Cpk;
if (!cpk) return [];
return [
{ label: "Cp", value: cpk.Cp, tone: cpk.Cp >= 1.33 ? ("ok" as const) : ("warn" as const) },
{ label: "Cpk", value: cpk.Cpk, tone: cpk.Cpk >= 1.33 ? ("ok" as const) : ("warn" as const) },
{ label: "Pp", value: cpk.Pp, tone: cpk.Pp >= 1.33 ? ("ok" as const) : ("warn" as const) },
{ label: "Ppk", value: cpk.Ppk, tone: cpk.Ppk >= 1.33 ? ("ok" as const) : ("warn" as const) },
{ label: "USL", value: cpk.USL },
{ label: "LSL", value: cpk.LSL }
];
});
const capabilityOption = computed(() => (result.value?.Cpk ? createCapabilityBarOption(result.value.Cpk) : null));
</script>
<template>
<article class="page-stack">
<MetricStrip v-if="metrics.length" :metrics="metrics" />
<EChartPanel
title="过程能力指标"
eyebrow="Capability"
description="能力柱状图用于快速比较 Cp、Cpk、Pp、Ppk。页面保留 1.00 与 1.33 参考线,方便验证常见能力门槛;分布形态请在直方图和正态曲线页面单独查看。"
:option="capabilityOption"
/>
</article>
</template>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import { computed } from "vue";
import EChartPanel from "../components/EChartPanel.vue";
import MetricStrip from "../components/MetricStrip.vue";
import { useSpc } from "../composables/useSpc";
import { createFrequencyHistogramOption } from "../utils/chartOptions";
const { result } = useSpc();
const option = computed(() => (result.value?.Cpk ? createFrequencyHistogramOption(result.value.Cpk) : null));
const metrics = computed(() => {
const cpk = result.value?.Cpk;
if (!cpk) return [];
const maxCount = Math.max(...cpk.YkCount);
return [
{ label: "分箱数", value: cpk.GroupCount },
{ label: "组距", value: cpk.GroupWidth },
{ label: "最大频数", value: maxCount },
{ label: "最小值", value: cpk.ValueMin },
{ label: "最大值", value: cpk.ValueMax },
{ label: "规格限", value: `${cpk.LSL} ~ ${cpk.USL}` }
];
});
</script>
<template>
<article class="page-stack">
<MetricStrip v-if="metrics.length" :metrics="metrics" />
<EChartPanel
title="频数直方图"
eyebrow="Histogram"
description="直方图只展示各测量区间的频数分布,并标出规格限和目标值。它适合先观察真实分布形态、偏态、双峰、离群区间以及数据是否靠近规格边界。"
:option="option"
/>
</article>
</template>

View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
import { computed } from "vue";
import EChartPanel from "../components/EChartPanel.vue";
import MetricStrip from "../components/MetricStrip.vue";
import { useSpc } from "../composables/useSpc";
import { createNormalCurveOption } from "../utils/chartOptions";
const { result } = useSpc();
const option = computed(() => (result.value?.Cpk ? createNormalCurveOption(result.value.Cpk) : null));
const metrics = computed(() => {
const cpk = result.value?.Cpk;
if (!cpk) return [];
return [
{ label: "Sigma", value: cpk.Singma },
{ label: "SigmaS", value: cpk.SingmaS },
{ label: "Ca", value: cpk.Ca },
{ label: "SL", value: cpk.SL },
{ label: "USL", value: cpk.USL },
{ label: "LSL", value: cpk.LSL }
];
});
</script>
<template>
<article class="page-stack">
<MetricStrip v-if="metrics.length" :metrics="metrics" />
<EChartPanel
title="正态曲线"
eyebrow="Normal Curve"
description="正态曲线单独展示当前 SPC 结果中的拟合分布,并标出规格限和目标值。它用于辅助判断能力指标解释是否依赖正态分布假设。"
:option="option"
/>
</article>
</template>

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
import { computed } from "vue";
import JsonPreview from "../components/JsonPreview.vue";
import MetricStrip from "../components/MetricStrip.vue";
import { useSpc } from "../composables/useSpc";
const { result, rawResponse } = useSpc();
const metrics = computed(() => {
const cpk = result.value?.Cpk;
const xr = result.value?.XR;
const xs = result.value?.XS;
if (!cpk || !xr || !xs) return [];
return [
{ label: "Cpk", value: cpk.Cpk, tone: cpk.Cpk >= 1.33 ? ("ok" as const) : ("warn" as const) },
{ label: "Cp", value: cpk.Cp, tone: cpk.Cp >= 1.33 ? ("ok" as const) : ("warn" as const) },
{ label: "Ppk", value: cpk.Ppk, tone: cpk.Ppk >= 1.33 ? ("ok" as const) : ("warn" as const) },
{ label: "XR 均值CL", value: xr.CL_X },
{ label: "R 均值CL", value: xr.CL_R },
{ label: "S 均值CL", value: xs.CL_S }
];
});
</script>
<template>
<article class="page-stack">
<section class="page-intro">
<p class="eyebrow">Overview</p>
<h2>SPC 结果总览</h2>
<p>
页面启动后会自动调用一次 `Cmd_Spc`左侧可以替换 nk规格限和测量数据所有控制图页面共用同一份计算结果
</p>
</section>
<MetricStrip v-if="metrics.length" :metrics="metrics" />
<div v-else class="empty-state">等待 SPC 结果</div>
<section class="info-grid">
<div class="info-panel">
<h3>Xbar-R / R</h3>
<p>使用子组均值和极差验证过程中心与短期组内波动适合小子组连续型数据</p>
</div>
<div class="info-panel">
<h3>Xbar-S / S</h3>
<p>使用子组均值和样本标准差验证过程稳定性适合子组容量较大或需要标准差口径的场景</p>
</div>
<div class="info-panel">
<h3>Cpk 能力</h3>
<p>结合规格限直方图和正态曲线检查过程能力指标与数据分布是否相互印证</p>
</div>
</section>
<JsonPreview :value="rawResponse" />
</article>
</template>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import { computed } from "vue";
import EChartPanel from "../components/EChartPanel.vue";
import MetricStrip from "../components/MetricStrip.vue";
import { useSpc } from "../composables/useSpc";
import { createControlLimitOption } from "../utils/chartOptions";
const { result } = useSpc();
const option = computed(() => {
const xr = result.value?.XR;
if (!xr) return null;
return createControlLimitOption({
title: "R 极差控制图",
valueName: "子组极差",
values: xr.CL_Rk,
center: xr.CL_R,
upper: xr.UCL_R,
lower: xr.LCL_R
});
});
const metrics = computed(() => {
const xr = result.value?.XR;
if (!xr) return [];
return [
{ label: "CL_R", value: xr.CL_R },
{ label: "UCL_R", value: xr.UCL_R },
{ label: "LCL_R", value: xr.LCL_R },
{ label: "子组容量", value: xr.n }
];
});
</script>
<template>
<article class="page-stack">
<MetricStrip v-if="metrics.length" :metrics="metrics" />
<EChartPanel
title="R 极差控制图"
eyebrow="Range"
description="R 图使用每个子组的最大值减最小值观察组内波动。若 R 图先失控Xbar 控制限的解释需要谨慎。"
:option="option"
/>
</article>
</template>

View File

@@ -0,0 +1,49 @@
<script setup lang="ts">
import { computed } from "vue";
import EChartPanel from "../components/EChartPanel.vue";
import MetricStrip from "../components/MetricStrip.vue";
import { useSpc } from "../composables/useSpc";
import { createRunChartOption } from "../utils/chartOptions";
const { currentInput } = useSpc();
const option = computed(() => {
const input = currentInput.value;
if (!input) return null;
return createRunChartOption({
values: input.x,
usl: input.usl,
lsl: input.lsl,
target: (input.usl + input.lsl) / 2,
movingAverageWindow: input.n
});
});
const metrics = computed(() => {
const input = currentInput.value;
if (!input) return [];
const overSpecCount = input.x.filter((value) => value > input.usl || value < input.lsl).length;
const average = input.x.reduce((sum, value) => sum + value, 0) / input.x.length;
return [
{ label: "样本数", value: input.x.length },
{ label: "移动平均窗口", value: input.n },
{ label: "均值", value: Number(average.toFixed(6)) },
{ label: "目标值", value: Number(((input.usl + input.lsl) / 2).toFixed(6)) },
{ label: "超规格点", value: overSpecCount, tone: overSpecCount === 0 ? ("ok" as const) : ("warn" as const) },
{ label: "规格限", value: `${input.lsl} ~ ${input.usl}` }
];
});
</script>
<template>
<article class="page-stack">
<MetricStrip v-if="metrics.length" :metrics="metrics" />
<EChartPanel
title="基本趋势图"
eyebrow="Run Chart"
description="基本趋势图按原始采集顺序展示测量值,叠加均值线、中位线、目标值、规格限和移动平均线。它不替代控制图,主要用于先观察漂移、跳变、周期波动和超规格点。"
:option="option"
/>
</article>
</template>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import { computed } from "vue";
import EChartPanel from "../components/EChartPanel.vue";
import MetricStrip from "../components/MetricStrip.vue";
import { useSpc } from "../composables/useSpc";
import { createControlLimitOption } from "../utils/chartOptions";
const { result } = useSpc();
const option = computed(() => {
const xs = result.value?.XS;
if (!xs) return null;
return createControlLimitOption({
title: "S 标准差控制图",
valueName: "子组标准差",
values: xs.CL_Sk,
center: xs.CL_S,
upper: xs.UCL_S,
lower: xs.LCL_S
});
});
const metrics = computed(() => {
const xs = result.value?.XS;
if (!xs) return [];
return [
{ label: "CL_S", value: xs.CL_S },
{ label: "UCL_S", value: xs.UCL_S },
{ label: "LCL_S", value: xs.LCL_S },
{ label: "子组容量", value: xs.n }
];
});
</script>
<template>
<article class="page-stack">
<MetricStrip v-if="metrics.length" :metrics="metrics" />
<EChartPanel
title="S 标准差控制图"
eyebrow="Standard Deviation"
description="S 图使用每个子组的样本标准差观察组内离散程度,对标准差变化更敏感,常用于较大子组数据。"
:option="option"
/>
</article>
</template>

View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
import { computed } from "vue";
import EChartPanel from "../components/EChartPanel.vue";
import MetricStrip from "../components/MetricStrip.vue";
import { useSpc } from "../composables/useSpc";
import { createControlLimitOption } from "../utils/chartOptions";
const { result } = useSpc();
const chart = computed(() => {
const xr = result.value?.XR;
if (!xr) return null;
return {
title: "Xbar 控制图XR 控制限)",
valueName: "子组均值",
values: xr.CL_Xk,
center: xr.CL_X,
upper: xr.UCL_X,
lower: xr.LCL_X
};
});
const option = computed(() => (chart.value ? createControlLimitOption(chart.value) : null));
const metrics = computed(() => {
const xr = result.value?.XR;
if (!xr) return [];
return [
{ label: "CL_X", value: xr.CL_X },
{ label: "UCL_X", value: xr.UCL_X },
{ label: "LCL_X", value: xr.LCL_X },
{ label: "子组数", value: xr.k }
];
});
</script>
<template>
<article class="page-stack">
<MetricStrip v-if="metrics.length" :metrics="metrics" />
<EChartPanel
title="Xbar-R均值控制图"
eyebrow="Xbar-R"
description="Xbar 图使用每个子组的均值观察过程中心是否稳定;这里的均值控制限来自 XR 结果,适合与 R 图一起验证小子组数据。"
:option="option"
/>
</article>
</template>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import { computed } from "vue";
import EChartPanel from "../components/EChartPanel.vue";
import MetricStrip from "../components/MetricStrip.vue";
import { useSpc } from "../composables/useSpc";
import { createControlLimitOption } from "../utils/chartOptions";
const { result } = useSpc();
const option = computed(() => {
const xs = result.value?.XS;
if (!xs) return null;
return createControlLimitOption({
title: "Xbar 控制图XS 控制限)",
valueName: "子组均值",
values: xs.CL_Xk,
center: xs.CL_X,
upper: xs.UCL_X,
lower: xs.LCL_X
});
});
const metrics = computed(() => {
const xs = result.value?.XS;
if (!xs) return [];
return [
{ label: "CL_X", value: xs.CL_X },
{ label: "UCL_X", value: xs.UCL_X },
{ label: "LCL_X", value: xs.LCL_X },
{ label: "子组数", value: xs.k }
];
});
</script>
<template>
<article class="page-stack">
<MetricStrip v-if="metrics.length" :metrics="metrics" />
<EChartPanel
title="Xbar-S均值控制图"
eyebrow="Xbar-S"
description="Xbar-S 中的均值图同样观察过程中心,但控制限由样本标准差口径计算,适合和 S 图配套检查过程稳定性。"
:option="option"
/>
</article>
</template>

19
spc-vue/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.vue"],
"references": []
}

48
spc-vue/vite.config.ts Normal file
View File

@@ -0,0 +1,48 @@
import fs from "node:fs";
import path from "node:path";
import vue from "@vitejs/plugin-vue";
import { defineConfig, type Plugin } from "vite";
const repoWasmDir = path.resolve(__dirname, "../public/wasm");
// 复用主项目已经构建好的 WASM 文件,开发时映射 /wasm打包时复制到 dist/wasm。
function repoWasmAssets(): Plugin {
return {
name: "repo-wasm-assets",
configureServer(server) {
server.middlewares.use("/wasm", (req, res, next) => {
const requestPath = decodeURIComponent((req.url || "").split("?")[0].replace(/^\/+/, ""));
const filePath = path.resolve(repoWasmDir, requestPath);
if (!filePath.startsWith(repoWasmDir) || !fs.existsSync(filePath)) {
next();
return;
}
if (filePath.endsWith(".wasm")) {
res.setHeader("Content-Type", "application/wasm");
} else if (filePath.endsWith(".js")) {
res.setHeader("Content-Type", "application/javascript; charset=utf-8");
}
fs.createReadStream(filePath).pipe(res);
});
},
closeBundle() {
const outDir = path.resolve(__dirname, "dist/wasm");
fs.mkdirSync(outDir, { recursive: true });
for (const assetName of ["smart_math.js", "smart_math.wasm"]) {
const source = path.join(repoWasmDir, assetName);
if (fs.existsSync(source)) {
fs.copyFileSync(source, path.join(outDir, assetName));
}
}
}
};
}
export default defineConfig({
plugins: [vue(), repoWasmAssets()],
server: {
port: 5174
}
});

View File

@@ -84,9 +84,6 @@ void KinematicsHelper::SimRobot()
json KinematicsHelper::QuadrupedRobot_CalculateAllPointsFromMotorAngles(const std::string &jsonInput)
{
std::cout << "[DEBUG] json KinematicsHelper::QuadrupedRobot_CalculateAllPointsFromMotorAngles(const std::string &jsonInput) " << std::endl;
std::string jsonStr = "{}";
auto manager = RobotGaitDataManagerFromJson(jsonInput);
ReverseKinematicsCalculator simulation(manager);
@@ -96,9 +93,6 @@ json KinematicsHelper::QuadrupedRobot_CalculateAllPointsFromMotorAngles(const st
}
json KinematicsHelper::QuadrupedRobot_PerformForwardKinematics(const std::string &jsonInput)
{
std::cout << "[DEBUG] json KinematicsHelper::QuadrupedRobot_PerformForwardKinematics(const std::string &jsonInput) " << std::endl;
auto manager = RobotGaitDataManagerFromJson(jsonInput);
QuadrupedRobotConfiguration config(manager->GetGaitInfo(), manager->GetSystemParameters());
QuadrupedRobotSimulation simulation(config);

View File

@@ -972,8 +972,6 @@ std::string ReverseKinematicsCalculator::CalculateAllPointsFromMotorAnglesJsonSt
if (paramDict.empty())
{
std::cout << "[DEBUG] 没有Param数据 json KinematicsHelper::QuadrupedRobot_CalculateAllPointsFromMotorAngles(const std::string &jsonInput) " << std::endl;
return "没有Param数据";
}
// debugPrintParamDict(paramDict);
@@ -991,14 +989,6 @@ std::string ReverseKinematicsCalculator::CalculateAllPointsFromMotorAnglesJsonSt
auto points_dict = CalculateAllPointsFromMotorAnglesReverse(
thigh_angle_deg, shank_angle_deg, ankle_angle_deg, leg_type);
// std::cout << "计算得到的点数量: " << points_dict.size() << "\n";
// std::cout << "包含的点: ";
for (const auto &p : points_dict)
{
std::cout << p.first << " ";
}
// std::cout << "\n";
// 改为:
auto modelID = _manager->GetModelIDObject(); // 使用新方法

View File

@@ -886,11 +886,6 @@ void QuadrupedRobotSimulation::CalculateMotorData()
int phase_RF = static_cast<int>(timeRF * n_frames);
int phase_RH = static_cast<int>(timeRH * n_frames);
std::cout << "步态类型: " << gait_type << std::endl;
std::cout << "总帧数: " << n_frames << std::endl;
std::cout << "相位偏移(帧): LF=0, LH=" << phase_LH
<< ", RF=" << phase_RF << ", RH=" << phase_RH << std::endl;
// 1. 计算左前腿基准数据
motor_data_LF_raw = CalculateIncrementsAndVelocities();

View File

@@ -3,6 +3,7 @@
#include <stdexcept>
#include <kdl_parser/kdl_parser.hpp>
#include <kdl/tree.hpp>
#include <urdf_parser/urdf_parser.h>
// #include <cmath>
#include <algorithm>
@@ -10,14 +11,17 @@
#include <kdl/velocityprofile_trap.hpp>
#include <kdl/trajectory_segment.hpp>
#include <kdl/rotational_interpolation_sa.hpp>
#include <Eigen/SVD>
#include <limits>
#include <regex>
#include <vector>
using namespace KDL;
using namespace std;
Robot::Robot() : m_initialized(false), fkSolver(nullptr), ikVelSolver(nullptr),
ikSolverNR(nullptr), ikSolverLMA(nullptr)
ikSolverNR(nullptr), ikSolverLMA(nullptr), m_hasJointLimits(false)
{
// 构造函数中显式初始化全部求解器指针。
}
@@ -25,7 +29,8 @@ Robot::Robot(const std::string &urdfString) : m_initialized(false),
fkSolver(nullptr),
ikVelSolver(nullptr),
ikSolverNR(nullptr),
ikSolverLMA(nullptr)
ikSolverLMA(nullptr),
m_hasJointLimits(false)
{
initRobot(urdfString);
}
@@ -38,11 +43,203 @@ Robot::~Robot()
delete ikSolverLMA;
}
// 自动收集 KDL Tree 中的叶子节点,用作自动推导运动学末端候选。
std::vector<std::string> Robot::collectLeafSegmentNames(const KDL::Tree &tree) const
{
std::vector<std::string> leafSegmentNames;
const auto &segments = tree.getSegments();
for (const auto &segmentPair : segments)
{
if (GetTreeElementChildren(segmentPair.second).empty())
{
leafSegmentNames.push_back(segmentPair.first);
}
}
return leafSegmentNames;
}
// 自动选择运动学链:优先兼容旧命名,失败后从 URDF 根节点推导 6 轴叶子链。
bool Robot::selectKinematicChain(const KDL::Tree &tree, KDL::Chain &selectedChain) const
{
KDL::Chain legacyChain;
if (tree.getChain("base", "tool0", legacyChain) && legacyChain.getNrOfJoints() == 6)
{
selectedChain = legacyChain;
return true;
}
const auto &segments = tree.getSegments();
auto rootSegment = tree.getRootSegment();
if (rootSegment == segments.end())
{
std::cerr << "Failed to infer kinematic chain: tree root segment not found" << std::endl;
return false;
}
const std::string rootName = rootSegment->first;
const std::vector<std::string> leafSegmentNames = collectLeafSegmentNames(tree);
bool foundChain = false;
std::string selectedTipName;
unsigned int selectedSegmentCount = 0;
for (const auto &tipName : leafSegmentNames)
{
KDL::Chain candidateChain;
if (!tree.getChain(rootName, tipName, candidateChain))
{
continue;
}
if (candidateChain.getNrOfJoints() != 6)
{
continue;
}
// 多个 6 轴叶子链并存时,优先选择段数最多的完整工具链。
if (!foundChain || candidateChain.getNrOfSegments() > selectedSegmentCount)
{
selectedChain = candidateChain;
selectedTipName = tipName;
selectedSegmentCount = candidateChain.getNrOfSegments();
foundChain = true;
}
}
if (!foundChain)
{
std::cerr << "Failed to infer a 6-joint kinematic chain from root segment: "
<< rootName << std::endl;
return false;
}
return true;
}
// 从 URDF 模型中读取当前活动关节的 lower/upper 限位。
bool Robot::parseJointLimitsFromUrdf(const std::string &urdfString)
{
activeJointNames.clear();
jointLowerLimits = KDL::JntArray(kinematicChain.getNrOfJoints());
jointUpperLimits = KDL::JntArray(kinematicChain.getNrOfJoints());
m_hasJointLimits = false;
urdf::ModelInterfaceSharedPtr robotModel = urdf::parseURDF(urdfString);
if (!robotModel)
{
std::cerr << "Failed to parse URDF model for joint limits" << std::endl;
return false;
}
unsigned int jointIndex = 0;
for (unsigned int segmentIndex = 0; segmentIndex < kinematicChain.getNrOfSegments(); segmentIndex++)
{
const KDL::Joint &joint = kinematicChain.getSegment(segmentIndex).getJoint();
if (joint.getType() == KDL::Joint::Fixed)
{
continue;
}
const std::string &jointName = joint.getName();
urdf::JointConstSharedPtr urdfJoint = robotModel->getJoint(jointName);
if (!urdfJoint)
{
std::cerr << "Joint limit missing: joint not found in URDF: " << jointName << std::endl;
return false;
}
if (urdfJoint->type == urdf::Joint::CONTINUOUS)
{
jointLowerLimits(jointIndex) = -std::numeric_limits<double>::infinity();
jointUpperLimits(jointIndex) = std::numeric_limits<double>::infinity();
}
else if (urdfJoint->limits)
{
jointLowerLimits(jointIndex) = urdfJoint->limits->lower;
jointUpperLimits(jointIndex) = urdfJoint->limits->upper;
}
else
{
std::cerr << "Joint limit missing: <limit> not found for joint: " << jointName << std::endl;
return false;
}
if (jointLowerLimits(jointIndex) > jointUpperLimits(jointIndex))
{
std::cerr << "Joint limit invalid for joint " << jointName
<< ": lower is greater than upper" << std::endl;
return false;
}
activeJointNames.push_back(jointName);
jointIndex++;
}
if (jointIndex != kinematicChain.getNrOfJoints())
{
std::cerr << "Joint limit count mismatch, expected "
<< kinematicChain.getNrOfJoints() << ", got " << jointIndex << std::endl;
return false;
}
m_hasJointLimits = true;
return true;
}
// 校验固定数组关节值是否处于 URDF 上下限内。
bool Robot::validateJointLimits(const double joints[6], const std::string &context) const
{
if (!m_hasJointLimits)
{
return true;
}
constexpr double tolerance = 1e-9;
for (unsigned int i = 0; i < jointLowerLimits.rows(); i++)
{
const double value = joints[i];
const double lower = jointLowerLimits(i);
const double upper = jointUpperLimits(i);
if (value < lower - tolerance || value > upper + tolerance)
{
return false;
}
}
return true;
}
// 校验 vector 关节值是否处于 URDF 上下限内。
bool Robot::validateJointLimits(const std::vector<double> &joints, const std::string &context) const
{
if (joints.size() != 6)
{
return false;
}
return validateJointLimits(joints.data(), context);
}
// 根据 URDF 初始化机器人,并提取关节与 child link 的 UUID 映射。
bool Robot::initRobot(const std::string &urdfString)
{
try
{
delete fkSolver;
delete ikVelSolver;
delete ikSolverNR;
delete ikSolverLMA;
fkSolver = nullptr;
ikVelSolver = nullptr;
ikSolverNR = nullptr;
ikSolverLMA = nullptr;
m_initialized = false;
kinematicChain = KDL::Chain();
activeJointNames.clear();
m_hasJointLimits = false;
KDL::Tree tree;
// 从 URDF 字符串解析 KDL Tree。
@@ -52,10 +249,9 @@ bool Robot::initRobot(const std::string &urdfString)
return false;
}
// 提取 basetool0 的运动学链
if (!tree.getChain("base", "tool0", kinematicChain))
// 自动选择可用运动学链,避免固定依赖 base/tool0 命名
if (!selectKinematicChain(tree, kinematicChain))
{
std::cerr << "Failed to get chain from base to tool0" << std::endl;
return false;
}
@@ -67,6 +263,12 @@ bool Robot::initRobot(const std::string &urdfString)
return false;
}
// 从 URDF 提取关节上下限,供 FK 输入和 IK 输出统一校验。
if (!parseJointLimitsFromUrdf(urdfString))
{
return false;
}
// 解析 joint 到 child link UUID 的映射。
if (!parseJointChildLinkUuidsFromUrdf(urdfString))
{
@@ -76,12 +278,10 @@ bool Robot::initRobot(const std::string &urdfString)
// 初始化 FK / IK 求解器。
fkSolver = new KDL::ChainFkSolverPos_recursive(kinematicChain);
ikVelSolver = new KDL::ChainIkSolverVel_pinv(kinematicChain);
ikSolverNR = new KDL::ChainIkSolverPos_NR(kinematicChain, *fkSolver, *ikVelSolver, 100, 1e-6);
ikSolverNR = new KDL::ChainIkSolverPos_NR_JL(kinematicChain, jointLowerLimits, jointUpperLimits, *fkSolver, *ikVelSolver, 100, 1e-6);
// LMA 求解器按调用时动态构造,便于传入不同收敛参数。
m_initialized = true;
std::cout << "Robot initialized successfully with "
<< kinematicChain.getNrOfJoints() << " joints" << std::endl;
numberOfJoints = getNumberOfJoints();
return true;
}
@@ -197,9 +397,6 @@ bool Robot::parseJointChildLinkUuidsFromUrdf(const std::string &urdfString)
std::smatch matches;
std::string::const_iterator searchStart(urdfString.cbegin());
// 输出解析过程,便于排查 URDF 结构问题。
std::cout << "Parsing URDF for joint child link UUIDs..." << std::endl;
bool foundAny = false;
while (std::regex_search(searchStart, urdfString.cend(), matches, jointRegex))
{
@@ -210,10 +407,6 @@ bool Robot::parseJointChildLinkUuidsFromUrdf(const std::string &urdfString)
std::string uuid = matches[3].str();
jointChildLinkUuidMap[jointName] = uuid;
// 输出匹配结果。
std::cout << "Found joint: " << jointName
<< " -> Child link: " << childLinkName
<< " -> UUID: " << uuid << std::endl;
foundAny = true;
}
searchStart = matches.suffix().first;
@@ -222,8 +415,6 @@ bool Robot::parseJointChildLinkUuidsFromUrdf(const std::string &urdfString)
// 如果首轮没有命中,则退化为更宽松的解析方式。
if (!foundAny)
{
std::cout << "Trying alternative parsing method..." << std::endl;
// 方案二:分别匹配 joint 名称和 child link。
std::regex jointNameRegex(R"(<joint\s+name=\"([^\"]+)\")");
std::regex childLinkRegex(R"(<child\s+link=\"([^\"]+)\"\s+uuid=\"([^\"]+)\")");
@@ -259,9 +450,6 @@ bool Robot::parseJointChildLinkUuidsFromUrdf(const std::string &urdfString)
std::string uuid = childMatches[2].str();
jointChildLinkUuidMap[jointName] = uuid;
std::cout << "Found joint: " << jointName
<< " -> Child link: " << childLinkName
<< " -> UUID: " << uuid << std::endl;
foundAny = true;
}
}
@@ -277,15 +465,9 @@ bool Robot::parseJointChildLinkUuidsFromUrdf(const std::string &urdfString)
{
std::cerr << "No joint child link UUIDs found in URDF" << std::endl;
// 仅打印前 500 个字符,避免日志过长。
std::cout << "First 500 characters of URDF:" << std::endl;
std::cout << urdfString.substr(0, 500) << std::endl;
return false;
}
std::cout << "Successfully parsed " << jointChildLinkUuidMap.size()
<< " joint child link UUIDs" << std::endl;
return true;
}
catch (const std::exception &e)
@@ -315,6 +497,11 @@ bool Robot::calculateIK_NR(const double pose[7], const double iniJ[6], double re
try
{
if (!validateJointLimits(iniJ, "IK initial joints"))
{
return false;
}
// 将输入位姿转换为 KDL Frame。
KDL::Vector position(pose[0], pose[1], pose[2]);
KDL::Rotation rotation = KDL::Rotation::Quaternion(pose[3], pose[4], pose[5], pose[6]);
@@ -338,7 +525,7 @@ bool Robot::calculateIK_NR(const double pose[7], const double iniJ[6], double re
{
resultJoints[i] = result(i);
}
return true;
return validateJointLimits(resultJoints, "IK result joints");
}
else
{
@@ -364,6 +551,11 @@ bool Robot::calculateIK_LMA(const double pose[7], const double iniJ[6], double r
try
{
if (!validateJointLimits(iniJ, "IK initial joints"))
{
return false;
}
// 每次调用时动态构造 LMA 求解器,便于传入不同参数。
KDL::ChainIkSolverPos_LMA ikSolverLMA(kinematicChain, eps, maxiter, eps_joints);
@@ -390,7 +582,7 @@ bool Robot::calculateIK_LMA(const double pose[7], const double iniJ[6], double r
{
resultJoints[i] = result(i);
}
return true;
return validateJointLimits(resultJoints, "IK result joints");
}
else
{
@@ -415,6 +607,11 @@ bool Robot::calculateFK_TCP(const double joints[6], double tcpPose[7])
try
{
if (!validateJointLimits(joints, "FK input joints"))
{
return false;
}
// 将关节数组转换为 KDL 关节对象。
KDL::JntArray jointArray(6);
for (int i = 0; i < 6; i++)
@@ -460,6 +657,11 @@ bool Robot::calculateFK_TCP(const double joints[6], double tcpPose[7])
// const double joints[6], double jointPoses[42]
bool Robot::forwardKinematics(const double inJoint[6], double outFrame[42])
{
if (!validateJointLimits(inJoint, "FK all joints input"))
{
return false;
}
Frame F_result;
ChainFkSolverPos_recursive fkSolver(kinematicChain);
@@ -513,6 +715,11 @@ bool Robot::calculateFK_AllJointsforwardKinematics(const double joints[6], doubl
try
{
if (!validateJointLimits(joints, "FK all joints input"))
{
return false;
}
// 将关节数组转换为 KDL 关节对象。
KDL::JntArray jointArray(6);
for (int i = 0; i < 6; i++)
@@ -561,6 +768,122 @@ bool Robot::calculateFK_AllJointsforwardKinematics(const double joints[6], doubl
}
}
// 基于 TCP 雅可比矩阵的奇异值分析,判断当前姿态是否接近奇异点。
json Robot::checkSingularity(const std::string &joints_str,
double singularThreshold,
double warningThreshold,
double conditionThreshold,
double conditionWarningThreshold)
{
try
{
if (!m_initialized)
{
return json{{"success", false}, {"error", "Robot not initialized"}};
}
std::vector<double> joints = parseJointString(joints_str);
if (joints.size() != 6)
{
return json{{"success", false}, {"error", "Invalid joints format"}};
}
if (!validateJointLimits(joints, "Singularity check input joints"))
{
return json{{"success", false}, {"error", "Joint value out of limits"}};
}
KDL::JntArray jointArray(6);
for (int i = 0; i < 6; i++)
{
jointArray(i) = joints[i];
}
KDL::Jacobian jacobian(kinematicChain.getNrOfJoints());
KDL::ChainJntToJacSolver jacSolver(kinematicChain);
const int status = jacSolver.JntToJac(jointArray, jacobian);
if (status < 0)
{
return json{{"success", false}, {"error", "Jacobian calculation failed"}, {"status", status}};
}
Eigen::JacobiSVD<Eigen::MatrixXd> svd(jacobian.data, Eigen::ComputeThinU | Eigen::ComputeThinV);
const auto singularValues = svd.singularValues();
json singularValuesJson = json::array();
double minSingularValue = std::numeric_limits<double>::infinity();
double maxSingularValue = 0.0;
double manipulability = 1.0;
int rank = 0;
for (int i = 0; i < singularValues.size(); i++)
{
const double value = singularValues(i);
singularValuesJson.push_back(value);
minSingularValue = std::min(minSingularValue, value);
maxSingularValue = std::max(maxSingularValue, value);
manipulability *= value;
if (value > singularThreshold)
{
rank++;
}
}
if (!std::isfinite(minSingularValue))
{
minSingularValue = 0.0;
}
const double conditionNumber = minSingularValue <= 0.0
? std::numeric_limits<double>::infinity()
: maxSingularValue / minSingularValue;
const bool isSingular = minSingularValue <= singularThreshold ||
conditionNumber >= conditionThreshold ||
rank < static_cast<int>(kinematicChain.getNrOfJoints());
const bool isNearSingular = !isSingular &&
(minSingularValue <= warningThreshold ||
conditionNumber >= conditionWarningThreshold);
const std::string riskLevel = isSingular ? "singular" : (isNearSingular ? "warning" : "normal");
json jacobianJson = json::array();
for (int row = 0; row < jacobian.data.rows(); row++)
{
json rowJson = json::array();
for (int col = 0; col < jacobian.data.cols(); col++)
{
rowJson.push_back(jacobian.data(row, col));
}
jacobianJson.push_back(rowJson);
}
return {
{"success", true},
{"is_singular", isSingular},
{"is_near_singular", isNearSingular},
{"risk_level", riskLevel},
{"rank", rank},
{"joint_count", kinematicChain.getNrOfJoints()},
{"min_singular_value", minSingularValue},
{"max_singular_value", maxSingularValue},
{"condition_number", conditionNumber},
{"manipulability", manipulability},
{"singular_values", singularValuesJson},
{"thresholds", {
{"singular", singularThreshold},
{"warning", warningThreshold},
{"condition", conditionThreshold},
{"condition_warning", conditionWarningThreshold},
}},
{"joints", joints},
{"jacobian", jacobianJson},
};
}
catch (const std::exception &e)
{
return json{{"success", false}, {"error", "Failed to check singularity: " + std::string(e.what())}};
}
}
/**
* @brief 计算四元数之间的夹角差,用于轨迹步数估算。
*/

View File

@@ -15,6 +15,7 @@ bool isRobotCommand(const std::string &req_cmd)
req_cmd == "Cmd_Kinematics_inverse_pose_str_NoDifference" ||
req_cmd == "Cmd_Kinematics_forward_pose_str" ||
req_cmd == "Cmd_Kinematics_forward_all_joints" ||
req_cmd == "Cmd_Kinematics_check_singularity" ||
req_cmd == "Cmd_InitRobot" ||
req_cmd == "Cmd_GetRobot" ||
req_cmd == "Cmd_RemoveRobot" ||
@@ -149,8 +150,6 @@ json KinematicsWebAPI::createUnknownCommandResponse(const std::string &req_cmd,
void KinematicsWebAPI::log(const std::string &message)
{
std::cout << "[" << getCurrentTimestamp() << "] " << message << std::endl;
if (onLog)
{
onLog("[" + getCurrentTimestamp() + "] " + message);

View File

@@ -7,13 +7,11 @@ json KinematicsWebAPI::handleQuadrupedCommand(const std::string &req_cmd, const
{
if (req_cmd == "Cmd_QuadrupedRobot_CalculateAllPointsFromMotorAngles")
{
std::cout << "[DEBUG] Cmd_QuadrupedRobot_CalculateAllPointsFromMotorAngles: 开始计算点位" << std::endl;
return KinematicsHelper::QuadrupedRobot_CalculateAllPointsFromMotorAngles(req_param.dump());
}
if (req_cmd == "Cmd_QuadrupedRobot_PerformForwardKinematics")
{
std::cout << "[DEBUG] Cmd_QuadrupedRobot_PerformForwardKinematics: 开始执行正运动学" << std::endl;
return KinematicsHelper::QuadrupedRobot_PerformForwardKinematics(req_param.dump());
}

View File

@@ -175,6 +175,39 @@ json KinematicsWebAPI::handleRobotCommand(const std::string &req_cmd, const json
res_data = {{"error", "Failed to calculate forward kinematics for all joints: " + std::string(e.what())}};
}
}
else if (req_cmd == "Cmd_Kinematics_check_singularity")
{
try
{
log("Handling Cmd_Kinematics_check_singularity command");
std::string joints_str = req_param.value("joints_str", req_param.value("q_init_str", "0,0,0,0,0,0"));
std::string robot_uuid = req_param.value("robot_uuid", "default");
double singular_threshold = req_param.value("singular_threshold", 1e-4);
double warning_threshold = req_param.value("warning_threshold", 1e-2);
double condition_threshold = req_param.value("condition_threshold", 1e6);
double condition_warning_threshold = req_param.value("condition_warning_threshold", 1e4);
auto robot = RobotManager::getRobot(robot_uuid);
if (!robot || !robot->isInitialized())
{
res_data = {{"error", "Robot not found or not initialized"}};
}
else
{
res_data = robot->checkSingularity(
joints_str,
singular_threshold,
warning_threshold,
condition_threshold,
condition_warning_threshold);
}
}
catch (const std::exception &e)
{
res_data = {{"error", "Failed to check singularity: " + std::string(e.what())}};
}
}
else if (req_cmd == "Cmd_InitRobot")
{
try