Align AXIS control flow and fix pause and program rendering
Document source UI gates, explicit native live-plot clearing, and remaining parity boundaries. Fix pause/resume transport continuity, repeated stepping, coordinate previews, and spindle override state. Optimize large program snapshots and rendering, and cover the control flows with native Tcl, WASM, and browser checks. Validation: 22 native tests and source/browser checks pass. Worker/App regression: 113 of 123 pass; 10 pre-existing failures remain documented with no new failures.
This commit is contained in:
22
web/worker/axis-source-ui.mjs
Normal file
22
web/worker/axis-source-ui.mjs
Normal file
@@ -0,0 +1,22 @@
|
||||
// UI predicates from axis.tcl::update_state, independent of task acceptance.
|
||||
export function axisSourceUi({ task_state, interp_state, taskfile = false, queued_mdi_commands = 0, max_queued_mdi_commands = 10, motion_mode = "FREE", kinematics_type = "BOTH", tool = 0, spindle_direction = 0, on_any_limit = false, paused = false } = {}) {
|
||||
const on = task_state === "STATE_ON";
|
||||
const idle = interp_state === "INTERP_IDLE";
|
||||
const manual = on && idle;
|
||||
const touch = manual && (motion_mode !== "FREE" || kinematics_type === "IDENTITY");
|
||||
const predicates = {
|
||||
estop: true, power: task_state !== "STATE_ESTOP",
|
||||
load: idle, reload: idle && Boolean(taskfile), run: manual, step: on && Boolean(taskfile),
|
||||
pause: on && ["INTERP_READING", "INTERP_WAITING"].includes(interp_state),
|
||||
resume: on && interp_state === "INTERP_PAUSED", pauseResume: on && !idle,
|
||||
abort: on && !idle, manual, home: manual, jog: manual, spindle: manual, coolant: manual,
|
||||
mdi: on && queued_mdi_commands < max_queued_mdi_commands,
|
||||
touchOff: touch, toolTouchOff: touch && tool > 0,
|
||||
spindleAdjust: manual && spindle_direction !== 0, overrideLimits: Boolean(on_any_limit),
|
||||
override: true, jogScale: true, maxVelocity: true, spindleOverride: true,
|
||||
};
|
||||
return {
|
||||
gates: Object.fromEntries(Object.entries(predicates).map(([action, enabled]) => [action, { action, enabled, reason: enabled ? "axis_source_ui_available" : `axis_source_ui_${action}_disabled` }])),
|
||||
relief: { estop: task_state === "STATE_ESTOP", power: on, run: !idle, pauseResume: Boolean(paused), abort: idle },
|
||||
};
|
||||
}
|
||||
@@ -37,14 +37,13 @@ export function axisState({ task = {}, program = {}, motion = {}, devices = {},
|
||||
const hasIoState = typeof ioState.estop === "boolean" || typeof ioState.estop === "number";
|
||||
const ioEstop = hasIoState && Boolean(ioState.estop);
|
||||
const ioReady = typeof ioState.ready === "boolean" || typeof ioState.ready === "number" ? Boolean(ioState.ready) : null;
|
||||
// A powered-off machine is OFF. ESTOP_RESET is distinct and is only
|
||||
// reported after the WASM IO latch has recorded the ESTOP edge.
|
||||
const estopReset = hasIoState && !ioEstop && ioReady === false && Number(ioState.last_error) === -904;
|
||||
let machineState = ioEstop || ["ESTOP", "FAULT"].includes(taskState) ? "ESTOP" : estopReset ? "ESTOP_RESET" : ioReady === false ? "OFF" : ["READY", "RUNNING", "PAUSED", "STOPPED"].includes(taskState) ? "ON" : "OFF";
|
||||
// axis.tcl displays STATE_ESTOP_RESET as OFF; diagnostics are not state bits.
|
||||
const machineState = hasIoState ? ioEstop ? "ESTOP" : ioReady ? "ON" : "OFF"
|
||||
: taskState === "ESTOP" ? "ESTOP" : ["READY", "RUNNING", "PAUSED", "STOPPED"].includes(taskState) ? "ON" : "OFF";
|
||||
let operationState = programActivity(task, program);
|
||||
const state = machineState === "ESTOP" || machineState === "ESTOP_RESET" ? machineState : operationState === "HOME" ? "HOME" : operationState === "RUNNING" ? "RUNNING" : operationState === "PAUSED" ? "PAUSED" : machineState === "OFF" ? "OFF" : "IDLE";
|
||||
const sourceTaskState = machineState === "ESTOP" ? "STATE_ESTOP" : machineState === "ESTOP_RESET" ? "STATE_ESTOP_RESET" : machineState === "OFF" ? "STATE_OFF" : "STATE_ON";
|
||||
const sourceInterpState = operationState === "PAUSED" ? "INTERP_PAUSED" : operationState === "RUNNING" ? "INTERP_READING" : "INTERP_IDLE";
|
||||
const sourceTaskState = machineState === "ESTOP" ? "STATE_ESTOP" : machineState === "OFF" ? "STATE_ESTOP_RESET" : "STATE_ON";
|
||||
const sourceInterpState = operationState === "PAUSED" ? "INTERP_PAUSED" : operationState === "RUNNING" ? (program?.program_status === "completed" || String(task.mode).toUpperCase() === "MDI" ? "INTERP_WAITING" : "INTERP_READING") : "INTERP_IDLE";
|
||||
const sourceMode = String(task.mode ?? "MANUAL").toUpperCase();
|
||||
return Object.freeze({ state, machine_state: machineState, operation_state: operationState, source_task_state: sourceTaskState, source_interp_state: sourceInterpState, source_mode: sourceMode, states: [...AXIS_STATES] });
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { createTrace, validateTrace } from "./trace-export.mjs";
|
||||
import { ConfigurationBatchRuntime } from "../workspace/configuration-batch.mjs";
|
||||
import { AuthoritativeRuntime } from "./authoritative-runtime.mjs";
|
||||
import { axisState } from "./axis-state.mjs";
|
||||
import { axisSourceUi } from "./axis-source-ui.mjs";
|
||||
import { WasmMotionQueue } from "./wasm-motion-queue.mjs";
|
||||
import { WasmLinuxcncTp } from "./wasm-linuxcnc-tp.mjs";
|
||||
import { WasmHomeLimit } from "./wasm-home-limit.mjs";
|
||||
@@ -301,6 +302,7 @@ export class SimSession {
|
||||
on_any_limit: Boolean(home.state?.limit_error),
|
||||
following_error: 0,
|
||||
jog: null,
|
||||
stepping: this.wasmMotion.isStepping?.() ?? false,
|
||||
estop: Boolean(ioState?.estop),
|
||||
fault: ioState?.fault ? (ioState.fault_reason ?? "io_fault") : null,
|
||||
};
|
||||
@@ -369,6 +371,11 @@ export class SimSession {
|
||||
return { status: "verified" };
|
||||
}
|
||||
|
||||
clearLivePlot() {
|
||||
this.livePathEpoch += 1;
|
||||
return this.livePathEpoch;
|
||||
}
|
||||
|
||||
panelSnapshot({ task = this.task.export(), program = this.xyzacProgram ? this.xyzacProgram.snapshot() : this.program.loaded ? this.program.snapshot() : null, input = {}, tick = this.tick, motionTrace = null, includeToolpath = input.kind !== "SIM_STEP" || !this.xyzacProgram, includeConfiguration = input.kind !== "SIM_STEP" || !this.xyzacProgram, includeStableRuntime = input.kind !== "SIM_STEP" || !this.xyzacProgram } = {}) {
|
||||
const motion = this.xyzacProgram ? this.wasmMotionProjection(program) : this.motion.export();
|
||||
const wasmMotion = this.wasmMotion.read();
|
||||
@@ -384,9 +391,15 @@ export class SimSession {
|
||||
const xyzac = this.xyzacKinematics ? { coordinates: "XYZAC", joint_count: 5, hal_pin_vars: true, switchkins_net: true, finite_offsets: true, userk_capability: true, active_type: this.xyzacKinematics.snapshot().active_type } : null;
|
||||
const gateInput = { task, program: program ?? {}, motion, devices, io: this.xyzacIo ? this.xyzacIo.export() : {}, device_io: deviceIo, xyzac, taskfile: Boolean(this.xyzacProgram ? this.xyzacProgram.snapshot().status !== 0 : this.program.loaded), kinematicsIdentity: this.motionAuthority === "wasm" };
|
||||
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",
|
||||
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
|
||||
? {
|
||||
load: this.xyzacCommandGate({ kind: "PROGRAM_LOAD", payload: {} }),
|
||||
reload: this.xyzacProgram.path ? this.xyzacCommandGate({ kind: "PROGRAM_LOAD", payload: {} }) : { enabled: false, reason: "axis_taskfile_missing" },
|
||||
run: this.xyzacCommandGate({ kind: "PROGRAM_RUN", payload: {} }),
|
||||
runLine: this.xyzacCommandGate({ kind: "PROGRAM_RUN", payload: {} }),
|
||||
step: this.xyzacCommandGate({ kind: "PROGRAM_STEP", payload: {} }),
|
||||
@@ -442,8 +455,14 @@ export class SimSession {
|
||||
active_progress: wasmMotion.status === "verified" ? wasmMotion.state.progress : 0,
|
||||
...(Array.isArray(motionTrace) && motionTrace.length ? { motion_trace: motionTrace } : {}),
|
||||
source: "wasm_canonical_motion",
|
||||
velocity_mm_min: motion.velocity_mm_min ?? 0,
|
||||
machine_limits: this.wasmMotion.axisLimits?.() ?? null,
|
||||
coordinate_offset: program?.coordinate_context?.offsets?.[(program?.coordinate_context?.system ?? 1) - 1]?.slice(0, 3) ?? null,
|
||||
};
|
||||
const axisCoordinates = [position.x, position.y, position.z, position.a ?? 0, position.c ?? 0];
|
||||
const coordinateContext = program?.coordinate_context;
|
||||
const activeOffsets = coordinateContext?.offsets?.[coordinateContext.system - 1] ?? Array(9).fill(0);
|
||||
const displayedOffsets = [0, 1, 2, 3, 5].map((index) => activeOffsets[index]);
|
||||
const unsigned = {
|
||||
schema: "axis-panel-snapshot.v1",
|
||||
context_id: this.contextId,
|
||||
@@ -452,13 +471,14 @@ export class SimSession {
|
||||
task,
|
||||
program,
|
||||
motion,
|
||||
coordinates: { machine: [...axisCoordinates], relative: [...axisCoordinates], commanded: [...axisCoordinates] },
|
||||
coordinates: { machine: [...axisCoordinates], relative: axisCoordinates.map((value, index) => value - displayedOffsets[index]), commanded: [...axisCoordinates], offsets: displayedOffsets, system: coordinateContext?.system ?? 1 },
|
||||
overrides: { feed: motion.feed_override, spindle: [motion.spindle_override] },
|
||||
wasm_motion: wasmMotion,
|
||||
...(includeStableRuntime ? { home_limit: homeLimit, hal: { configuration: this.halState, runtime: halRuntime }, devices, device_io: deviceIo } : {}),
|
||||
...(includeConfiguration ? { configuration: this.configurationState } : {}),
|
||||
axis_state: axis,
|
||||
gates,
|
||||
...(this.xyzacProgram ? { ui_gates: { ...ui.gates, toolTouchOff: gates.toolTouchOff }, ui_relief: ui.relief } : {}),
|
||||
visual,
|
||||
authoritative,
|
||||
motion_authority: this.motionAuthority,
|
||||
@@ -536,8 +556,8 @@ export class SimSession {
|
||||
const loaded = program.status !== 0;
|
||||
const action = PANEL_GATE_ACTIONS[input.kind] ?? null;
|
||||
if (input.kind === "TOOL_TOUCH_OFF") return { action: "toolTouchOff", enabled: false, reason: "blocked_by_abi_tool_offsets", taskState: task.state, interpState: "IDLE", wasm: { io_ready: ready, io_estop: estop, program_status: program.program_status } };
|
||||
const command = input.kind === "JOG_STOP" || input.kind === "JOG_START" || input.kind === "SET_JOG_SCALE" ? WASM_GATE_COMMANDS.JOG
|
||||
: input.kind === "SET_MAX_VELOCITY" || input.kind === "SET_SPINDLE_OVERRIDE" ? WASM_GATE_COMMANDS.OVERRIDE
|
||||
const command = input.kind === "JOG_STOP" || input.kind === "JOG_START" ? WASM_GATE_COMMANDS.JOG
|
||||
: input.kind === "SET_JOG_SCALE" || input.kind === "SET_MAX_VELOCITY" || input.kind === "SET_SPINDLE_OVERRIDE" ? WASM_GATE_COMMANDS.OVERRIDE
|
||||
: input.kind === "SET_OVERRIDE" ? WASM_GATE_COMMANDS.OVERRIDE
|
||||
: input.kind === "SPINDLE_COMMAND" || input.kind === "SPINDLE_CONSTANT" || input.kind === "SPINDLE_BRAKE" ? WASM_GATE_COMMANDS.SPINDLE
|
||||
: input.kind === "COOLANT_COMMAND" ? WASM_GATE_COMMANDS.COOLANT
|
||||
@@ -607,15 +627,16 @@ export class SimSession {
|
||||
const value = Number(payload.value);
|
||||
if (!Number.isFinite(value)) return { status: "error", code: "touch_off_value_invalid" };
|
||||
const selectedSystem = String(payload.system ?? "P1 G54").trim().toUpperCase();
|
||||
const systemMatch = selectedSystem.match(/^(P[0-9]|G5[4-9](?:\.[123])?)(?:\s+.*)?$/);
|
||||
const systemMatch = selectedSystem.match(/^(P[0-9]|G(?:5[4-8]|59(?:\.[123])?))(?:\s+.*)?$/);
|
||||
if (!systemMatch) return { status: "error", code: "touch_off_system_invalid" };
|
||||
const systemToken = systemMatch[1];
|
||||
const systemNumber = systemToken.startsWith("P") ? Number(systemToken.slice(1)) : 1 + (Number(systemToken.slice(1, 3)) - 54) + (systemToken.includes(".") ? Number(systemToken.slice(-1)) : 0);
|
||||
if (!Number.isInteger(systemNumber) || systemNumber < 0 || systemNumber > 9) return { status: "error", code: "touch_off_system_invalid" };
|
||||
const displayUnits = String(payload.units ?? "").toLowerCase();
|
||||
const programUnits = Number(this.xyzacProgram.snapshot().units);
|
||||
const converted = displayUnits === "mm" && programUnits === 20 ? value / 25.4
|
||||
: displayUnits === "inch" && programUnits === 21 ? value * 25.4 : value;
|
||||
const linear = ["X", "Y", "Z"].includes(axis);
|
||||
const converted = linear && displayUnits === "mm" && programUnits === 20 ? value / 25.4
|
||||
: linear && displayUnits === "inch" && programUnits === 21 ? value * 25.4 : value;
|
||||
const formatted = Number(converted.toFixed(12));
|
||||
const sourceBlock = `G10 L20 ${systemToken} ${axis}${formatted}`;
|
||||
// The LinuxCNC interpreter accepts the G54..G59 word in this command;
|
||||
@@ -626,14 +647,19 @@ export class SimSession {
|
||||
try {
|
||||
if (savedMode !== "MDI") this.task.setMode("MDI");
|
||||
this.wasmMotion.setPaused(false);
|
||||
const synchronized = this.xyzacProgram.synchronizePosition(this.wasmMotion.read().state.position);
|
||||
if (synchronized.status !== "verified") return synchronized;
|
||||
const offset = this.xyzacProgram.executeBlock(block);
|
||||
if (offset.status !== "verified") return { ...offset, touch_off: { axis, value, system: selectedSystem, source_system: systemToken, source_block: sourceBlock, block } };
|
||||
const preview = this.xyzacProgram.preview();
|
||||
this.previewState = preview.status === "verified" ? preview : null;
|
||||
if (preview.status !== "verified") return preview;
|
||||
this.task.setProgram("loaded", this.xyzacProgram.snapshot().current_line);
|
||||
if (savedMode !== "MDI") this.task.setMode(savedMode);
|
||||
return { status: "verified", command: "TOUCH_OFF", source_block: sourceBlock, block, touch_off: { axis, value, system: selectedSystem, source_system: systemToken, source_block: sourceBlock, block }, program: this.xyzacProgram.snapshot(), motion: offset.motion ?? null };
|
||||
} catch (error) {
|
||||
try { if (savedMode !== "MDI" && this.task.state.mode !== savedMode) this.task.setMode(savedMode); } catch { /* preserve the original interpreter error */ }
|
||||
return { status: "error", code: String(error?.message ?? error) };
|
||||
} finally {
|
||||
if (this.task.state.mode !== savedMode) this.task.setMode(savedMode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1317,6 +1343,10 @@ export class SimSession {
|
||||
};
|
||||
|
||||
const deviceResponse = (kind, payload) => {
|
||||
if (this.xyzacProgram && ["SPINDLE_COMMAND", "SPINDLE_BRAKE", "SPINDLE_CONSTANT"].includes(kind) && !payload?.source_keyboard && (kind !== "SPINDLE_COMMAND" || payload?.delta === undefined)) {
|
||||
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 result = this.xyzacIo ? this.xyzacIo.command(kind, payload ?? {}) : this.devices.command(kind, payload ?? {});
|
||||
if (result.status === "verified" && this.xyzacProgram && kind === "ESTOP" && payload?.active === true) {
|
||||
// LinuxCNC emcTaskSetState(ESTOP) calls emcTaskAbort: clear the
|
||||
@@ -1390,7 +1420,7 @@ export class SimSession {
|
||||
|
||||
const visualClearResponse = () => {
|
||||
const before = { task: this.task.hash(), motion: this.wasmMotion.exportState().state_hash, program: this.xyzacProgram?.snapshot().state_hash ?? null };
|
||||
this.livePathEpoch += 1;
|
||||
this.clearLivePlot();
|
||||
const after = { task: this.task.hash(), motion: this.wasmMotion.exportState().state_hash, program: this.xyzacProgram?.snapshot().state_hash ?? null };
|
||||
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, visual_clear: true, live_path_epoch: this.livePathEpoch, visual_only: true, before, after, operation_id: input.operation_id ?? null }), "verified");
|
||||
};
|
||||
@@ -1637,7 +1667,7 @@ export class SimSession {
|
||||
// worker. LinuxCNC keeps the paused task/TP state stable; accept that
|
||||
// transport frame as a no-op instead of rejecting it at the protocol
|
||||
// layer and leaving the next PROGRAM_RESUME with a sequence gap.
|
||||
if (this.xyzacProgram && (this.task.state.state === "PAUSED" || this.xyzacProgram.snapshot().status === 3)) {
|
||||
if (this.xyzacProgram && (this.task.state.state === "PAUSED" || this.xyzacProgram.snapshot().status === 3) && !this.wasmMotion.isStepping()) {
|
||||
const pausedProgram = this.xyzacProgram.snapshot();
|
||||
return finish(this.envelope("COMMAND_RESULT", input, {
|
||||
status: "verified",
|
||||
@@ -1661,7 +1691,7 @@ export class SimSession {
|
||||
task: (nextTick) => {
|
||||
if (this.xyzacProgram) {
|
||||
const wasmState = this.xyzacProgram.snapshot();
|
||||
if (![2, 3].includes(wasmState.status)) return;
|
||||
if (wasmState.status !== 2) return;
|
||||
if (this.pendingTaskSync !== 0) {
|
||||
this.pendingTaskSync = 0;
|
||||
return;
|
||||
@@ -1854,7 +1884,9 @@ export class SimSession {
|
||||
// cycle, so advance the WASM motion queue by the configured
|
||||
// servo/task ratio while publishing one stable task snapshot.
|
||||
let wasmMotion = this.wasmMotion.read();
|
||||
const programPaused = this.xyzacProgram?.snapshot().status === 3;
|
||||
for (let servo = 0; servo < this.xyzacTaskCycleTicks; servo += 1) {
|
||||
if (programPaused && !this.wasmMotion.isStepping()) break;
|
||||
if (this.planner === "linuxcnc-tp" && typeof this.wasmMotion.setExecutionState === "function") {
|
||||
this.wasmMotion.setExecutionState(this.xyzacExecutionWaitSamples > 0 ? 2 : 3);
|
||||
}
|
||||
@@ -1971,6 +2003,7 @@ export class SimSession {
|
||||
const status = this.xyzacProgram ? (program.status === 4 ? "completed" : program.status === 2 ? "running" : program.status === 3 ? "paused" : "loaded") : (program.program_status === "completed" ? "completed" : program.program_status === "running" ? "running" : program.program_status);
|
||||
this.task.setProgram(status, this.xyzacProgram ? (program.execution_source_line ?? program.current_line) : program.line);
|
||||
if (status === "completed" && this.task.state.state === "RUNNING" && (this.motionAuthority !== "wasm" || this.wasmMotion.read().state.queue_depth === 0)) this.task.transition("STOPPED");
|
||||
if (this.xyzacProgram && this.task.state.mode === "MDI" && this.task.state.state === "RUNNING" && this.wasmMotion.read().state.queue_depth === 0) this.task.transition("STOPPED");
|
||||
}
|
||||
const snapshotState = { tick: this.tick, cycles, motion_authority: this.motionAuthority, task: this.task.export(), scheduler: this.scheduler.export(), authoritative: this.xyzacProgram ? { schema: "wasm_authority_boundary.v1", source: "linuxcnc-wasm-worker" } : this.authoritative.export(), wasm_motion: this.wasmMotion.read(), motion: this.motionProjection(), plc: this.plc.export(), hal_runtime: this.xyzacIo ? this.xyzacIo.halExport() : this.hal.export(), device_io: this.xyzacDeviceIo ? this.xyzacDeviceIo.export() : this.xyzacIo ? this.xyzacIo.export() : this.deviceIo.export(), configuration_batch: this.configurationBatch.export(), io_ack: this.ioAck.export(), callbacks: this.callbacks.export(), xyzac_timing: { servo_cycle_time: this.xyzacMotionCycleTime, task_cycle_ticks: this.xyzacTaskCycleTicks } };
|
||||
if (program) snapshotState.program = program;
|
||||
@@ -2047,7 +2080,8 @@ export class SimSession {
|
||||
}
|
||||
restoreMode();
|
||||
const finalStatus = result.status === "verified" && preview.status === "verified" ? "verified" : "error";
|
||||
return finish(this.envelope(finalStatus === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, preview, program: this.xyzacProgram.snapshot(), task: this.task.export(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), finalStatus);
|
||||
return finish(this.envelope(finalStatus === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, preview, program: this.xyzacProgram.snapshot(), task: this.task.export(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null,
|
||||
}), finalStatus);
|
||||
}
|
||||
restoreMode();
|
||||
const result = this.program.load(source, input.payload?.path ?? null);
|
||||
@@ -2130,7 +2164,7 @@ export class SimSession {
|
||||
// false ERR_STATE. The LinuxCNC WASM TP remains authoritative for
|
||||
// the actual pause/resume motion transition.
|
||||
if (action === "pause") { this.wasmMotion.setPaused(true); this.task.transition("PAUSED"); const program = this.xyzacProgram.snapshot(); this.task.setProgram("paused", program.execution_source_line ?? program.current_line); }
|
||||
else if (action === "resume") { this.wasmMotion.setPaused(false); this.task.transition("RUNNING"); const program = this.xyzacProgram.snapshot(); this.task.setProgram("running", program.execution_source_line ?? program.current_line); }
|
||||
else if (action === "resume") { this.wasmMotion.setPaused(false); this.task.transition("RUNNING"); const program = this.xyzacProgram.snapshot(); this.task.setProgram(program.status === 4 ? "completed" : program.status === 1 ? "loaded" : "running", program.execution_source_line ?? program.current_line); }
|
||||
else { this.wasmMotion.abort(); this.task.transition("STOPPED"); const program = this.xyzacProgram.snapshot(); this.task.setProgram("aborted", program.execution_source_line ?? program.current_line); }
|
||||
}
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, program: this.xyzacProgram.snapshot(), task: this.task.export(), wasm_motion: this.wasmMotion.read(), motion: this.wasmMotionProjection(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
|
||||
@@ -2204,25 +2238,47 @@ export class SimSession {
|
||||
}
|
||||
case "PROGRAM_STEP": {
|
||||
if (this.xyzacProgram) {
|
||||
const current = this.xyzacProgram.snapshot();
|
||||
if (current.status === 1 && this.task.state.mode !== "AUTO") this.task.setMode("AUTO");
|
||||
if (current.status === 1) this.xyzacProgram.start();
|
||||
const result = this.xyzacProgram.step();
|
||||
if (result.status === "verified") {
|
||||
// AXIS AUTO_STEP returns to an interpreter-paused state after the
|
||||
// single canonical block. Keep the WASM interpreter status and
|
||||
// task status aligned so the Resume gate is authoritative.
|
||||
if (result.program_status !== 4) {
|
||||
const pauseResult = this.xyzacProgram.pause();
|
||||
if (pauseResult.status !== "verified") return finish(this.error(input, "program_pause", pauseResult.code ?? "program_pause_failed"), "failed");
|
||||
try {
|
||||
const current = this.xyzacProgram.snapshot();
|
||||
const idle = !["RUNNING", "PAUSED"].includes(this.task.state.state);
|
||||
this.task.setMode("AUTO");
|
||||
if (idle) {
|
||||
const started = this.xyzacProgram.start();
|
||||
if (started.status !== "verified") return finish(this.error(input, "program_step", started.code), "failed");
|
||||
} else if (current.status === 3 && this.wasmMotion.queueDepth() > 0) {
|
||||
this.wasmMotion.stepLine();
|
||||
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, program: current, task: this.task.export(), motion: this.wasmMotionProjection() }), "verified");
|
||||
} else {
|
||||
if (current.status === 3) this.xyzacProgram.resume();
|
||||
const stepped = this.xyzacProgram.step();
|
||||
if (stepped.status !== "verified") return finish(this.error(input, "program_step", stepped.code), "failed");
|
||||
const sync = this.syncXyzacProgramSpindle(stepped.program);
|
||||
if (sync.status !== "verified" && sync.status !== "skipped") return finish(this.error(input, "program_spindle", sync.code), "failed");
|
||||
if (stepped.has_control && [428, 429, 430].includes(stepped.control_code)) {
|
||||
const selected = this.switchXyzacKinematics({ 428: 1, 429: 0, 430: 2 }[stepped.control_code]);
|
||||
if (selected.status !== "verified") return finish(this.error(input, "program_step", selected.code), "failed");
|
||||
}
|
||||
if (stepped.motion) {
|
||||
stepped.motion.kinematics_mode = this.xyzacProgramKinematicsMode;
|
||||
const queued = this.wasmMotion.enqueue(stepped.motion);
|
||||
if (queued.status === "error") return finish(this.error(input, "motion_queue", queued.code), "failed");
|
||||
}
|
||||
}
|
||||
if (this.task.state.state === "READY") this.task.transition("RUNNING");
|
||||
if (result.motion) this.wasmMotion.enqueue(result.motion);
|
||||
if (this.xyzacProgram.snapshot().status === 4 && this.wasmMotion.read().state.queue_depth === 0) {
|
||||
this.task.setProgram("completed", this.xyzacProgram.snapshot().current_line);
|
||||
if (this.task.state.state !== "STOPPED") this.task.transition("STOPPED");
|
||||
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, program: this.xyzacProgram.snapshot(), task: this.task.export(), motion: this.wasmMotionProjection() }), "verified");
|
||||
}
|
||||
const paused = this.xyzacProgram.pause();
|
||||
if (paused.status !== "verified") return finish(this.error(input, "program_pause", paused.code), "failed");
|
||||
this.wasmMotion.setPaused(true);
|
||||
this.task.setProgram(result.program_status === 4 ? "completed" : "paused", result.line);
|
||||
this.task.transition(result.program_status === 4 ? "STOPPED" : "PAUSED");
|
||||
if (["READY", "STOPPED"].includes(this.task.state.state)) this.task.transition("RUNNING");
|
||||
if (this.task.state.state === "RUNNING") this.task.transition("PAUSED");
|
||||
this.task.setProgram("paused", this.xyzacProgram.snapshot().current_line);
|
||||
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, program: this.xyzacProgram.snapshot(), task: this.task.export(), motion: this.wasmMotionProjection() }), "verified");
|
||||
} catch (error) {
|
||||
return finish(this.error(input, "program_step", String(error?.message ?? error)), "failed");
|
||||
}
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, program: this.xyzacProgram.snapshot(), task: this.task.export(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
|
||||
}
|
||||
if (this.task.state.state === "READY") {
|
||||
this.task.setMode("AUTO");
|
||||
@@ -2280,6 +2336,8 @@ export class SimSession {
|
||||
// WASM TP, so clear that transport pause before queuing MDI motion;
|
||||
// the queue itself remains authoritative for execution.
|
||||
this.wasmMotion.setPaused(false);
|
||||
const synchronized = this.xyzacProgram.synchronizePosition(this.wasmMotion.read().state.position);
|
||||
if (synchronized.status !== "verified") return finish(this.error(input, "mdi_synch", synchronized.code), "failed");
|
||||
const result = this.xyzacProgram.executeBlock(block);
|
||||
if (result.status === "verified") {
|
||||
const controlCode = Number(result.program?.control_code ?? 0);
|
||||
@@ -2295,6 +2353,7 @@ export class SimSession {
|
||||
if (result.motion) {
|
||||
const enqueue = this.wasmMotion.enqueue(result.motion);
|
||||
if (enqueue.status === "error") return finish(this.error(input, "motion_queue", enqueue.code), "failed");
|
||||
if (this.wasmMotion.read().state.queue_depth > 0 && this.task.state.state !== "RUNNING") this.task.transition("RUNNING");
|
||||
}
|
||||
}
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, program: this.xyzacProgram.snapshot(), task: this.task.export(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
|
||||
@@ -2334,11 +2393,13 @@ export class SimSession {
|
||||
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 && this.xyzacProgramKinematicsMode !== XYZAC_MODES.identity) return finish(this.error(input, "home_limit", "home_joint_requires_identity"), "failed");
|
||||
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");
|
||||
// AXIS home_all_joints()/home_joint() calls ensure_mode(MANUAL)
|
||||
// before issuing the homing command.
|
||||
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, requestedAxis);
|
||||
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");
|
||||
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");
|
||||
@@ -2391,9 +2452,10 @@ export class SimSession {
|
||||
if (direction === null) return finish(this.error(input, "motion_jog", "motion_jog_range"), "failed");
|
||||
this.wasmMotion.setPaused(false);
|
||||
const requestedIncrement = input.payload?.increment;
|
||||
const distance = requestedIncrement === null || input.payload?.continuous === true
|
||||
? Math.max(Number(this.wasmMotion.jogVelocity(axis)) * this.xyzacMotionCycleTime * 5, Number.EPSILON)
|
||||
: requestedIncrement ?? 1;
|
||||
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);
|
||||
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;
|
||||
|
||||
81
web/worker/test_axis_source_execution.mjs
Normal file
81
web/worker/test_axis_source_execution.mjs
Normal file
@@ -0,0 +1,81 @@
|
||||
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 bundle = await loadSimulationCase({ path: "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini", fetchImpl: async (url) => {
|
||||
try { const source = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url), "utf8"); return { ok: true, text: async () => source }; }
|
||||
catch { return { ok: false, text: async () => "" }; }
|
||||
} });
|
||||
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
|
||||
const session = new XyzacSession(core, { sessionId: "axis-source-execution" });
|
||||
let seq = 0;
|
||||
const send = (kind, payload = {}) => session.accept({ schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId, seq: ++seq, operation_id: `source-exec-${seq}`, kind, payload });
|
||||
const ok = (kind, payload = {}) => {
|
||||
const response = send(kind, payload);
|
||||
assert.notEqual(response.kind, "ERROR", `${kind}: ${response.payload?.gate_reason ?? response.payload?.message_key ?? response.payload?.code}`);
|
||||
return response.payload;
|
||||
};
|
||||
const panel = () => ok("SNAPSHOT_REQUEST").panel_snapshot;
|
||||
const drain = () => { for (let n = 0; n < 2000 && session.wasmMotion.queueDepth() > 0; n++) ok("SIM_STEP", { cycles: 1, response: "motion_delta" }); };
|
||||
ok("HELLO"); ok("SESSION_ATTACH"); ok("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
|
||||
ok("PROGRAM_LOAD", { source: "G21 G90\nG1 X1 F600\nG1 X2\nM2\n", path: "source-step.ngc" });
|
||||
assert.equal(panel().axis_state.source_task_state, "STATE_ESTOP_RESET");
|
||||
ok("SET_OVERRIDE", { feed: 0.9 });
|
||||
assert.equal(panel().axis_state.source_task_state, "STATE_ESTOP_RESET");
|
||||
ok("POWER", { active: true });
|
||||
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");
|
||||
|
||||
ok("PROGRAM_STEP");
|
||||
assert.equal(panel().program.current_line, 0, "first native Step starts at line zero and pauses");
|
||||
ok("PROGRAM_STEP");
|
||||
assert.equal(panel().program.current_line, 1);
|
||||
ok("PROGRAM_STEP");
|
||||
assert.equal(panel().program.current_line, 2);
|
||||
assert.ok(session.wasmMotion.queueDepth() > 0);
|
||||
ok("PROGRAM_STEP");
|
||||
assert.equal(panel().motion.stepping, true);
|
||||
drain();
|
||||
assert.ok(Math.abs(panel().visual.pose.x - 1) < 1e-8);
|
||||
assert.equal(panel().program.current_line, 2);
|
||||
assert.equal(panel().axis_state.operation_state, "PAUSED");
|
||||
ok("PROGRAM_STEP"); ok("PROGRAM_STEP"); drain();
|
||||
assert.ok(Math.abs(panel().visual.pose.x - 2) < 1e-8);
|
||||
ok("PROGRAM_ABORT");
|
||||
|
||||
ok("PROGRAM_LOAD", { source: "G21 G90\nG1 X5 F600\nG1 X10\nM2\n", path: "source-waiting.ngc" });
|
||||
ok("PROGRAM_RUN");
|
||||
for (let n = 0; n < 20 && panel().program.program_status !== "completed"; n++) ok("SIM_STEP", { cycles: 1 });
|
||||
assert.equal(panel().axis_state.source_interp_state, "INTERP_WAITING");
|
||||
ok("PROGRAM_PAUSE");
|
||||
const paused = panel().visual.pose;
|
||||
ok("SIM_STEP", { cycles: 2 });
|
||||
assert.deepEqual(panel().visual.pose, paused);
|
||||
ok("PROGRAM_RESUME");
|
||||
assert.equal(panel().program.program_status, "completed", "Resume restores WAITING, not READING");
|
||||
drain(); ok("SIM_STEP", { cycles: 1 });
|
||||
assert.ok(Math.abs(panel().visual.pose.x - 10) < 1e-8);
|
||||
assert.equal(panel().axis_state.source_interp_state, "INTERP_IDLE");
|
||||
|
||||
ok("PROGRAM_LOAD", { source: "G21 G90\nM2\n", path: "source-mdi.ngc" });
|
||||
ok("MDI_EXECUTE", { block: "G1 X12 F600" });
|
||||
assert.equal(panel().axis_state.source_interp_state, "INTERP_WAITING");
|
||||
assert.equal(panel().ui_gates.mdi.enabled, true);
|
||||
assert.equal(panel().ui_gates.jog.enabled, false);
|
||||
assert.equal(send("MDI_EXECUTE", { block: "G0 X0" }).kind, "ERROR");
|
||||
ok("PROGRAM_PAUSE"); ok("PROGRAM_RESUME"); drain(); ok("SIM_STEP", { cycles: 1 });
|
||||
assert.equal(panel().axis_state.source_interp_state, "INTERP_IDLE");
|
||||
assert.ok(Math.abs(panel().visual.pose.x - 12) < 1e-8);
|
||||
ok("SPINDLE_COMMAND", { rpm: -600, enabled: true });
|
||||
ok("SET_SPINDLE_OVERRIDE", { scale: 0 });
|
||||
assert.equal(panel().devices.spindles[0].direction, -1);
|
||||
assert.equal(panel().devices.spindles[0].command_rpm, 0);
|
||||
ok("SET_SPINDLE_OVERRIDE", { scale: 1 });
|
||||
assert.equal(panel().devices.spindles[0].command_rpm, -600);
|
||||
ok("SPINDLE_COMMAND", { rpm: 0, enabled: false });
|
||||
console.log(JSON.stringify({ schema: "axis-source-execution.v1", status: "verified", checks: ["ui_vs_command_gate", "source_off_state", "switchkins_type", "repeated_step", "step_motion", "waiting_pause_resume", "mdi_motion_state", "spindle_zero_override"] }));
|
||||
@@ -13,8 +13,13 @@ assert.equal(axisState({ task: { state: "READY" }, program: { program_status: "i
|
||||
assert.equal(axisState({ task: { state: "RUNNING", mode: "AUTO" }, program: { program_status: "running" } }).source_interp_state, "INTERP_READING");
|
||||
assert.equal(axisState({ task: { state: "PAUSED", mode: "AUTO" }, program: { program_status: "paused" } }).source_interp_state, "INTERP_PAUSED");
|
||||
assert.equal(axisState({ task: { state: "STOPPED" }, io: { state: { estop: false, ready: false, last_error: 0 } } }).machine_state, "OFF");
|
||||
assert.equal(axisState({ task: { state: "STOPPED" }, io: { state: { estop: false, ready: false, last_error: -904 } } }).machine_state, "ESTOP_RESET");
|
||||
for (const last_error of [0, -904, -903]) {
|
||||
const state = axisState({ task: { state: "STOPPED" }, io: { state: { estop: false, ready: false, last_error } } });
|
||||
assert.equal(state.machine_state, "OFF");
|
||||
assert.equal(state.source_task_state, "STATE_ESTOP_RESET");
|
||||
}
|
||||
assert.equal(validateAxisTransition("ESTOP", "ESTOP_RESET").status, "accepted");
|
||||
assert.equal(axisState({ task: { state: "FAULT" }, io: { state: { estop: false, ready: true } } }).source_task_state, "STATE_ON");
|
||||
assert.equal(validateAxisTransition("ESTOP_RESET", "ON").status, "accepted");
|
||||
assert.equal(validateAxisTransition("ON", "RUNNING").status, "accepted");
|
||||
assert.equal(validateAxisTransition("RUNNING", "HOME").status, "blocked");
|
||||
|
||||
@@ -41,10 +41,14 @@ assert.equal(readyGates.tool.enabled, true);
|
||||
assert.equal(readyGates.probe.enabled, true);
|
||||
assert.equal(send("KINEMATICS_SELECT", { type: 1 }).payload.kinematics.active_type, 1);
|
||||
const alternateKinematicsGates = send("SNAPSHOT_REQUEST").payload.panel_snapshot.gates;
|
||||
assert.equal(alternateKinematicsGates.spindle.reason, "linuxcnc_wasm_mode_unavailable");
|
||||
// Switchkins leaves MDI selected; AXIS spindle() ensures MANUAL itself.
|
||||
assert.equal(alternateKinematicsGates.spindle.enabled, true);
|
||||
assert.equal(alternateKinematicsGates.spindle.reason, "linuxcnc_wasm_authority");
|
||||
assert.equal(alternateKinematicsGates.tool.reason, "linuxcnc_wasm_mode_unavailable");
|
||||
assert.equal(send("RESET").payload.command, "RESET");
|
||||
assert.equal(send("MDI_EXECUTE", { block: "G0 X1" }).payload.task.mode, "MDI");
|
||||
assert.equal(send("JOG_STOP").kind, "ERROR", "jog stop must not abort a running MDI move");
|
||||
assert.equal(send("SIM_STEP", { cycles: 100 }).kind, "COMMAND_RESULT");
|
||||
assert.equal(send("JOG_STOP").payload.command, "JOG_STOP");
|
||||
assert.equal(send("SET_MODE", { mode: "MANUAL" }).payload.mode, "MANUAL");
|
||||
assert.equal(send("POWER", { active: false }).payload.panel_snapshot.axis_state.machine_state, "OFF");
|
||||
@@ -72,7 +76,7 @@ assert.equal(send("PROGRAM_ABORT").payload.task.state, "STOPPED");
|
||||
|
||||
assert.equal(send("ESTOP", { active: true }).payload.panel_snapshot.axis_state.machine_state, "ESTOP");
|
||||
const reset = send("ESTOP", { active: false });
|
||||
assert.equal(reset.payload.panel_snapshot.axis_state.machine_state, "ESTOP_RESET");
|
||||
assert.equal(reset.payload.panel_snapshot.axis_state.source_task_state, "STATE_ESTOP_RESET");
|
||||
assert.equal(send("POWER", { active: true }).payload.panel_snapshot.axis_state.machine_state, "ON");
|
||||
|
||||
console.log(JSON.stringify({
|
||||
|
||||
@@ -56,7 +56,7 @@ const estopGates = xyzacGateMatrix({ task: { state: "ESTOP", mode: "MANUAL" }, p
|
||||
assert.equal(estopGates.switchkinsXyzac.enabled, false);
|
||||
assert.equal(estopGates.switchkinsXyzac.reason, "xyzac_estop_active");
|
||||
send("ESTOP", "estop-on", { active: true });
|
||||
assert.equal(send("ESTOP", "estop-reset", { active: false }).payload.panel_snapshot.axis_state.machine_state, "ESTOP_RESET");
|
||||
assert.equal(send("ESTOP", "estop-reset", { active: false }).payload.panel_snapshot.axis_state.source_task_state, "STATE_ESTOP_RESET");
|
||||
send("POWER", "power-on-again", { active: true });
|
||||
const recovered = send("SNAPSHOT_REQUEST", "recovered-snapshot");
|
||||
assert.equal(typeof recovered.payload.panel_snapshot.state_hash, "string");
|
||||
|
||||
@@ -50,7 +50,7 @@ assert.equal(released.payload.status, "verified");
|
||||
assert.equal(released.payload.device_io.state.estop, 0);
|
||||
assert.equal(released.payload.device_io.state.ready, 0);
|
||||
assert.equal(released.payload.task.state, "STOPPED");
|
||||
assert.equal(released.payload.panel_snapshot.axis_state.machine_state, "ESTOP_RESET");
|
||||
assert.equal(released.payload.panel_snapshot.axis_state.source_task_state, "STATE_ESTOP_RESET");
|
||||
|
||||
const powered = send("POWER", { active: true });
|
||||
assert.equal(powered.payload.status, "verified");
|
||||
@@ -97,9 +97,16 @@ const restarted = send("PROGRAM_RESTART");
|
||||
assert.equal(restarted.payload.status, "verified");
|
||||
const fullRun = send("PROGRAM_RUN");
|
||||
assert.equal(fullRun.payload.task.state, "RUNNING");
|
||||
const completed = send("SIM_STEP", { cycles: 8000 });
|
||||
let completed = send("SIM_STEP", { cycles: 8000 });
|
||||
assert.equal(completed.payload.status, "verified");
|
||||
assert.equal(completed.payload.program.program_status, "completed");
|
||||
// M2 closes the interpreter before the last trajectory segment drains.
|
||||
let drainTicks = 0;
|
||||
for (; drainTicks < 100 && completed.payload.wasm_motion.state.queue_depth > 0; drainTicks += 1) {
|
||||
assert.equal(completed.payload.panel_snapshot.gates.run.enabled, false);
|
||||
completed = send("SIM_STEP", { cycles: 1 });
|
||||
assert.equal(completed.payload.status, "verified");
|
||||
}
|
||||
assert.equal(completed.payload.task.state, "STOPPED");
|
||||
assert.equal(completed.payload.wasm_motion.state.queue_depth, 0);
|
||||
assert.equal((completed.payload.panel_snapshot.visual.toolpath ?? loaded.payload.panel_snapshot.visual.toolpath).length, 29);
|
||||
@@ -120,7 +127,7 @@ console.log(JSON.stringify({
|
||||
active_progress: stepped.payload.panel_snapshot.visual.active_progress,
|
||||
rtcp_active_type: stepped.payload.panel_snapshot.kinematics.active_type,
|
||||
rtcp_joint_pose_delta: stepped.payload.panel_snapshot.kinematics.joints.map((value, index) => value - [stepped.payload.panel_snapshot.visual.pose.x, stepped.payload.panel_snapshot.visual.pose.y, stepped.payload.panel_snapshot.visual.pose.z, stepped.payload.panel_snapshot.visual.pose.a, stepped.payload.panel_snapshot.visual.pose.c][index]),
|
||||
full_run: { cycles: 8000, program_status: completed.payload.program.program_status, task_state: completed.payload.task.state, queue_depth: completed.payload.wasm_motion.state.queue_depth, final_switchkins_type: completed.payload.panel_snapshot.kinematics.active_type },
|
||||
full_run: { cycles: 8000 + drainTicks, drain_ticks: drainTicks, program_status: completed.payload.program.program_status, task_state: completed.payload.task.state, queue_depth: completed.payload.wasm_motion.state.queue_depth, final_switchkins_type: completed.payload.panel_snapshot.kinematics.active_type },
|
||||
js_motion_unchanged: session.motion.export().state_hash === jsMotionHash,
|
||||
state_hash: stepped.payload.panel_snapshot.state_hash,
|
||||
}));
|
||||
|
||||
@@ -23,7 +23,7 @@ function setup(id, { homeAll = true } = {}) {
|
||||
|
||||
const partialHome = setup("xyzac-manual-partial-home", { homeAll: false });
|
||||
for (const [index, axis] of ["x", "y", "z", "a", "c"].entries()) {
|
||||
const response = partialHome.send("HOME", `home-${axis}`, { axis });
|
||||
const response = partialHome.send("HOME", `home-${axis}`, { joint: index });
|
||||
assert.equal(response.payload.wasm_home_limit.state.homed_mask, (1 << (index + 1)) - 1);
|
||||
assert.equal(response.payload.wasm_home_limit.state.homed, index === 4);
|
||||
}
|
||||
@@ -60,15 +60,22 @@ assert.equal(ferror.kind, "ERROR");
|
||||
assert.equal(ferror.payload.code, "motion_following_error");
|
||||
|
||||
const mdiCase = setup("xyzac-manual-mdi");
|
||||
const mdi = mdiCase.send("MDI_EXECUTE", "mdi-xyzac", { block: "G21 G90 G1 X10 Y5 Z180 A30 C45 F100" });
|
||||
// The selected INI limits Z to [-120, 120]; use an in-range completion case.
|
||||
const mdi = mdiCase.send("MDI_EXECUTE", "mdi-xyzac", { block: "G21 G90 G1 X10 Y5 Z18 A30 C45 F600" });
|
||||
assert.equal(mdi.kind, "COMMAND_RESULT");
|
||||
assert.equal(mdi.payload.motion_authority, "wasm");
|
||||
assert.equal(mdi.payload.program.source, "linuxcnc-wasm-sim_program");
|
||||
assert.equal(mdi.payload.panel_snapshot.kinematics.active_type, 0);
|
||||
const invalidMdi = mdiCase.send("MDI_EXECUTE", "mdi-invalid", { block: "G999" });
|
||||
assert.equal(invalidMdi.kind, "ERROR");
|
||||
assert.equal(invalidMdi.payload.gate_reason, "mdi_while_program_active");
|
||||
for (let tick = 0; tick < 20000 && mdiCase.session.wasmMotion.read().state.queue_depth > 0; tick += 32) {
|
||||
const sample = mdiCase.send("SIM_STEP", `mdi-drain-${tick}`, { cycles: 32, response: "motion_delta" });
|
||||
assert.equal(sample.kind, "COMMAND_RESULT", sample.payload.message_key);
|
||||
}
|
||||
assert.equal(mdiCase.session.wasmMotion.read().state.queue_depth, 0);
|
||||
const loaded = mdiCase.send("PROGRAM_LOAD", "mdi-program", { source: "G21 G90\nG1 X20 A10 C20 F100\nM2\n", path: "mdi-run.ngc" });
|
||||
assert.equal(loaded.kind, "COMMAND_RESULT");
|
||||
assert.equal(loaded.kind, "COMMAND_RESULT", JSON.stringify({ reason: loaded.payload.gate_reason ?? loaded.payload.message_key, code: loaded.payload.code, preview: loaded.payload.preview, program: loaded.payload.program?.program_status }));
|
||||
mdiCase.send("PROGRAM_RUN", "mdi-run");
|
||||
const mdiWhileRunning = mdiCase.send("MDI_EXECUTE", "mdi-running", { block: "G0 X1" });
|
||||
assert.equal(mdiWhileRunning.kind, "ERROR");
|
||||
|
||||
71
web/worker/test_xyzac_pause_resume_consistency.mjs
Normal file
71
web/worker/test_xyzac_pause_resume_consistency.mjs
Normal file
@@ -0,0 +1,71 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
|
||||
import { XyzacSession, CONTEXT_ID } from "./xyzac-session.mjs";
|
||||
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
|
||||
|
||||
const bundle = await loadSimulationCase({ path: "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini", fetchImpl: async (url) => {
|
||||
try { const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url), "utf8"); return { ok: true, text: async () => body }; }
|
||||
catch { return { ok: false, text: async () => "" }; }
|
||||
} });
|
||||
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
|
||||
const session = new XyzacSession(core, { sessionId: "pause-consistency" });
|
||||
let seq = 0;
|
||||
const send = (kind, payload = {}, accepted = true) => {
|
||||
const result = session.accept({ schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId, kind, payload, seq: ++seq, operation_id: `pause-consistency-${seq}` });
|
||||
assert.equal(result.kind === "ERROR", !accepted, `${kind}: ${result.payload.gate_reason ?? result.payload.message_key ?? result.payload.code}`);
|
||||
assert.equal(core._sim_protocol_last_seq(), seq, `${kind} stranded the transport`);
|
||||
assert.equal(session.timeline.events.cursor, seq, `${kind} stranded the event log`);
|
||||
if (!accepted) assert.equal(result.payload.code, "axis_gate");
|
||||
return result.payload;
|
||||
};
|
||||
const motion = () => session.wasmMotion.read().state;
|
||||
const snapshot = () => session.xyzacProgram.snapshot();
|
||||
const sample = (cycles = 5) => send("SIM_STEP", { cycles, response: "motion_delta" });
|
||||
send("HELLO"); send("SESSION_ATTACH"); send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
|
||||
send("PROGRAM_LOAD", { path: "pause-reading.ngc", source: "G21 G90\n" + Array.from({ length: 80 }, (_, i) => `G1 X${i % 2 ? 0 : 2} F600`).join("\n") + "\nM2\n" });
|
||||
send("POWER", { active: true }); send("HOME", { all: true }); send("PROGRAM_RUN"); sample();
|
||||
for (let round = 0; round < 3; round++) {
|
||||
send("PROGRAM_PAUSE");
|
||||
const pose = motion().position, line = snapshot().current_line, queue = motion().queue_depth;
|
||||
const epoch = session.livePathEpoch;
|
||||
sample(); assert.deepEqual(motion().position, pose);
|
||||
send("VISUAL_CLEAR"); assert.equal(session.livePathEpoch, epoch + 1);
|
||||
for (const [kind, payload] of [["SET_SPINDLE_OVERRIDE", { scale: 1 }], ["SET_MAX_VELOCITY", { percent: 100 }], ["SET_JOG_SCALE", { percent: 50 }], ["SET_OVERRIDE", { feed: 1 }]]) send(kind, payload);
|
||||
for (const [kind, payload] of [["PROGRAM_PAUSE", {}], ["PROGRAM_RUN", {}], ["PROGRAM_LOAD", { path: "bad.ngc", source: "M2\n" }], ["MDI_EXECUTE", { block: "G0 X100" }], ["HOME", { all: true }], ["JOG_START", { axis: "x", direction: 1, increment: 1 }]]) send(kind, payload, false);
|
||||
assert.equal(snapshot().current_line, line);
|
||||
assert.equal(snapshot().program_status, "paused");
|
||||
assert.deepEqual(motion().position, pose);
|
||||
assert.equal(motion().queue_depth, queue);
|
||||
send("PROGRAM_RESUME");
|
||||
for (let n = 0; n < 3; n++) sample();
|
||||
assert.notDeepEqual(motion().position, pose, "Resume did not advance the preserved TP");
|
||||
send("PROGRAM_RESUME", {}, false);
|
||||
sample();
|
||||
}
|
||||
send("PROGRAM_PAUSE");
|
||||
const beforeStep = motion().position;
|
||||
send("PROGRAM_STEP");
|
||||
for (let tick = 0; tick < 300 && session.wasmMotion.isStepping(); tick++) sample(1);
|
||||
assert.notDeepEqual(motion().position, beforeStep, "Step while paused did not move");
|
||||
assert.equal(snapshot().program_status, "paused");
|
||||
send("VISUAL_CLEAR"); send("PROGRAM_RESUME"); sample(); send("PROGRAM_ABORT");
|
||||
assert.equal(motion().queue_depth, 0);
|
||||
|
||||
send("PROGRAM_LOAD", { path: "pause-waiting.ngc", source: "G21 G90\nG1 X30 F600\nM2\n" });
|
||||
send("PROGRAM_RUN");
|
||||
for (let tick = 0; tick < 30 && snapshot().status !== 4; tick++) sample(1);
|
||||
assert.equal(snapshot().status, 4); assert.ok(motion().queue_depth > 0);
|
||||
send("PROGRAM_PAUSE"); send("VISUAL_CLEAR"); send("PROGRAM_RESUME");
|
||||
assert.equal(snapshot().status, 4, "WAITING resume must not rewind or re-read");
|
||||
for (let tick = 0; tick < 1000 && motion().queue_depth; tick++) sample();
|
||||
assert.ok(Math.abs(motion().position.x - 30) < 1e-8);
|
||||
send("PROGRAM_LOAD", { path: "pause-mdi.ngc", source: "G21 G90\nM2\n" });
|
||||
send("MDI_EXECUTE", { block: "G1 X20 F600" }); sample();
|
||||
send("PROGRAM_PAUSE");
|
||||
const mdiPose = motion().position;
|
||||
send("SET_SPINDLE_OVERRIDE", { scale: .8 }); send("VISUAL_CLEAR"); send("PROGRAM_RESUME"); sample();
|
||||
assert.notDeepEqual(motion().position, mdiPose);
|
||||
assert.equal(session.task.state.mode, "MDI");
|
||||
send("PROGRAM_ABORT");
|
||||
console.log(JSON.stringify({ schema: "xyzac-pause-consistency.v1", status: "verified", commands: seq, transport_seq: core._sim_protocol_last_seq(), checks: ["reading_repeated_pause", "clear_and_overrides_while_paused", "rejected_commands_keep_sequence", "paused_step_then_resume", "waiting_resume_to_completion", "mdi_pause_resume", "abort_after_resume"] }));
|
||||
@@ -3,6 +3,7 @@ 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 { SimSession, CONTEXT_ID } from "./sim-session.mjs";
|
||||
import { fnv1a64 } from "./replay.mjs";
|
||||
|
||||
const CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini";
|
||||
const fetchImpl = async (url) => {
|
||||
@@ -29,6 +30,38 @@ assert.equal(second.payload.program.path, "second.ngc");
|
||||
assert.notEqual(second.payload.program.source_hash, first.payload.program.source_hash);
|
||||
assert.equal(second.payload.panel_snapshot.visual.toolpath.length, 1);
|
||||
|
||||
const epoch = () => session.livePathEpoch;
|
||||
const before = epoch();
|
||||
const source = "G21 G90\nG1 X2 Y2 Z3 F100\nM2\n";
|
||||
const replaced = send("PROGRAM_LOAD", { source, path: "web-replacement.ngc", clear_live_plot_on_replace: true });
|
||||
assert.equal(replaced.kind, "COMMAND_RESULT");
|
||||
assert.equal(replaced.payload.visual_clear, undefined);
|
||||
assert.equal(epoch(), before, "changed file retains the native trace, even from an old caller");
|
||||
assert.equal(replaced.payload.program.source_hash, fnv1a64(source));
|
||||
assert.equal(session.xyzacProgram.snapshot().source_hash, fnv1a64(source));
|
||||
assert.equal(send("PROGRAM_LOAD", { source, path: "web-replacement.ngc", clear_live_plot_on_replace: true }).kind, "COMMAND_RESULT");
|
||||
assert.equal(epoch(), before, "unchanged reload preserves live history");
|
||||
const failedReplacement = send("PROGRAM_LOAD", { source: "G999\n", path: "bad-web.ngc", clear_live_plot_on_replace: true });
|
||||
assert.equal(failedReplacement.kind, "ERROR");
|
||||
assert.equal(epoch(), before, "failed parse does not clear history");
|
||||
const native = send("PROGRAM_LOAD", { source, path: "native.ngc" });
|
||||
assert.equal(native.kind, "COMMAND_RESULT");
|
||||
assert.equal(epoch(), before, "default source PROGRAM_LOAD preserves history");
|
||||
send("POWER", { active: true }); send("HOME", { all: true }); send("PROGRAM_RUN");
|
||||
const blocked = send("PROGRAM_LOAD", { source, path: "blocked.ngc", clear_live_plot_on_replace: true });
|
||||
assert.equal(blocked.kind, "ERROR");
|
||||
assert.equal(epoch(), before, "gate rejection does not clear history");
|
||||
send("PROGRAM_ABORT");
|
||||
const edited = send("PROGRAM_LOAD", { source: source.replace("X2", "X3"), path: "native.ngc", clear_live_plot_on_replace: true });
|
||||
assert.equal(edited.kind, "COMMAND_RESULT");
|
||||
assert.equal(epoch(), before, "edited text retains the native live trace");
|
||||
assert.equal(edited.payload.program.source_hash, fnv1a64(source.replace("X2", "X3")));
|
||||
const cleared = send("VISUAL_CLEAR");
|
||||
assert.equal(cleared.kind, "COMMAND_RESULT");
|
||||
assert.equal(epoch(), before + 1);
|
||||
assert.deepEqual(cleared.payload.before, cleared.payload.after, "clear must not change CNC state");
|
||||
assert.deepEqual(cleared.payload.panel_snapshot.visual.toolpath, edited.payload.panel_snapshot.visual.toolpath);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
schema: "xyzac-program-replacement.v1",
|
||||
status: "verified",
|
||||
|
||||
49
web/worker/test_xyzac_source_widget_gates.mjs
Normal file
49
web/worker/test_xyzac_source_widget_gates.mjs
Normal file
@@ -0,0 +1,49 @@
|
||||
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 bundle = await loadSimulationCase({
|
||||
path: "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini",
|
||||
fetchImpl: async (url) => {
|
||||
try { const text = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url), "utf8"); return { ok: true, text: async () => text }; }
|
||||
catch { return { ok: false, text: async () => "" }; }
|
||||
},
|
||||
});
|
||||
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
|
||||
const session = new XyzacSession(core, { sessionId: "source-widget-gates" });
|
||||
let seq = 0;
|
||||
const send = (kind, payload = {}) => session.accept({ schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId, seq: ++seq, operation_id: `source-widget-${seq}`, kind, payload });
|
||||
const snapshot = () => send("SNAPSHOT_REQUEST").payload.panel_snapshot;
|
||||
send("HELLO"); send("SESSION_ATTACH");
|
||||
assert.equal(send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }).kind, "COMMAND_RESULT");
|
||||
assert.equal(send("PROGRAM_LOAD", { source: "G21 G90\nG1 X100 F60\nM2\n", path: "source-widget.ngc" }).kind, "COMMAND_RESULT");
|
||||
for (const estop of [false, true]) {
|
||||
send("ESTOP", { active: estop });
|
||||
for (const [kind, payload] of [["SET_OVERRIDE", { feed: 0.8 }], ["SET_JOG_SCALE", { percent: 50 }], ["SET_MAX_VELOCITY", { percent: 60 }], ["SET_SPINDLE_OVERRIDE", { scale: 0.7 }]]) {
|
||||
const response = send(kind, payload);
|
||||
assert.equal(response.kind, "COMMAND_RESULT", `${kind} in ${estop ? "ESTOP" : "OFF"}: ${response.payload.gate_reason ?? response.payload.message_key}`);
|
||||
}
|
||||
assert.equal(snapshot().io.state.spindles[0].enabled, 0);
|
||||
assert.equal(snapshot().io.state.spindles[0].command_rpm, 0);
|
||||
assert.equal(snapshot().ui_gates.abort.enabled, false, "Stop widget must be disabled at IDLE");
|
||||
}
|
||||
send("ESTOP", { active: false }); send("POWER", { active: true }); send("HOME", { all: true });
|
||||
send("SET_MODE", { mode: "MDI" });
|
||||
assert.equal(snapshot().gates.spindle.enabled, true);
|
||||
assert.equal(snapshot().gates.touchOff.enabled, true);
|
||||
assert.equal(send("SPINDLE_COMMAND", { direction: 1, rpm: 100, enabled: true }).kind, "COMMAND_RESULT");
|
||||
assert.equal(snapshot().task.mode, "MANUAL", "source spindle radio handler ensures MANUAL");
|
||||
send("SPINDLE_COMMAND", { direction: 0, rpm: 0, enabled: false });
|
||||
send("PROGRAM_RUN"); send("SIM_STEP", { cycles: 2 });
|
||||
assert.equal(snapshot().ui_gates.abort.enabled, true);
|
||||
assert.equal(snapshot().ui_gates.coolant.enabled, false, "source manual checkbox group requires IDLE");
|
||||
assert.equal(snapshot().gates.load.enabled, false);
|
||||
send("PROGRAM_PAUSE");
|
||||
assert.equal(snapshot().gates.load.enabled, false);
|
||||
assert.equal(snapshot().ui_gates.abort.enabled, true);
|
||||
send("PROGRAM_ABORT");
|
||||
assert.equal(snapshot().ui_gates.abort.enabled, false);
|
||||
assert.equal(snapshot().ui_gates.coolant.enabled, true);
|
||||
console.log(JSON.stringify({ schema: "xyzac-source-widget-gates.v1", status: "verified", cases: ["OFF", "ESTOP", "MDI_IDLE", "AUTO_RUNNING", "PAUSED", "ABORTED_IDLE"] }));
|
||||
@@ -35,11 +35,38 @@ for (let sample = 0; sample < 5; sample += 1) jogSample = send("SIM_STEP", { cyc
|
||||
assert.ok(Number(jogSample.payload.wasm_motion?.state?.position?.x ?? 0) > 0);
|
||||
assert.equal(send("JOG_STOP", {}).payload.status, "verified");
|
||||
|
||||
const beforeTouch = session.wasmMotion.read().state.position;
|
||||
const epochBefore = send("SNAPSHOT_REQUEST").payload.panel_snapshot.visual.live_path_epoch;
|
||||
const systemTouch = send("TOUCH_OFF", { axis: "X", value: 12, system: "P1 G54", units: "mm" });
|
||||
assert.equal(systemTouch.kind, "COMMAND_RESULT");
|
||||
assert.equal(systemTouch.payload.touch_off.source_block, "G10 L20 P1 X12");
|
||||
assert.equal(systemTouch.payload.touch_off.block, "G10 L20 P1 X12");
|
||||
assert.equal(systemTouch.payload.task.mode, "MANUAL");
|
||||
let panel = send("SNAPSHOT_REQUEST").payload.panel_snapshot;
|
||||
assert.deepEqual(session.wasmMotion.read().state.position, beforeTouch);
|
||||
assert.ok(Math.abs(panel.coordinates.relative[0] - 12) < 1e-9);
|
||||
assert.ok(Math.abs(panel.visual.toolpath[0].end.x - (beforeTouch.x - 12 + 10)) < 1e-9);
|
||||
assert.equal(panel.visual.live_path_epoch, epochBefore);
|
||||
for (const axis of ["A", "C"]) {
|
||||
const angular = send("TOUCH_OFF", { axis, value: 15, system: "P1 G54", units: "inch" });
|
||||
assert.equal(angular.kind, "COMMAND_RESULT", JSON.stringify(angular.payload));
|
||||
assert.equal(angular.payload.block, `G10 L20 P1 ${axis}15`);
|
||||
panel = send("SNAPSHOT_REQUEST").payload.panel_snapshot;
|
||||
assert.equal(panel.coordinates.relative[axis === "A" ? 3 : 4], 15);
|
||||
}
|
||||
const savedOffsets = panel.program.coordinate_context;
|
||||
assert.equal(send("PROGRAM_LOAD", { source: "G21 G90\nG0 X13 A15 C15\nM2\n", path: "touch-reload.ngc" }).kind, "COMMAND_RESULT");
|
||||
panel = send("SNAPSHOT_REQUEST").payload.panel_snapshot;
|
||||
assert.deepEqual(panel.program.coordinate_context, savedOffsets);
|
||||
assert.ok(Math.abs(panel.visual.toolpath[0].end.x - beforeTouch.x - 1) < 1e-9);
|
||||
assert.equal(send("TOUCH_OFF", { axis: "X", value: 0, system: "G54.1" }).payload.code, "touch_off_system_invalid");
|
||||
assert.equal(send("TOUCH_OFF", { axis: "X", value: 20, system: "G59.3" }).kind, "COMMAND_RESULT");
|
||||
assert.equal(send("MDI_EXECUTE", { block: "G59.3" }).kind, "COMMAND_RESULT");
|
||||
panel = send("SNAPSHOT_REQUEST").payload.panel_snapshot;
|
||||
assert.equal(panel.coordinates.system, 9);
|
||||
assert.ok(Math.abs(panel.coordinates.relative[0] - 20) < 1e-9);
|
||||
assert.equal(send("MDI_EXECUTE", { block: "G54" }).kind, "COMMAND_RESULT");
|
||||
assert.equal(send("SET_MODE", { mode: "MANUAL" }).kind, "COMMAND_RESULT");
|
||||
|
||||
assert.equal(send("TOOL_PREPARE", { tool: 1, pocket: 1 }).payload.status, "verified");
|
||||
assert.equal(send("TOOL_PREPARED", { prepared: true }).payload.status, "verified");
|
||||
|
||||
@@ -56,7 +56,7 @@ const estop = send("ESTOP", "io-estop", { active: true });
|
||||
assert.equal(estop.payload.device_io.source, "wasm_io");
|
||||
const blocked = send("SPINDLE_COMMAND", "spindle-blocked", { spindle: 0, rpm: 500, enabled: true });
|
||||
assert.equal(blocked.kind, "ERROR");
|
||||
assert.equal(send("ESTOP", "io-estop-reset", { active: false }).payload.panel_snapshot.axis_state.machine_state, "ESTOP_RESET");
|
||||
assert.equal(send("ESTOP", "io-estop-reset", { active: false }).payload.panel_snapshot.axis_state.source_task_state, "STATE_ESTOP_RESET");
|
||||
assert.equal(send("POWER", "power-on-after-estop", { active: true }).payload.device_io.source, "wasm_io");
|
||||
const firstHash = send("SNAPSHOT_REQUEST", "snapshot-a").payload.panel_snapshot.device_io.projection_hash;
|
||||
const secondHash = send("SNAPSHOT_REQUEST", "snapshot-b").payload.panel_snapshot.device_io.projection_hash;
|
||||
|
||||
@@ -98,7 +98,8 @@ export class WasmHomeLimit {
|
||||
|
||||
home(sequence = 0, axis = null) {
|
||||
if (!this.available) return this.read();
|
||||
const index = axis === null || axis === undefined ? -1 : [...this.coordinates].findIndex((value) => value.toLowerCase() === String(axis).toLowerCase());
|
||||
const index = axis === null || axis === undefined ? -1 : Number.isInteger(axis) ? axis : [...this.coordinates].findIndex((value) => value.toLowerCase() === String(axis).toLowerCase());
|
||||
if (index >= this.jointCount) return { status: "error", code: "wasm_home_joint_invalid", state: this.read().state };
|
||||
if (axis !== null && index < 0) return { status: "error", code: "wasm_home_axis_invalid", state: this.read().state };
|
||||
const result = index < 0
|
||||
? call(this.core, "sim_kinematics_home_limit_home", "number", ["number", "number"], [this.homeLimit, sequence])
|
||||
|
||||
@@ -98,6 +98,17 @@ export class WasmLinuxcncTp {
|
||||
jog(axis, direction, increment, tag = 0) { if (!this.available) return { status: "blocked", code: "linuxcnc_tp_unavailable" }; const result = call(this.core, "sim_linuxcnc_tp_jog", "number", ["number", "number", "number", "number", "number"], [this.planner, Number(axis), Number(direction), Number(increment), Number(tag)]); return result === 0 ? { status: "verified", command: "JOG_START" } : { status: "error", code: `linuxcnc_tp_jog_${result}` }; }
|
||||
jogVelocity(axis) { if (!this.available) return 0; const index = { x: 0, y: 1, z: 2, a: 3, b: 4, c: 5, u: 6, v: 7, w: 8 }[String(axis).toLowerCase()]; return index === undefined ? 0 : Number(call(this.core, "sim_linuxcnc_tp_jog_velocity", "number", ["number", "number"], [this.planner, index])); }
|
||||
setPaused(value) { this.controls.paused = Boolean(value); if (this.available) call(this.core, "sim_linuxcnc_tp_set_paused", "number", ["number", "number"], [this.planner, this.controls.paused ? 1 : 0]); return this.read(); }
|
||||
stepLine() {
|
||||
const result = call(this.core, "sim_linuxcnc_tp_step_line", "number", ["number"], [this.planner]);
|
||||
if (result !== 0) throw new Error(`linuxcnc_tp_step_line_${result}`);
|
||||
this.controls.paused = false;
|
||||
return this.read();
|
||||
}
|
||||
isStepping() { return Boolean(call(this.core, "sim_linuxcnc_tp_is_stepping", "number", ["number"], [this.planner])); }
|
||||
axisLimits() {
|
||||
const limits = [0, 1, 2].map((axis) => [0, 1].map((maximum) => Number(call(this.core, "sim_linuxcnc_tp_axis_limit", "number", ["number", "number", "number"], [this.planner, axis, maximum]))));
|
||||
return limits.flat().every(Number.isFinite) ? limits : null;
|
||||
}
|
||||
|
||||
enqueue(segment) {
|
||||
if (!this.available || !segment) return { status: "skipped", code: "linuxcnc_tp_unavailable" };
|
||||
|
||||
@@ -72,6 +72,8 @@ export class WasmProgramRuntime {
|
||||
this.samplePointer = 0;
|
||||
this.samplePosePointer = 0;
|
||||
this.source = "";
|
||||
this.hashedSource = null;
|
||||
this.cachedSourceHash = null;
|
||||
this.path = null;
|
||||
this.subroutines = [];
|
||||
this.executionSource = "";
|
||||
@@ -95,6 +97,16 @@ export class WasmProgramRuntime {
|
||||
return result === 0 ? { status: "verified", command: name } : { status: "error", code: `wasm_${errorPrefix}_${result}`, result, reason: PROGRAM_ERROR_REASONS[result] ?? `wasm_${errorPrefix}_${result}`, message_key: PROGRAM_ERROR_REASONS[result] ?? `wasm_${errorPrefix}_${result}`, source: "linuxcnc-wasm-sim_program" };
|
||||
}
|
||||
|
||||
get sourceHash() {
|
||||
// Source text is immutable between file replacements. Only its identity
|
||||
// needs checking on a tick; the canonical WASM state is still read fresh.
|
||||
if (this.hashedSource !== this.source) {
|
||||
this.hashedSource = this.source;
|
||||
this.cachedSourceHash = this.source ? fnv1a64(this.source) : null;
|
||||
}
|
||||
return this.cachedSourceHash;
|
||||
}
|
||||
|
||||
load(source, path = null) {
|
||||
if (typeof source !== "string" || source.length === 0) return { status: "error", code: "program_source_invalid" };
|
||||
const bytes = new TextEncoder().encode(source);
|
||||
@@ -140,6 +152,9 @@ export class WasmProgramRuntime {
|
||||
if (loaded.status !== "verified") return loaded;
|
||||
const synchronized = temporary.synchronizePosition(this.synchronizedPosition);
|
||||
if (synchronized.status !== "verified") return synchronized;
|
||||
const state = this.snapshot();
|
||||
const initialized = temporary.initializeCoordinates(state.coordinate_context, state);
|
||||
if (initialized.status !== "verified") return initialized;
|
||||
temporary.setRapidVelocity(this.rapidVelocity);
|
||||
for (const subroutine of this.subroutines) {
|
||||
const added = temporary.addSubroutine(subroutine.name, subroutine.source, subroutine.path);
|
||||
@@ -156,11 +171,26 @@ export class WasmProgramRuntime {
|
||||
if (step.motion) segments.push({ id: segments.length + 1, kinematics_mode: kinematicsMode, ...step.motion });
|
||||
if ([4, 5, 6].includes(step.program.status)) break;
|
||||
}
|
||||
return { status: "verified", schema: "wasm-canonical-preview.v1", source: "linuxcnc-wasm-sim_program", path: this.path, source_hash: fnv1a64(this.source), line_count: this.snapshot().line_count, segments, preview_hash: fnv1a64(segments) };
|
||||
return { status: "verified", schema: "wasm-canonical-preview.v1", source: "linuxcnc-wasm-sim_program", path: this.path, source_hash: this.sourceHash, line_count: this.snapshot().line_count, segments, preview_hash: fnv1a64(segments) };
|
||||
} finally { temporary.destroy(); }
|
||||
}
|
||||
|
||||
start() { return this.invoke("sim_program_start", [], "program_start"); }
|
||||
initializeCoordinates(context, state = {}) {
|
||||
if (!context) return { status: "verified" };
|
||||
const previous = this.snapshot().coordinate_context;
|
||||
const blocks = ["G21"];
|
||||
for (const [index, offsets] of context.offsets.entries()) {
|
||||
if (offsets.some((value, axis) => value !== (previous?.offsets?.[index]?.[axis] ?? 0))) blocks.push(`G10 L2 P${index + 1} ${AXES.map((axis, a) => `${axis.toUpperCase()}${offsets[a]}`).join(" ")}`);
|
||||
}
|
||||
const system = context.system <= 6 ? `G${53 + context.system}` : `G59.${context.system - 6}`;
|
||||
blocks.push(`${system} G${state.units === 20 ? 20 : 21} G${state.distance_mode === 91 ? 91 : 90}`);
|
||||
for (const block of blocks) {
|
||||
const result = this.executeBlock(block);
|
||||
if (result.status !== "verified") return result;
|
||||
}
|
||||
return { status: "verified" };
|
||||
}
|
||||
synchronizePosition(value) {
|
||||
const position = Object.fromEntries(AXES.map((axis) => [axis, Number(value?.[axis] ?? 0)]));
|
||||
if (Object.values(position).some((coordinate) => !Number.isFinite(coordinate))) return { status: "error", code: "program_position_nonfinite" };
|
||||
@@ -208,10 +238,12 @@ export class WasmProgramRuntime {
|
||||
|
||||
restoreState(value) {
|
||||
if (!value || ![SCHEMA, "sim_program.v1"].includes(value.schema) || !Number.isSafeInteger(value.current_line) || value.current_line < 0 || !this.source) throw new Error("wasm_program_restore_schema");
|
||||
if (value.source_hash && value.source_hash !== fnv1a64(this.source)) throw new Error("wasm_program_restore_source_hash");
|
||||
if (value.source_hash && value.source_hash !== this.sourceHash) throw new Error("wasm_program_restore_source_hash");
|
||||
const subroutines = [...this.subroutines];
|
||||
const loaded = this.load(this.source, this.path);
|
||||
if (loaded.status !== "verified") throw new Error(loaded.code);
|
||||
const initialized = this.initializeCoordinates(value.coordinate_context, value);
|
||||
if (initialized.status !== "verified") throw new Error(initialized.code);
|
||||
const synchronized = this.synchronizePosition(Object.fromEntries(AXES.map((axis) => [axis, Number(value[axis] ?? 0)])));
|
||||
if (synchronized.status !== "verified") throw new Error(synchronized.code);
|
||||
for (const subroutine of subroutines) {
|
||||
@@ -232,6 +264,14 @@ export class WasmProgramRuntime {
|
||||
} else if (status === "completed") {
|
||||
this.start();
|
||||
for (let index = 0; index < this.snapshot().line_count + 1 && this.snapshot().status === 2; index += 1) this.step();
|
||||
} else if (status === "paused") {
|
||||
const from = Number(value.pause_from ?? 2);
|
||||
if (from !== 1) {
|
||||
this.start();
|
||||
if (from === 4) for (let index = 0; index < this.snapshot().line_count + 1 && this.snapshot().status === 2; index += 1) this.step();
|
||||
}
|
||||
const paused = this.pause();
|
||||
if (paused.status !== "verified") throw new Error(paused.code);
|
||||
} else if (status === "aborted") {
|
||||
const aborted = this.abort();
|
||||
if (aborted.status !== "verified") throw new Error(aborted.code);
|
||||
@@ -305,7 +345,7 @@ export class WasmProgramRuntime {
|
||||
const motionControl = { 0: "continuous", 1: "exact_path", 2: "exact_stop" }[state.motion_control_mode] ?? "continuous";
|
||||
const executionLineText = state.current_line > 0 ? (this.executionLines[state.current_line - 1] ?? "").trim() : "";
|
||||
const executionMeta = state.current_line > 0 ? (this.executionLineMetadata[state.current_line - 1] ?? { path: this.path, line: state.current_line }) : { path: this.path, line: 0 };
|
||||
return { schema: SCHEMA, source: "linuxcnc-wasm-sim_program", ...state, motion_control: { mode: motionControl, path_tolerance: Number(state.path_tolerance ?? 0), naive_cam_tolerance: Number(state.naive_cam_tolerance ?? 0) }, active_gcodes: activeGcodes(state), loaded: state.status !== 0, line: state.current_line, currentLine: state.current_line, execution_line: state.current_line, execution_line_text: executionLineText, execution_source_file: executionMeta.path, execution_source_line: executionMeta.line, program_status: programStatus, path: this.path, subroutines: this.subroutines.map((subroutine) => subroutine.name), state_hash: `fnv1a64:${BigInt.asUintN(64, BigInt(hash >>> 0)).toString(16).padStart(16, "0")}`, source_hash: this.source ? fnv1a64(this.source) : null };
|
||||
return { schema: SCHEMA, source: "linuxcnc-wasm-sim_program", ...state, motion_control: { mode: motionControl, path_tolerance: Number(state.path_tolerance ?? 0), naive_cam_tolerance: Number(state.naive_cam_tolerance ?? 0) }, active_gcodes: activeGcodes(state), loaded: state.status !== 0, line: state.current_line, currentLine: state.current_line, execution_line: state.current_line, execution_line_text: executionLineText, execution_source_file: executionMeta.path, execution_source_line: executionMeta.line, program_status: programStatus, path: this.path, subroutines: this.subroutines.map((subroutine) => subroutine.name), state_hash: `fnv1a64:${BigInt.asUintN(64, BigInt(hash >>> 0)).toString(16).padStart(16, "0")}`, source_hash: this.sourceHash };
|
||||
} finally { this.core._free(buffer); }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user