Compare commits
13 Commits
71bf027fde
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9192ccbe83 | ||
|
|
78c80cf5f7 | ||
|
|
5ca2657536 | ||
|
|
37517d5279 | ||
|
|
856a7250f2 | ||
|
|
5aa855d7aa | ||
|
|
0c7dc9e05b | ||
|
|
a100e44ac0 | ||
|
|
e26b04e915 | ||
|
|
c2d79a74b9 | ||
|
|
a3efcd66a5 | ||
|
|
aaf2c0cc41 | ||
|
|
eeef7594fd |
496
docs/robot_kinematics_api.md
Normal file
496
docs/robot_kinematics_api.md
Normal 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 输入关节角。
|
||||
314
docs/robot_kinematics_capabilities.md
Normal file
314
docs/robot_kinematics_capabilities.md
Normal 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
309
docs/urdf.xml
Normal 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>
|
||||
@@ -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 运动学链。
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
3
spc-vue/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
*.local
|
||||
34
spc-vue/README.md
Normal file
34
spc-vue/README.md
Normal 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
13
spc-vue/index.html
Normal 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
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
23
spc-vue/package.json
Normal 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
39
spc-vue/src/App.vue
Normal 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>
|
||||
59
spc-vue/src/components/EChartPanel.vue
Normal file
59
spc-vue/src/components/EChartPanel.vue
Normal 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>
|
||||
12
spc-vue/src/components/JsonPreview.vue
Normal file
12
spc-vue/src/components/JsonPreview.vue
Normal 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>
|
||||
14
spc-vue/src/components/MetricStrip.vue
Normal file
14
spc-vue/src/components/MetricStrip.vue
Normal 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>
|
||||
54
spc-vue/src/components/SpcInputPanel.vue
Normal file
54
spc-vue/src/components/SpcInputPanel.vue
Normal 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>
|
||||
133
spc-vue/src/composables/useSpc.ts
Normal file
133
spc-vue/src/composables/useSpc.ts
Normal 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
3
spc-vue/src/config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const appConfig = {
|
||||
wasmScriptUrl: "/wasm/smart_math.js"
|
||||
};
|
||||
25
spc-vue/src/data/sampleSpc.ts
Normal file
25
spc-vue/src/data/sampleSpc.ts
Normal 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
6
spc-vue/src/main.ts
Normal 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
28
spc-vue/src/router.ts
Normal 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
|
||||
});
|
||||
136
spc-vue/src/services/wasmSpcClient.ts
Normal file
136
spc-vue/src/services/wasmSpcClient.ts
Normal 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
478
spc-vue/src/styles.css
Normal 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
96
spc-vue/src/types/spc.ts
Normal 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;
|
||||
}
|
||||
376
spc-vue/src/utils/chartOptions.ts
Normal file
376
spc-vue/src/utils/chartOptions.ts
Normal 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" }
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
36
spc-vue/src/views/CpkView.vue
Normal file
36
spc-vue/src/views/CpkView.vue
Normal 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>
|
||||
38
spc-vue/src/views/HistogramView.vue
Normal file
38
spc-vue/src/views/HistogramView.vue
Normal 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>
|
||||
37
spc-vue/src/views/NormalCurveView.vue
Normal file
37
spc-vue/src/views/NormalCurveView.vue
Normal 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>
|
||||
56
spc-vue/src/views/OverviewView.vue
Normal file
56
spc-vue/src/views/OverviewView.vue
Normal 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`。左侧可以替换 n、k、规格限和测量数据,所有控制图页面共用同一份计算结果。
|
||||
</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>
|
||||
45
spc-vue/src/views/RChartView.vue
Normal file
45
spc-vue/src/views/RChartView.vue
Normal 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>
|
||||
49
spc-vue/src/views/RunChartView.vue
Normal file
49
spc-vue/src/views/RunChartView.vue
Normal 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>
|
||||
45
spc-vue/src/views/SChartView.vue
Normal file
45
spc-vue/src/views/SChartView.vue
Normal 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>
|
||||
46
spc-vue/src/views/XBarRView.vue
Normal file
46
spc-vue/src/views/XBarRView.vue
Normal 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>
|
||||
45
spc-vue/src/views/XBarSView.vue
Normal file
45
spc-vue/src/views/XBarSView.vue
Normal 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
19
spc-vue/tsconfig.json
Normal 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
48
spc-vue/vite.config.ts
Normal 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
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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(); // 使用新方法
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
379
src/Robot.cpp
379
src/Robot.cpp
@@ -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;
|
||||
}
|
||||
|
||||
// 提取 base 到 tool0 的运动学链。
|
||||
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 计算四元数之间的夹角差,用于轨迹步数估算。
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user