Fix web homing motion and synchronize preview state

This commit is contained in:
2026-09-09 09:31:32 +08:00
parent bade560372
commit eb5c58cdb3
11 changed files with 375 additions and 33 deletions

View File

@@ -33,6 +33,7 @@ import { axisGate, xyzacGate, xyzacGateMatrix } from "../app/axis-contract.mjs";
const PROTOCOL_SCHEMA = 1;
const CONTEXT_ID = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1";
const XYZAC_AXIS_TO_JOINT = Object.freeze({ x: 0, y: 1, z: 2, a: 3, c: 4 });
const OPERATION_KINDS = new Set([
"SESSION_ATTACH", "LOAD_WORKSPACE", "SIM_STEP", "DESTROY", "CHECKPOINT", "RECOVER", "ABORT",
@@ -135,6 +136,12 @@ export class SimSession {
this.xyzacDeviceIo = null;
this.xyzacProgram = null;
this.xyzacProgramKinematicsMode = XYZAC_MODES.identity;
// The Web panel presents fixed XYZAC world-axis controls. Homing still
// follows AXIS's KINEMATICS_BOTH transaction and uses a numeric joint;
// keep the presentation mode separate so the adapter can restore it
// after the accepted home transaction.
this.xyzacMotionMode = "TELEOP";
this.pendingHome = null;
this.xyzacRapidVelocity = 35;
this.xyzacMotionCycleTime = 0.01;
this.xyzacTaskCycleTicks = 1;
@@ -301,6 +308,7 @@ export class SimSession {
limits: { x: Boolean(home.state?.limit_error), y: Boolean(home.state?.limit_error), z: Boolean(home.state?.limit_error), a: Boolean(home.state?.limit_error), c: Boolean(home.state?.limit_error) },
on_any_limit: Boolean(home.state?.limit_error),
following_error: 0,
motion_mode: this.xyzacProgram ? this.xyzacMotionMode : (home.state?.homed ? "TELEOP" : "FREE"),
jog: null,
stepping: this.wasmMotion.isStepping?.() ?? false,
estop: Boolean(ioState?.estop),
@@ -393,7 +401,7 @@ export class SimSession {
const gateActions = ["load", "run", "runLine", "step", "pause", "resume", "abort", "restart", "reload", "edit", "mdi", "jogStart", "jogStop", "home", "estop", "recover", "override", "overrideLimits", "reset", "spindle", "coolant", "touchOff", "toolTouchOff"];
const ui = axisSourceUi({ task_state: axis.source_task_state, interp_state: axis.source_interp_state,
taskfile: Boolean(this.xyzacProgram?.path), queued_mdi_commands: task.queued_mdi_commands ?? 0,
motion_mode: homeLimit.state?.homed ? "TELEOP" : "FREE", kinematics_type: "BOTH",
motion_mode: this.xyzacProgram ? this.xyzacMotionMode : (homeLimit.state?.homed ? "TELEOP" : "FREE"), kinematics_type: "BOTH",
spindle_direction: Number(devices.spindles?.[0]?.direction ?? 0), tool: Number(devices.tool?.current ?? 0),
on_any_limit: motion.on_any_limit, paused: axis.operation_state === "PAUSED" });
const gates = this.xyzacProgram
@@ -449,6 +457,10 @@ export class SimSession {
schema: "axis-visual.v1",
pose: { ...position },
joints: Array.isArray(joints) ? [...joints] : [],
// Home changes the canonical current pose even when the simulation
// tick does not advance. The renderer uses this monotonic source
// sequence to reset its displayed pose without clearing Live Plot.
pose_epoch: Number(homeLimit.state?.home_sequence ?? 0),
live_path_epoch: this.livePathEpoch,
...(includeToolpath ? { toolpath: (preview?.segments ?? []).map((segment, index) => ({ id: index + 1, ...segment })) } : {}),
active_segment_id: wasmMotion.status === "verified" ? wasmMotion.state.active_id : authoritative.planner.active_id,
@@ -551,6 +563,9 @@ export class SimSession {
return { action: input.payload?.type === 0 ? "switchkinsIdentity" : input.payload?.type === 1 ? "switchkinsXyzac" : "switchkinsUserk", enabled: false, reason: "xyzac_motion_active", taskState: task.state, interpState: "READING", wasm: { queue_depth: wasmMotion.state.queue_depth, active_id: wasmMotion.state.active_id } };
}
const homeLimit = this.wasmHomeLimit.read();
if (this.pendingHome && ["HOME", "JOG_START", "MDI_EXECUTE", "PROGRAM_LOAD", "PROGRAM_RUN", "PROGRAM_STEP", "KINEMATICS_SELECT", "SET_MODE", "TOUCH_OFF", "SPINDLE_COMMAND", "SPINDLE_CONSTANT", "SPINDLE_BRAKE", "COOLANT_COMMAND"].includes(input.kind)) {
return { action: PANEL_GATE_ACTIONS[input.kind] ?? "home", enabled: false, reason: "xyzac_home_in_progress", taskState: task.state, interpState: "READING" };
}
const estop = Boolean(io.estop);
const ready = Boolean(io.ready) && !estop;
const loaded = program.status !== 0;
@@ -1372,6 +1387,8 @@ export class SimSession {
this.xyzacAppendDeferredOnActive = false;
this.wasmMotion.abort();
this.wasmMotion.setPaused(true);
this.pendingHome = null;
this.xyzacMotionMode = "TELEOP";
const resetProgram = this.resetXyzacProgramToLoaded();
if (resetProgram.status !== "verified") return finish(this.error(input, "program_restart", resetProgram.code ?? "program_reload_failed"), "failed");
this.task.setProgram("loaded", 0);
@@ -1475,6 +1492,8 @@ export class SimSession {
this.xyzacAppendDeferredOnActive = false;
this.wasmMotion.abort();
this.wasmMotion.setPaused(true);
this.pendingHome = null;
this.xyzacMotionMode = "TELEOP";
const resetProgram = this.resetXyzacProgramToLoaded();
if (resetProgram.status !== "verified") return finish(this.error(input, "program_restart", resetProgram.code ?? "program_reload_failed"), "failed");
this.task.setProgram("loaded", 0);
@@ -1594,6 +1613,8 @@ export class SimSession {
: new WasmMotionQueue(this.core, { cycleTime: this.xyzacMotionCycleTime, velocityLimit: 1e9 });
this.xyzacProgram = new WasmProgramRuntime(this.core);
this.xyzacProgramKinematicsMode = XYZAC_MODES.identity;
this.xyzacMotionMode = "TELEOP";
this.pendingHome = null;
if (typeof this.wasmMotion.setKinematics === "function") this.wasmMotion.setKinematics(this.xyzacKinematics);
this.xyzacRapidVelocity = Number(configuration.kinematics?.max_linear_velocity ?? 35);
const linearLimit = Number(configuration.kinematics?.axis_linear_velocity_limit ?? 20);
@@ -1941,6 +1962,18 @@ export class SimSession {
}
}
}
if (this.pendingHome && wasmMotion.status === "verified" && Number(wasmMotion.state.queue_depth ?? 0) === 0) {
const pendingHome = this.pendingHome;
const home = this.wasmHomeLimit.home(pendingHome.seq, pendingHome.all ? null : pendingHome.joint);
if (home.status !== "verified") throw new Error(home.code ?? "wasm_home_commit_failed");
if (this.xyzacProgram && typeof this.xyzacProgram.synchronizePosition === "function") {
const synchronized = this.xyzacProgram.synchronizePosition(home.state.pose);
if (synchronized.status !== "verified") throw new Error(synchronized.code ?? "home_position_sync_failed");
}
this.xyzacMotionMode = pendingHome.modeBefore;
this.task.setMode("MANUAL");
this.pendingHome = null;
}
if (motionState?.fault && this.task.state.state === "RUNNING") this.task.fault(-1205);
if (this.motionAuthority === "wasm") {
if (wasmMotion.status === "verified") {
@@ -2392,17 +2425,54 @@ export class SimSession {
case "HOME": {
if (this.xyzacProgram) {
if (this.wasmHomeLimit.read().status !== "verified") return finish(this.error(input, "home_limit", "wasm_home_limit_unavailable"), "failed");
const requestedAxis = input.payload?.all ? null : String(input.payload?.axis ?? "").toLowerCase() || null;
if (requestedAxis) return finish(this.error(input, "home_limit", "home_joint_requires_joint_mode"), "failed");
const requestedJoint = input.payload?.joint;
if (requestedJoint !== undefined && (!Number.isInteger(requestedJoint) || requestedJoint < 0 || requestedJoint >= 5)) return finish(this.error(input, "home_limit", "home_joint_invalid"), "failed");
const payload = input.payload ?? {};
// Older callers used HOME with an empty payload for Home All. Keep
// that compatible while making the explicit Web contract
// HOME {all:true}.
const all = payload.all === true || (payload.all === undefined && payload.axis === undefined && payload.joint === undefined);
const requestedAxis = all ? null : String(payload.axis ?? "").trim().toLowerCase() || null;
let requestedJoint = payload.joint;
if (requestedAxis !== null) {
const mappedJoint = XYZAC_AXIS_TO_JOINT[requestedAxis];
if (mappedJoint === undefined) return finish(this.error(input, "home_limit", "home_axis_invalid"), "failed");
if (requestedJoint !== undefined && requestedJoint !== mappedJoint) return finish(this.error(input, "home_limit", "home_axis_joint_mismatch"), "failed");
requestedJoint = mappedJoint;
}
if (!all && requestedJoint === undefined) return finish(this.error(input, "home_limit", "home_joint_required"), "failed");
if (!all && (!Number.isInteger(requestedJoint) || requestedJoint < 0 || requestedJoint >= 5)) return finish(this.error(input, "home_limit", "home_joint_invalid"), "failed");
// AXIS home_all_joints()/home_joint() calls ensure_mode(MANUAL)
// before issuing the homing command.
// before go_home() selects FREE/joint and issues c.home().
try { if (this.task.state.mode !== "MANUAL") this.task.setMode("MANUAL"); } catch (error) { return finish(this.error(input, "task_mode", String(error?.message ?? error)), "failed"); }
const home = this.wasmHomeLimit.home(input.seq, input.payload?.all ? null : requestedJoint ?? null);
if (home.status === "error") return finish(this.error(input, "home_limit", home.code), "failed");
const modeBefore = this.xyzacMotionMode;
const currentMotion = this.wasmMotion.read();
const currentPose = currentMotion.state?.position;
const currentJoints = Array.isArray(currentMotion.state?.joint_commanded) && currentMotion.state.joint_commanded.length === 5
? [...currentMotion.state.joint_commanded]
: [...(this.xyzacKinematics?.snapshot()?.joints ?? [currentPose.x, currentPose.y, currentPose.z, currentPose.a ?? 0, currentPose.c ?? 0])];
const targetHome = this.wasmHomeLimit.previewHome(all ? null : requestedJoint);
if (targetHome.status !== "verified") return finish(this.error(input, "home_limit", targetHome.code ?? "wasm_home_target_failed"), "failed");
const targetPose = targetHome.state.pose;
const needsMove = ["x", "y", "z", "a", "b", "c", "u", "v", "w"].some((axis) => Math.abs(Number(currentPose?.[axis] ?? 0) - Number(targetPose?.[axis] ?? 0)) > 1e-9);
this.xyzacMotionMode = "FREE";
if (needsMove) {
const enqueue = this.wasmMotion.enqueue({
start: currentPose,
end: targetPose,
velocity: Math.max(Number(this.xyzacRapidVelocity ?? 35), Number.EPSILON),
acceleration: Math.max(Number(this.xyzacAxisAccelerationLimits?.x ?? 300), Number.EPSILON),
motion_type: 1,
source_line: 0,
kinematics_mode: this.xyzacProgramKinematicsMode,
});
if (enqueue.status !== "verified") { this.xyzacMotionMode = modeBefore; return finish(this.error(input, "home_limit", enqueue.code ?? "home_motion_enqueue_failed"), "failed"); }
this.pendingHome = { seq: input.seq, all, axis: requestedAxis, joint: all ? null : requestedJoint, modeBefore, currentJoints, targetPose };
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, homing: { phase: "moving", target: all ? "all" : "joint", source_axis: requestedAxis, joint: all ? null : requestedJoint, motion_mode_before: modeBefore, motion_mode_during: "FREE", motion_mode_after: "FREE", target_pose: targetPose }, motion: this.wasmMotionProjection(), wasm_home_limit: this.wasmHomeLimit.read(), motion_authority: "wasm", operation_id: input.operation_id ?? null }), "verified");
}
const home = this.wasmHomeLimit.home(input.seq, all ? null : requestedJoint);
if (home.status !== "verified") { this.xyzacMotionMode = modeBefore; return finish(this.error(input, "home_limit", home.code), "failed"); }
this.xyzacMotionMode = modeBefore;
this.task.setMode("MANUAL");
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, motion: this.wasmMotionProjection(), wasm_home_limit: this.wasmHomeLimit.read(), motion_authority: "wasm", operation_id: input.operation_id ?? null }), "verified");
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, homing: { phase: "complete", target: all ? "all" : "joint", source_axis: requestedAxis, joint: all ? null : requestedJoint, motion_mode_before: modeBefore, motion_mode_during: "FREE", motion_mode_after: this.xyzacMotionMode }, motion: this.wasmMotionProjection(), wasm_home_limit: home, motion_authority: "wasm", operation_id: input.operation_id ?? null }), "verified");
}
const result = this.motion.home(input.payload?.axis ?? null);
if (result.status === "verified" && this.motionAuthority === "wasm") {
@@ -2450,13 +2520,28 @@ export class SimSession {
if (!["x", "y", "z", "a", "c"].includes(axis)) return finish(this.error(input, "motion_axis", "motion_axis_invalid"), "failed");
const direction = input.payload?.direction === -1 ? -1 : input.payload?.direction === 1 ? 1 : null;
if (direction === null) return finish(this.error(input, "motion_jog", "motion_jog_range"), "failed");
// The Web jog buttons are world-axis controls. Mirror AXIS's
// teleop selection for this adapter before submitting the TP jog.
this.xyzacMotionMode = "TELEOP";
this.wasmMotion.setPaused(false);
const requestedIncrement = input.payload?.increment;
const continuous = requestedIncrement === null || input.payload?.continuous === true;
const displayScale = ["x", "y", "z"].includes(axis) && input.payload?.units === "inch" ? 25.4 : 1;
const distance = continuous ? 0 : Number(requestedIncrement ?? 1) * displayScale;
if (!continuous && (!Number.isFinite(distance) || distance <= 0)) return finish(this.error(input, "motion_jog", "motion_jog_nonfinite"), "failed");
const jog = this.wasmMotion.jog({ x: 0, y: 1, z: 2, a: 3, c: 5 }[axis], direction, distance, input.seq);
// The selected XYZAC case uses the LinuxCNC TP jog ABI. Keep the
// existing queue-backed WASM cases working as well: they have no
// native jog entry point, so represent one incremental/continuous
// sample as a normal queued line and retain the same JOG_STOP abort
// path. This compatibility branch is never used by XYZAC TRT.
const jog = typeof this.wasmMotion.jog === "function"
? this.wasmMotion.jog({ x: 0, y: 1, z: 2, a: 3, c: 5 }[axis], direction, distance, input.seq)
: (() => {
const current = this.wasmMotion.read();
const start = current.state?.position ?? {};
const end = { ...start, [axis]: Number(start[axis] ?? 0) + direction * (continuous ? Math.max(Number(input.payload?.speed ?? 1) * this.xyzacMotionCycleTime * 5, Number.EPSILON) : distance) };
return this.wasmMotion.enqueue({ start, end, velocity: Math.max(Number(input.payload?.speed ?? 1), Number.EPSILON), acceleration: 1, motion_type: 1, source_line: input.seq });
})();
if (jog.status !== "verified") {
const code = jog.code === "linuxcnc_tp_jog_-1217" ? "motion_limit_range" : jog.code === "linuxcnc_tp_jog_-1215" ? "motion_jog_nonfinite" : jog.code;
return finish(this.error(input, "motion_queue", code), "failed");

View File

@@ -29,7 +29,9 @@ assert.equal(panel().ui_gates.run.enabled, true);
assert.equal(panel().ui_gates.step.enabled, true);
assert.equal(send("PROGRAM_RUN").payload.gate_reason, "xyzac_not_all_homed");
ok("HOME", { all: true });
assert.equal(send("HOME", { axis: "x" }).payload.message_key, "home_joint_requires_joint_mode");
const homeAxis = send("HOME", { axis: "x", joint: 0 });
assert.equal(homeAxis.payload.status, "verified");
assert.deepEqual(homeAxis.payload.homing, { phase: "complete", target: "joint", source_axis: "x", joint: 0, motion_mode_before: "TELEOP", motion_mode_during: "FREE", motion_mode_after: "TELEOP" });
ok("PROGRAM_STEP");
assert.equal(panel().program.current_line, 0, "first native Step starts at line zero and pauses");

View File

@@ -0,0 +1,74 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
import { XyzacSession, CONTEXT_ID } from "./xyzac-session.mjs";
const CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini";
const fetchImpl = async (url) => {
try {
const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url));
return { ok: true, text: async () => body.toString("utf8") };
} catch {
return { ok: false, text: async () => "" };
}
};
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
assert.equal(bundle.status, "ready");
const wasmBinary = await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url));
const core = await createModule({ wasmBinary });
function open(sessionId) {
const session = new XyzacSession(core, { sessionId });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: sessionId };
let seq = 0;
const send = (kind, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: `${sessionId}-${seq}`, payload });
assert.equal(send("HELLO").kind, "CAPABILITIES");
assert.equal(send("SESSION_ATTACH").kind, "SESSION_READY");
assert.equal(send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }).payload.status, "verified");
assert.equal(send("POWER", { active: true }).payload.status, "verified");
return { session, send };
}
const partial = open("xyzac-home-axis-source");
for (const [axis, joint] of [["x", 0], ["y", 1], ["z", 2], ["a", 3], ["c", 4]]) {
const response = partial.send("HOME", { axis, joint });
assert.equal(response.kind, "COMMAND_RESULT", `${axis}: ${JSON.stringify(response.payload)}`);
assert.deepEqual(response.payload.homing, {
phase: "complete", target: "joint", source_axis: axis, joint,
motion_mode_before: "TELEOP", motion_mode_during: "FREE", motion_mode_after: "TELEOP",
});
assert.equal(response.payload.wasm_home_limit.state.homed_mask, (1 << (joint + 1)) - 1);
}
assert.equal(partial.send("HOME", { axis: "x", joint: 1 }).payload.message_key, "home_axis_joint_mismatch");
assert.equal(partial.session.wasmHomeLimit.read().state.homed_mask, 31);
const all = open("xyzac-home-all-source");
assert.equal(all.send("HOME", { all: true }).payload.wasm_home_limit.state.homed, true);
const move = all.send("MDI_EXECUTE", { block: "G21 G90 G0 X10 Y5 Z20 A30 C45" });
assert.equal(move.kind, "COMMAND_RESULT");
for (let tick = 0; tick < 20000 && all.session.wasmMotion.read().state.queue_depth > 0; tick += 1) all.send("SIM_STEP", { cycles: 1 });
assert.ok(Math.abs(all.session.wasmMotion.read().state.position.x - 10) < 1e-8);
const rehome = all.send("HOME", { all: true });
assert.equal(rehome.kind, "COMMAND_RESULT");
assert.equal(rehome.payload.homing.phase, "moving");
assert.notDeepEqual(rehome.payload.panel_snapshot.visual.pose, { x: 0, y: 0, z: 0, a: 0, b: 0, c: 0, u: 0, v: 0, w: 0 });
for (let tick = 0; tick < 2000 && all.session.wasmMotion.read().state.queue_depth > 0; tick += 1) all.send("SIM_STEP", { cycles: 1 });
const completed = all.send("SNAPSHOT_REQUEST");
assert.deepEqual(completed.payload.panel_snapshot.visual.pose, { x: 0, y: 0, z: 0, a: 0, b: 0, c: 0, u: 0, v: 0, w: 0 });
assert.deepEqual(completed.payload.wasm_home_limit.state.joints, [0, 0, 0, 0, 0]);
assert.ok(completed.payload.wasm_home_limit.state.home_sequence > rehome.payload.wasm_home_limit.state.home_sequence);
const running = open("xyzac-home-gate-source");
assert.equal(running.send("PROGRAM_LOAD", { source: "G21 G90\nG1 X10 F60\nM2\n", path: "home-gate.ngc" }).kind, "COMMAND_RESULT");
assert.equal(running.send("HOME", { all: true }).kind, "COMMAND_RESULT");
assert.equal(running.send("PROGRAM_RUN").kind, "COMMAND_RESULT");
assert.equal(running.send("HOME", { axis: "x", joint: 0 }).payload.gate_reason, "xyzac_interp_running");
console.log(JSON.stringify({
schema: "xyzac-home-source-flow.v1",
status: "verified",
source: ["axis.py:home_all_joints", "axis.py:home_joint", "axis.py:go_home", "axis.tcl:update_state"],
checks: ["axis_to_joint_map", "free_joint_transaction", "pose_sync", "invalid_pair_rejection", "running_gate"],
}));

View File

@@ -108,6 +108,17 @@ export class WasmHomeLimit {
return this.read();
}
// Resolve the canonical target of a home command without committing the
// homed mask or pose. The actual commit is performed only after the LinuxCNC
// TP has executed the queued move to this target.
previewHome(axis = null) {
const saved = this.exportState();
const target = this.home(0, axis);
try { this.restoreState(saved); } catch (error) { return { status: "error", code: String(error?.message ?? error) }; }
if (target.status !== "verified") return target;
return { status: "verified", state: target.state };
}
check(joints) {
if (!this.available) return this.read();
if (!Array.isArray(joints) || joints.length < this.jointCount || joints.some((value) => typeof value !== "number" || !Number.isFinite(value))) return { status: "error", code: "wasm_home_joints_invalid" };