Fix web homing motion and synchronize preview state
This commit is contained in:
@@ -13,6 +13,7 @@ import { XYZAC_CASE, projectXyzacPanel, type XyzacOperationTrace } from "./xyzac
|
||||
export const SELECTED_XYZAC_CASE = XYZAC_CASE;
|
||||
const DEFAULT_CASE = SELECTED_XYZAC_CASE;
|
||||
const PROGRAM_PATH = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins.ngc";
|
||||
const XYZAC_AXIS_TO_JOINT = Object.freeze({ x: 0, y: 1, z: 2, a: 3, c: 4 } as const);
|
||||
const normalizeProgramSource = (source: string) => source.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
// Button-to-command sequencing is defined in
|
||||
// docs/linuxcnc-axis-web-control-panel-source-logic.md; React only projects
|
||||
@@ -239,8 +240,10 @@ export function App() {
|
||||
const aboutDialog = useRef<HTMLDivElement>(null);
|
||||
const [layers, setLayers] = useState({ grid: true, tool: true, path: true, live: true, limits: false, velocity: false, offsets: false, extents: false });
|
||||
const runTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const homeTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const jogTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const runStepPending = useRef(false);
|
||||
const homeStepPending = useRef(false);
|
||||
const jogStepPending = useRef(false);
|
||||
const continuousJog = useRef<{ axis: string; direction: number } | null>(null);
|
||||
const programFileInput = useRef<HTMLInputElement>(null);
|
||||
@@ -315,7 +318,7 @@ export function App() {
|
||||
setCommandStatus("PROGRAM_LOAD acknowledged");
|
||||
setCaseStatus(`${DEFAULT_CASE} + ${PROGRAM_PATH} loaded`);
|
||||
}).catch((error: Error) => { setOperationTrace(operationController.exportTrace()); setCommandStatus(`Error: ${error.message}`); setCaseStatus(`blocked: ${error.message}`); }).finally(() => setProgramLoading(false));
|
||||
return () => { unsubscribe(); if (runTimer.current) clearInterval(runTimer.current); if (jogTimer.current) clearInterval(jogTimer.current); if (previewProgressTimer.current) clearInterval(previewProgressTimer.current); if (feedOverrideTimer.current) clearTimeout(feedOverrideTimer.current); if (uiCacheSaveTimer.current) clearTimeout(uiCacheSaveTimer.current); client.close(); };
|
||||
return () => { unsubscribe(); if (runTimer.current) clearInterval(runTimer.current); if (homeTimer.current) clearInterval(homeTimer.current); if (jogTimer.current) clearInterval(jogTimer.current); if (previewProgressTimer.current) clearInterval(previewProgressTimer.current); if (feedOverrideTimer.current) clearTimeout(feedOverrideTimer.current); if (uiCacheSaveTimer.current) clearTimeout(uiCacheSaveTimer.current); client.close(); };
|
||||
}, [client, operationController]);
|
||||
const axes = state.snapshot?.motion?.axes ?? [];
|
||||
const taskRecord = state.task ?? {};
|
||||
@@ -348,6 +351,17 @@ export function App() {
|
||||
const relief = state.snapshot?.ui_relief ?? {};
|
||||
const canLoad = !programLoading && gates.load.enabled;
|
||||
const stopRunTimer = () => { if (runTimer.current) { clearInterval(runTimer.current); runTimer.current = null; } };
|
||||
const stopHomeTimer = () => { if (homeTimer.current) { clearInterval(homeTimer.current); homeTimer.current = null; } };
|
||||
const startHomeTimer = () => {
|
||||
if (homeTimer.current) return;
|
||||
homeTimer.current = setInterval(() => {
|
||||
if (homeStepPending.current) return;
|
||||
homeStepPending.current = true;
|
||||
void operationController.execute("SIM_STEP", { cycles: 5, response: "motion_delta" }, { state: axisState, gate: false })
|
||||
.catch(() => stopHomeTimer())
|
||||
.finally(() => { homeStepPending.current = false; });
|
||||
}, 50);
|
||||
};
|
||||
const stopJogTimer = () => { continuousJog.current = null; if (jogTimer.current) { clearInterval(jogTimer.current); jogTimer.current = null; } };
|
||||
const startJogTimer = (axis: string, direction: number) => {
|
||||
continuousJog.current = { axis, direction };
|
||||
@@ -373,7 +387,7 @@ export function App() {
|
||||
}
|
||||
const pending = operationController.execute(kind, payload, { state: axisState, gate, ...(action ? { action } : {}) });
|
||||
setOperationTrace(operationController.exportTrace());
|
||||
return pending.then(() => { setOperationTrace(operationController.exportTrace()); setCommandStatus(`${kind} acknowledged`); if (kind === "PROGRAM_RUN" || kind === "PROGRAM_RESUME") startRunTimer(); if (["PROGRAM_PAUSE", "PROGRAM_ABORT", "PROGRAM_RESTART"].includes(kind)) stopRunTimer(); if (kind === "JOG_START") startJogTimer(String(payload.axis ?? "x"), Number(payload.direction)); if (kind === "JOG_STOP") stopJogTimer(); }).catch((error: Error) => {
|
||||
return pending.then((operation) => { setOperationTrace(operationController.exportTrace()); setCommandStatus(`${kind} acknowledged`); if (kind === "PROGRAM_RUN" || kind === "PROGRAM_RESUME") startRunTimer(); if (["PROGRAM_PAUSE", "PROGRAM_ABORT", "PROGRAM_RESTART"].includes(kind)) stopRunTimer(); if (kind === "HOME" && (operation.response as { payload?: { homing?: { phase?: string } } })?.payload?.homing?.phase === "moving") startHomeTimer(); if (kind === "JOG_START") startJogTimer(String(payload.axis ?? "x"), Number(payload.direction)); if (kind === "JOG_STOP") stopJogTimer(); }).catch((error: Error) => {
|
||||
setOperationTrace(operationController.exportTrace());
|
||||
const code = error.message;
|
||||
setCommandStatus(`${kind} blocked: ${code}`);
|
||||
@@ -396,7 +410,13 @@ export function App() {
|
||||
const homeAxis = () => {
|
||||
if (programLoading || !gates.home.enabled) return;
|
||||
// switchkins.c reports KINEMATICS_BOTH even when its active type is 0.
|
||||
void dispatch("HOME", { axis: selectedAxis }).catch(() => undefined);
|
||||
// AXIS therefore homes a numeric joint; this fixed XYZAC Web surface
|
||||
// maps its visible one-to-one axis selector to that joint explicitly.
|
||||
const joint = XYZAC_AXIS_TO_JOINT[selectedAxis as keyof typeof XYZAC_AXIS_TO_JOINT];
|
||||
if (joint === undefined) { setCommandStatus("HOME blocked: home_axis_invalid"); return; }
|
||||
const confirm = globalThis.confirm;
|
||||
if (Boolean(homedAxes[joint]) && typeof confirm === "function" && !confirm(`Joint ${joint} is already homed. Re-home it?`)) return;
|
||||
void dispatch("HOME", { axis: selectedAxis, joint }).catch(() => undefined);
|
||||
};
|
||||
const stopPreviewProgress = () => {
|
||||
if (previewProgressTimer.current) {
|
||||
@@ -625,7 +645,8 @@ export function App() {
|
||||
const mdiMotionActive = !programRunning && String(state.task?.mode ?? "").toUpperCase() === "MDI" && wasmQueueDepth > 0;
|
||||
if ((programRunning && !programComplete) || mdiMotionActive || Boolean(state.snapshot?.motion?.stepping)) startRunTimer(); else stopRunTimer();
|
||||
if (wasmQueueDepth === 0 && !jogStepPending.current) stopJogTimer();
|
||||
if (!ready) { stopRunTimer(); stopJogTimer(); }
|
||||
if (wasmQueueDepth === 0 && !homeStepPending.current) stopHomeTimer();
|
||||
if (!ready) { stopRunTimer(); stopHomeTimer(); stopJogTimer(); }
|
||||
}, [programRunning, ready, state.program?.program_status, state.task?.mode, state.snapshot?.wasm_motion]);
|
||||
const spindleState = (state.devices as Record<string, any> | null)?.spindles?.[0] as Record<string, any> | undefined;
|
||||
const spindleDirection = Number(spindleState?.direction ?? 0);
|
||||
|
||||
@@ -119,6 +119,7 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
|
||||
let historyVertexCount = 0;
|
||||
let activeLiveLine: THREE.Line | null = null;
|
||||
let livePathEpoch = -1;
|
||||
let poseEpoch = -1;
|
||||
let lastHistoryTick = -1;
|
||||
let lastActiveKey = "";
|
||||
let lastPreset = "";
|
||||
@@ -165,6 +166,29 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
|
||||
const segments = state.toolpath as Segment[];
|
||||
const layer = layersRef.current;
|
||||
const now = performance.now();
|
||||
// A HOME transaction can reset the canonical pose without advancing the
|
||||
// simulation tick. Treat the source home sequence as an explicit pose
|
||||
// barrier so stale queued jog samples cannot keep the old value on the
|
||||
// HUD or tool marker. Keep the Live Plot history intact.
|
||||
if (state.poseEpoch !== poseEpoch) {
|
||||
poseEpoch = state.poseEpoch;
|
||||
playbackQueue = [];
|
||||
playbackStart = 0;
|
||||
queuedLastTick = state.tick;
|
||||
renderedTick = state.tick;
|
||||
renderedPose = state.livePose;
|
||||
renderedJoints = state.joints;
|
||||
renderedActiveSegment = state.activeSegmentId ?? 0;
|
||||
renderedProgress = state.activeProgress;
|
||||
paintedTick = renderedTick;
|
||||
paintedPose = renderedPose;
|
||||
paintedJoints = renderedJoints;
|
||||
paintedActiveSegment = renderedActiveSegment;
|
||||
paintedProgress = renderedProgress;
|
||||
liveTick = renderedTick;
|
||||
if (activeLiveLine) { activeLiveLine.geometry.dispose(); liveGroup.remove(activeLiveLine); activeLiveLine = null; }
|
||||
lastActiveKey = "";
|
||||
}
|
||||
if (state.livePathEpoch !== livePathEpoch) {
|
||||
livePathEpoch = state.livePathEpoch;
|
||||
clearLines(liveGroup);
|
||||
|
||||
@@ -138,6 +138,7 @@ export type PanelSnapshotV1 = {
|
||||
schema: "axis-visual.v1";
|
||||
pose: Record<string, number>;
|
||||
joints: number[];
|
||||
pose_epoch?: number;
|
||||
live_path_epoch?: number;
|
||||
toolpath?: Array<Record<string, unknown>>;
|
||||
active_segment_id?: number | null;
|
||||
|
||||
@@ -9,6 +9,7 @@ export type XyzacVisualState = {
|
||||
joints: [number, number, number, number, number];
|
||||
livePose: XyzacKinematicsSnapshot["pose"];
|
||||
activeProgress: number;
|
||||
poseEpoch: number;
|
||||
livePathEpoch: number;
|
||||
motionTrace: Array<{ tick: number; pose: XyzacKinematicsSnapshot["pose"]; joints: number[]; activeSegmentId: number; activeProgress: number }>;
|
||||
offsets: XyzacKinematicsSnapshot["offsets"];
|
||||
@@ -28,7 +29,7 @@ export type XyzacVisualSource = {
|
||||
state_hash?: string;
|
||||
kinematics?: XyzacKinematicsSnapshot;
|
||||
motion?: { velocity_mm_min?: number };
|
||||
visual?: { active_segment_id?: number | null; active_progress?: number; live_path_epoch?: number; motion_trace?: Array<{ tick: number; pose: Record<string, number>; joints: number[]; active_segment_id: number; active_progress: number }>; pose?: Record<string, number>; joints?: number[]; toolpath?: Array<Record<string, unknown>> };
|
||||
visual?: { active_segment_id?: number | null; active_progress?: number; pose_epoch?: number; live_path_epoch?: number; motion_trace?: Array<{ tick: number; pose: Record<string, number>; joints: number[]; active_segment_id: number; active_progress: number }>; pose?: Record<string, number>; joints?: number[]; toolpath?: Array<Record<string, unknown>> };
|
||||
};
|
||||
|
||||
export function projectXyzacVisual(snapshot: XyzacVisualSource | null | undefined): XyzacVisualState {
|
||||
@@ -53,6 +54,7 @@ export function projectXyzacVisual(snapshot: XyzacVisualSource | null | undefine
|
||||
joints,
|
||||
livePose: pose,
|
||||
activeProgress,
|
||||
poseEpoch: Number.isSafeInteger(snapshot?.visual?.pose_epoch) ? Number(snapshot?.visual?.pose_epoch) : 0,
|
||||
livePathEpoch: Number.isSafeInteger(snapshot?.visual?.live_path_epoch) ? Number(snapshot?.visual?.live_path_epoch) : 0,
|
||||
motionTrace,
|
||||
offsets: kinematics?.offsets ?? ZERO_OFFSETS,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
74
web/worker/test_xyzac_home_source_flow.mjs
Normal file
74
web/worker/test_xyzac_home_source_flow.mjs
Normal 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"],
|
||||
}));
|
||||
@@ -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" };
|
||||
|
||||
Reference in New Issue
Block a user