✨ feat(planning): 添加离线飞拍规划能力
* 固化离线飞拍轨迹加载、保存和规划结果。 * 真机初始化失败时保留模型加载后的离线规划能力。 * 更新状态接口、调试页和回归测试覆盖。
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user