feat(fanuc): 添加直角坐标点动功能与相关接口

* 新增 `MovePose` 方法,支持以直角坐标执行点到点移动。
* 引入 `LegacyCartesianPoseRequest` 类,处理直角位姿请求体的解析与验证。
* 更新 `LegacyHttpApiController`,实现 `/move_pose/` 路由以支持新功能。
* 增强状态快照元数据,提供机器人初始化状态与已上传轨迹信息。
* 更新前端状态页面,增加直角坐标点动控制面板与步长设置选项。
* 相关文档与测试用例同步更新,确保新功能的完整性与稳定性。
This commit is contained in:
2026-05-14 17:46:42 +08:00
parent d120ef2a39
commit 2cd42f04e5
22 changed files with 2062 additions and 104 deletions

View File

@@ -16,6 +16,17 @@ const state = {
history: []
};
const requestBodySamples = {
"POST /move_pose/": {
x: 100.0,
y: 200.0,
z: 300.0,
w: 0.0,
p: 45.0,
r: 0.0
}
};
/** 简单的 escape把任意字符串安全嵌入 textContent 之外的位置时使用。 */
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, function (ch) {
@@ -137,6 +148,11 @@ function storageKey(op) {
return STORAGE_PREFIX + op.method + ":" + op.path;
}
/** 返回指定端点的手工调试样例,处理 JsonElement 等 OpenAPI 无法自动推断字段的接口。 */
function getRequestBodySample(op) {
return requestBodySamples[op.method + " " + op.path] || null;
}
/** 读取本端点最近一次输入;解析失败则当作空。 */
function loadInputs(op) {
try {
@@ -281,7 +297,7 @@ function renderBodyEditor(container, op, savedBody) {
if (savedBody !== undefined && savedBody !== null) {
initialText = typeof savedBody === "string" ? savedBody : JSON.stringify(savedBody, null, 2);
} else {
const sample = buildSampleFromSchema(op.bodySchema, 0);
const sample = getRequestBodySample(op) || buildSampleFromSchema(op.bodySchema, 0);
initialText = sample === null ? "" : JSON.stringify(sample, null, 2);
}
textarea.value = initialText;

View File

@@ -188,6 +188,68 @@ dd {
font-family: inherit;
}
.jog-panel {
grid-column: 1 / -1;
}
.jog-content {
padding: 16px;
}
.jog-settings {
display: grid;
grid-template-columns: repeat(2, minmax(180px, 1fr));
gap: 12px;
margin-bottom: 14px;
}
.jog-settings label {
display: grid;
gap: 6px;
color: var(--muted);
font-size: 13px;
}
.jog-settings input {
min-height: 36px;
width: 100%;
padding: 0 10px;
border: 1px solid var(--line);
border-radius: 6px;
background: #ffffff;
color: var(--text);
font: inherit;
}
.jog-grid {
display: grid;
grid-template-columns: repeat(6, minmax(64px, 1fr));
gap: 8px;
}
.jog-button {
min-height: 44px;
padding: 0 8px;
font-weight: 650;
touch-action: none;
user-select: none;
}
.jog-button.active {
filter: brightness(0.88);
}
.jog-status {
min-height: 24px;
margin-top: 12px;
color: var(--muted);
font-family: Consolas, "Cascadia Mono", monospace;
}
.jog-status.error {
color: var(--bad);
}
@media (max-width: 820px) {
.topbar {
align-items: flex-start;
@@ -199,6 +261,11 @@ dd {
grid-template-columns: 1fr;
}
.jog-settings,
.jog-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
dl {
grid-template-columns: 1fr;
}

View File

@@ -15,7 +15,28 @@ const fields = {
joints: document.getElementById("joints"),
pose: document.getElementById("pose"),
trajectories: document.getElementById("trajectories"),
refresh: document.getElementById("refresh")
refresh: document.getElementById("refresh"),
linearStep: document.getElementById("linear-step"),
angularStep: document.getElementById("angular-step"),
jogStatus: document.getElementById("jog-status"),
jogButtons: Array.from(document.querySelectorAll(".jog-button"))
};
const axisIndexes = {
x: 0,
y: 1,
z: 2,
w: 3,
p: 4,
r: 5
};
// 点动状态集中保存,确保按住按钮时不会并发发送多条 MovePose 请求。
const jogState = {
timer: null,
activeButton: null,
inFlight: false,
lastSnapshot: null
};
function formatArray(values) {
@@ -56,12 +77,136 @@ function setDot(connectionState) {
}
}
function clampStep(input, min, max) {
const value = Number(input.value);
if (!Number.isFinite(value)) {
input.value = String(min);
return min;
}
const clamped = Math.min(Math.max(value, min), max);
input.value = String(clamped);
return clamped;
}
function getStepForAxis(axis) {
// 平移和姿态使用不同单位,但共享 0.1 到 10 的现场可调范围。
return axis === "x" || axis === "y" || axis === "z"
? clampStep(fields.linearStep, 0.1, 10)
: clampStep(fields.angularStep, 0.1, 10);
}
function setJogStatus(message, isError) {
fields.jogStatus.textContent = message;
fields.jogStatus.classList.toggle("error", Boolean(isError));
}
async function loadSnapshotForJog() {
const response = await fetch("/api/status/snapshot", { cache: "no-store" });
if (!response.ok) {
throw new Error(`状态快照读取失败: HTTP ${response.status}`);
}
const payload = await response.json();
jogState.lastSnapshot = payload.snapshot;
return payload.snapshot;
}
function buildJogPose(snapshot, axis, direction) {
// 每次点动都从最新 TCP 位姿出发,只修改一个轴,避免连续按压时累积本地误差。
const sourcePose = Array.isArray(snapshot.cartesianPose) ? snapshot.cartesianPose : [];
if (sourcePose.length < 6) {
throw new Error("当前 TCP 位姿不足 6 维,无法点动。");
}
const pose = sourcePose.slice(0, 6).map(Number);
if (pose.some(value => !Number.isFinite(value))) {
throw new Error("当前 TCP 位姿包含非数值,无法点动。");
}
const axisIndex = axisIndexes[axis];
pose[axisIndex] = Number((pose[axisIndex] + direction * getStepForAxis(axis)).toFixed(6));
return {
x: pose[0],
y: pose[1],
z: pose[2],
w: pose[3],
p: pose[4],
r: pose[5]
};
}
async function sendJog(button) {
// MovePose 底层会生成完整直角轨迹;前端这里只负责构造增量目标位姿。
if (jogState.inFlight) {
return;
}
const axis = button.dataset.axis;
const direction = Number(button.dataset.direction);
if (!Object.prototype.hasOwnProperty.call(axisIndexes, axis) || !Number.isFinite(direction)) {
return;
}
jogState.inFlight = true;
try {
const snapshot = await loadSnapshotForJog();
const pose = buildJogPose(snapshot, axis, direction);
const response = await fetch("/move_pose/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(pose)
});
if (!response.ok) {
throw new Error(`MovePose 调用失败: HTTP ${response.status}`);
}
setJogStatus(`${axis.toUpperCase()}${direction > 0 ? "+" : "-"} 已发送`, false);
await refreshStatus();
} catch (error) {
setJogStatus(error instanceof Error ? error.message : "点动失败", true);
stopJog();
} finally {
jogState.inFlight = false;
}
}
function startJog(event) {
const button = event.currentTarget;
if (!button || button.disabled) {
return;
}
event.preventDefault();
stopJog();
jogState.activeButton = button;
button.classList.add("active");
sendJog(button);
jogState.timer = window.setInterval(function () {
sendJog(button);
}, 250);
}
function stopJog() {
if (jogState.timer !== null) {
window.clearInterval(jogState.timer);
jogState.timer = null;
}
if (jogState.activeButton) {
jogState.activeButton.classList.remove("active");
jogState.activeButton = null;
}
}
async function refreshStatus() {
fields.refresh.disabled = true;
try {
const response = await fetch("/api/status/snapshot", { cache: "no-store" });
const payload = await response.json();
const snapshot = payload.snapshot;
jogState.lastSnapshot = snapshot;
fields.connectionState.textContent = snapshot.connectionState;
fields.robotName.textContent = payload.robotName || "--";
@@ -88,5 +233,23 @@ async function refreshStatus() {
}
fields.refresh.addEventListener("click", refreshStatus);
fields.linearStep.addEventListener("change", function () {
clampStep(fields.linearStep, 0.1, 10);
});
fields.angularStep.addEventListener("change", function () {
clampStep(fields.angularStep, 0.1, 10);
});
fields.jogButtons.forEach(function (button) {
button.addEventListener("pointerdown", startJog);
button.addEventListener("pointerup", stopJog);
button.addEventListener("pointercancel", stopJog);
button.addEventListener("pointerleave", stopJog);
});
window.addEventListener("blur", stopJog);
window.addEventListener("keyup", function (event) {
if (event.key === "Escape") {
stopJog();
}
});
refreshStatus();
window.setInterval(refreshStatus, 2000);

View File

@@ -57,6 +57,36 @@
<dt>已上传轨迹</dt><dd id="trajectories" class="empty">--</dd>
</dl>
</section>
<section class="jog-panel">
<h2>直角坐标点动</h2>
<div class="jog-content">
<div class="jog-settings">
<label for="linear-step">
<span>平移步长 mm</span>
<input id="linear-step" type="number" min="0.1" max="10" step="0.1" value="1">
</label>
<label for="angular-step">
<span>姿态步长 deg</span>
<input id="angular-step" type="number" min="0.1" max="10" step="0.1" value="1">
</label>
</div>
<div class="jog-grid" aria-label="直角坐标点动按钮">
<button class="jog-button" type="button" data-axis="x" data-direction="-1">X-</button>
<button class="jog-button" type="button" data-axis="x" data-direction="1">X+</button>
<button class="jog-button" type="button" data-axis="y" data-direction="-1">Y-</button>
<button class="jog-button" type="button" data-axis="y" data-direction="1">Y+</button>
<button class="jog-button" type="button" data-axis="z" data-direction="-1">Z-</button>
<button class="jog-button" type="button" data-axis="z" data-direction="1">Z+</button>
<button class="jog-button" type="button" data-axis="w" data-direction="-1">W-</button>
<button class="jog-button" type="button" data-axis="w" data-direction="1">W+</button>
<button class="jog-button" type="button" data-axis="p" data-direction="-1">P-</button>
<button class="jog-button" type="button" data-axis="p" data-direction="1">P+</button>
<button class="jog-button" type="button" data-axis="r" data-direction="-1">R-</button>
<button class="jog-button" type="button" data-axis="r" data-direction="1">R+</button>
</div>
<div id="jog-status" class="jog-status">--</div>
</div>
</section>
</div>
</main>
<script src="/assets/status.js" defer></script>