✨ feat(fanuc): 添加直角坐标点动功能与相关接口
* 新增 `MovePose` 方法,支持以直角坐标执行点到点移动。 * 引入 `LegacyCartesianPoseRequest` 类,处理直角位姿请求体的解析与验证。 * 更新 `LegacyHttpApiController`,实现 `/move_pose/` 路由以支持新功能。 * 增强状态快照元数据,提供机器人初始化状态与已上传轨迹信息。 * 更新前端状态页面,增加直角坐标点动控制面板与步长设置选项。 * 相关文档与测试用例同步更新,确保新功能的完整性与稳定性。
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user