✨ feat(planning): 添加离线飞拍规划能力

* 固化离线飞拍轨迹加载、保存和规划结果。

* 真机初始化失败时保留模型加载后的离线规划能力。

* 更新状态接口、调试页和回归测试覆盖。
This commit is contained in:
2026-06-24 11:40:07 +08:00
parent 070d2ba5c0
commit 5193f537b0
24 changed files with 1398 additions and 738 deletions

View File

@@ -5,10 +5,10 @@ const HISTORY_LIMIT = 10;
const STATIC_DOCS_ELEMENT_ID = "debug-static-docs";
const groupTitleByPrefix = [
// 基础与状态分组:探活和状态快照两个固定 API 路径
{ match: function (op) { return op.path === "/healthz" || op.path === "/api/status/snapshot"; }, title: "基础与状态" },
// 默认兜底:剩余全部走 ControllerClient 兼容分组
{ match: function () { return true; }, title: "ControllerClient 兼容" }
{ match: function (op) { return ["/healthz", "/status", "/debug", "/api/status/snapshot"].indexOf(op.path) >= 0; }, title: "基础状态" },
{ match: function (op) { return ["/setup_robot/", "/init_mpc_robot", "/connect_robot/", "/enable_robot/", "/disable_robot/", "/disconnect_robot/", "/stop_move/"].indexOf(op.path) >= 0; }, title: "初始化与维护" },
{ match: function (op) { return ["/move_joint/", "/move_pose/", "/set_tcp/", "/get_tcp/", "/set_io/", "/get_io/", "/get_joint_position/", "/get_pose", "/robot_info/"].indexOf(op.path) >= 0; }, title: "机器人操作" },
{ match: function () { return true; }, title: "轨迹与飞拍" }
];
const state = {
@@ -19,6 +19,10 @@ const state = {
};
const requestBodySamples = {
"POST /init_mpc_robot": {
robot_name: "FANUC_LR_Mate_200iD",
robot_ip: "192.168.10.101"
},
"POST /move_pose/": {
x: 100.0,
y: 200.0,
@@ -26,9 +30,19 @@ const requestBodySamples = {
w: 0.0,
p: 45.0,
r: 0.0
},
"POST /save_traj_info/": {
name: "demo-flyshot",
method: "icsp"
}
};
const offlinePlanningState = {
result: null,
page: 1,
pageSize: 50
};
/** 简单的 escape:把任意字符串安全嵌入 textContent 之外的位置时使用。 */
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, function (ch) {
@@ -781,9 +795,9 @@ function renderGroups(operations) {
grouped.get(groupTitle).push(op);
});
// 固定输出顺序:基础与状态在前,ControllerClient 兼容在后,其余按字典序。
// 固定输出顺序与现场操作顺序一致。
const orderedTitles = [];
["基础与状态", "ControllerClient 兼容"].forEach(function (title) {
["基础状态", "初始化与维护", "机器人操作", "轨迹与飞拍"].forEach(function (title) {
if (grouped.has(title)) orderedTitles.push(title);
});
Array.from(grouped.keys()).sort().forEach(function (title) {
@@ -807,6 +821,144 @@ function renderGroups(operations) {
});
}
/** 调用 JSON API,并在非 2xx 时把服务端 detail 作为异常抛出。 */
async function requestJson(url, init) {
const response = await fetch(url, init || {});
const text = await response.text();
let payload = null;
if (text) {
try { payload = JSON.parse(text); } catch (error) { payload = { detail: text }; }
}
if (!response.ok) {
throw new Error(payload && payload.detail ? payload.detail : "HTTP " + response.status);
}
return payload;
}
/** 刷新固定 RobotConfig.json 中的飞拍轨迹下拉列表。 */
async function refreshOfflineTrajectoryList() {
const select = document.getElementById("offline-trajectory-select");
const payload = await requestJson("/list_flyShotTraj/");
const names = payload.flyshot_trajs || [];
select.innerHTML = "";
names.forEach(function (name) {
const option = document.createElement("option");
option.value = name;
option.textContent = name;
select.appendChild(option);
});
}
/** 按页渲染最终发送点,避免大点列一次性创建过多 DOM。 */
function renderOfflineSendPage() {
const result = offlinePlanningState.result;
const body = document.querySelector("#offline-send-table tbody");
body.innerHTML = "";
if (!result) {
document.getElementById("offline-page-info").textContent = "0 / 0";
return;
}
const points = result.finalSendPoints || [];
const pageCount = Math.max(1, Math.ceil(points.length / offlinePlanningState.pageSize));
offlinePlanningState.page = Math.min(Math.max(1, offlinePlanningState.page), pageCount);
const start = (offlinePlanningState.page - 1) * offlinePlanningState.pageSize;
points.slice(start, start + offlinePlanningState.pageSize).forEach(function (point) {
const row = document.createElement("tr");
[
point.sampleIndex,
Number(point.sendTime).toFixed(6),
Number(point.trajectoryTime).toFixed(6),
(point.jointsDegrees || []).map(function (value) { return Number(value).toFixed(4); }).join(", "),
point.ioMask,
point.ioValue
].forEach(function (value) {
const cell = document.createElement("td");
cell.textContent = value;
row.appendChild(cell);
});
body.appendChild(row);
});
document.getElementById("offline-page-info").textContent = offlinePlanningState.page + " / " + pageCount;
}
/** 展示离线规划摘要、触发绑定、最终发送点和导出路径。 */
function renderOfflinePlanningResult(result) {
offlinePlanningState.result = result;
offlinePlanningState.page = 1;
document.getElementById("offline-planning-summary").innerHTML =
"<dl class=\"offline-summary\">" +
"<dt>运行结果</dt><dd>" + escapeHtml(result.isValid ? "有效" : "无效") + "</dd>" +
"<dt>规划时长</dt><dd>" + Number(result.planningDurationSeconds).toFixed(6) + " s</dd>" +
"<dt>最终发送时长</dt><dd>" + Number(result.finalSendDurationSeconds).toFixed(6) + " s</dd>" +
"<dt>关键/稠密/发送点</dt><dd>" + result.plannedWaypointCount + " / " + result.densePointCount + " / " + result.finalSendPointCount + "</dd>" +
"<dt>速度倍率</dt><dd>" + result.speedRatio + "</dd>" +
"</dl>";
document.getElementById("offline-shot-events").textContent =
"ShotEvents / TriggerBindings\n" + JSON.stringify({
shotEvents: result.shotEvents,
triggerBindings: result.triggerBindings
}, null, 2);
document.getElementById("offline-artifact-paths").textContent =
"导出文件\n" + JSON.stringify(result.artifactPaths, null, 2);
renderOfflineSendPage();
}
/** 初始化离线规划操作面板。 */
function initializeOfflinePlanningPanel() {
const status = document.getElementById("offline-planning-status");
document.getElementById("offline-load-model").addEventListener("click", async function () {
try {
const robotName = document.getElementById("offline-robot-name").value.trim();
await requestJson("/setup_robot/?robot_name=" + encodeURIComponent(robotName), { method: "POST" });
status.textContent = "模型已加载,当前可离线规划。";
} catch (error) {
status.textContent = "模型加载失败: " + error.message;
}
});
document.getElementById("offline-refresh-trajectories").addEventListener("click", async function () {
try { await refreshOfflineTrajectoryList(); status.textContent = "轨迹列表已刷新。"; }
catch (error) { status.textContent = "刷新失败: " + error.message; }
});
document.getElementById("offline-upload").addEventListener("click", async function () {
try {
const body = document.getElementById("offline-upload-json").value;
await requestJson("/upload_flyshot/", { method: "POST", headers: { "Content-Type": "application/json" }, body: body });
await refreshOfflineTrajectoryList();
status.textContent = "轨迹已上传到固定 RobotConfig.json。";
} catch (error) {
status.textContent = "上传失败: " + error.message;
}
});
document.getElementById("offline-generate").addEventListener("click", async function () {
try {
const name = document.getElementById("offline-trajectory-select").value;
const method = document.getElementById("offline-planning-method").value;
status.textContent = "正在生成最终发送轨迹...";
const result = await requestJson("/save_traj_info/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name, method: method })
});
renderOfflinePlanningResult(result);
status.textContent = "离线规划完成,未向机器人发送命令。";
} catch (error) {
status.textContent = "离线规划失败: " + error.message;
}
});
document.getElementById("offline-prev-page").addEventListener("click", function () {
offlinePlanningState.page--;
renderOfflineSendPage();
});
document.getElementById("offline-next-page").addEventListener("click", function () {
offlinePlanningState.page++;
renderOfflineSendPage();
});
refreshOfflineTrajectoryList().catch(function (error) {
status.textContent = "轨迹列表加载失败: " + error.message;
});
}
/** 加载 OpenAPI 文档并渲染。 */
async function loadSpecAndRender() {
const metaSpec = document.getElementById("meta-spec-url");
@@ -854,4 +1006,5 @@ document.getElementById("history-clear").addEventListener("click", function () {
});
renderHistory();
initializeOfflinePlanningPanel();
loadSpecAndRender();