Align AXIS Web control lifecycle with LinuxCNC

This commit is contained in:
2026-09-07 13:47:10 +08:00
parent 35adac6471
commit 0a679b5eeb
34 changed files with 2598 additions and 177 deletions

View File

@@ -37,14 +37,14 @@ const OPERATION_KINDS = new Set([
"SESSION_ATTACH", "LOAD_WORKSPACE", "SIM_STEP", "DESTROY", "CHECKPOINT", "RECOVER", "ABORT",
"COMMAND", "START", "STOP", "RESET", "PROGRAM_LOAD", "PROGRAM_RUN",
"PROGRAM_PAUSE", "PROGRAM_RESUME", "PROGRAM_ABORT", "PROGRAM_RESTART", "JOG_START", "JOG_STOP",
"HOME", "SET_MODE", "SET_OVERRIDE", "SET_JOG_SCALE", "SET_MAX_VELOCITY", "SET_SPINDLE_OVERRIDE", "SPINDLE_COMMAND", "SPINDLE_BRAKE", "TOOL_PREPARE",
"HOME", "SET_MODE", "SET_OVERRIDE", "SET_JOG_SCALE", "SET_MAX_VELOCITY", "SET_SPINDLE_OVERRIDE", "SPINDLE_COMMAND", "SPINDLE_CONSTANT", "SPINDLE_BRAKE", "TOOL_PREPARE",
"TOOL_CHANGED", "TOOL_PREPARED", "TOOL_CHANGE", "TOOL_ABORT", "TOOL_FAULT", "TOOL_CLEAR_FAULT",
"SPINDLE_FAULT", "SPINDLE_INHIBIT", "SPINDLE_ORIENT", "SPINDLE_ORIENTED", "IO_STEP", "IO_RECOVER",
"PROBE_CONFIG", "LIMIT_SET", "ESTOP", "HAL_COMMAND", "HAL_SET",
"ENCODER_SET", "COOLANT_COMMAND", "COOLANT_FAULT", "DOOR_SET", "INTERLOCK_SET", "INTERLOCK_RESET", "DEVICE_ESTOP", "DEVICE_ACK_ISSUE", "DEVICE_ACK", "DEVICE_ACK_ABORT",
"CONFIGURATION_BATCH",
"PYTHON_IMPORT", "PYTHON_CALL", "PYTHON_CAPABILITY",
"PROGRAM_STEP", "PROGRAM_SEEK", "MDI_EXECUTE", "PLC_CONFIG", "PLC_SET", "PLC_TRIGGER", "FOLLOWING_ERROR", "IO_COMMAND", "IO_ACK", "IO_TIMEOUT",
"PROGRAM_STEP", "PROGRAM_SEEK", "MDI_EXECUTE", "TOUCH_OFF", "TOOL_TOUCH_OFF", "PLC_CONFIG", "PLC_SET", "PLC_TRIGGER", "FOLLOWING_ERROR", "IO_COMMAND", "IO_ACK", "IO_TIMEOUT",
"REMAP_REGISTER", "REMAP_CALL", "VCP_REGISTER", "VCP_UPDATE", "FILTER_EXEC",
"POWER", "KINEMATICS_SELECT", "VISUAL_CLEAR",
]);
@@ -53,7 +53,7 @@ const PANEL_GATE_ACTIONS = Object.freeze({
PROGRAM_RUN: "run", PROGRAM_STEP: "step", PROGRAM_PAUSE: "pause",
PROGRAM_RESUME: "resume", PROGRAM_ABORT: "abort", PROGRAM_RESTART: "restart",
JOG_START: "jogStart", JOG_STOP: "jogStop", SET_JOG_SCALE: "jogStart", SET_MAX_VELOCITY: "override", SET_SPINDLE_OVERRIDE: "override", HOME: "home", SET_OVERRIDE: "override",
SPINDLE_COMMAND: "spindle", SPINDLE_BRAKE: "spindle", COOLANT_COMMAND: "coolant", TOOL_PREPARE: "tool", TOOL_CHANGE: "tool", PROBE_CONFIG: "probe",
SPINDLE_COMMAND: "spindle", SPINDLE_CONSTANT: "spindle", SPINDLE_BRAKE: "spindle", COOLANT_COMMAND: "coolant", TOOL_PREPARE: "tool", TOOL_CHANGE: "tool", PROBE_CONFIG: "probe",
ESTOP: "estop", IO_RECOVER: "recover",
POWER: "power",
});
@@ -103,6 +103,8 @@ function canonicalPayload(value) {
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalPayload(value[key])}`).join(",")}}`;
}
// Control-flow contract: docs/linuxcnc-axis-web-control-panel-source-logic.md
// maps each browser operation to its LinuxCNC AXIS source symbol and gate.
export class SimSession {
constructor(core, { contextId = CONTEXT_ID, sessionId = "", planner = "queue" } = {}) {
this.core = core;
@@ -161,6 +163,7 @@ export class SimSession {
this.configurationState = null;
this.halState = null;
this.previewState = null;
this.livePathEpoch = 0;
this.workspaceFiles = {};
call(core, "sim_protocol_reset");
if (typeof core.ccall === "function") core.ccall("sim_event_reset", null, [], []);
@@ -173,7 +176,8 @@ export class SimSession {
// The loaded toolpath is immutable during execution. Avoid cloning all
// WASM pose samples into every 10 ms step response; the frontend retains
// the canonical path from PROGRAM_LOAD/PROGRAM_RESTART.
const lightweightXyzacStep = input.kind === "SIM_STEP" && Boolean(this.xyzacProgram);
const xyzacProgramActive = Boolean(this.xyzacProgram);
const lightweightXyzacStep = input.kind === "SIM_STEP" && xyzacProgramActive;
const traceOnlyStep = lightweightXyzacStep && input.payload?.trace_only === true;
const currentMotion = traceOnlyStep && statePayload.wasm_motion ? statePayload.wasm_motion : this.wasmMotion.read();
const completedMotion = program.status === 4 && currentMotion.state?.queue_depth === 0;
@@ -181,7 +185,21 @@ export class SimSession {
// responses must retain their code/reason instead of being rewritten into
// a delta with undefined fields.
const motionDelta = kind === "COMMAND_RESULT" && lightweightXyzacStep && input.payload?.response === "motion_delta" && !completedMotion;
const panelSnapshot = motionDelta || traceOnlyStep ? null : this.panelSnapshot({ task, program, input, tick, motionTrace: statePayload.motion_trace, includeToolpath: !lightweightXyzacStep, includeConfiguration: !lightweightXyzacStep, includeStableRuntime: !lightweightXyzacStep });
// The frontend retains the canonical preview from PROGRAM_LOAD. Repeating
// thousands of immutable segments on unrelated control responses makes a
// loaded program delay abort/power/home commands behind structured-clone
// work. Explicit preview transactions still publish the full replacement.
const publishXyzacToolpath = ["PROGRAM_LOAD", "PROGRAM_RESTART", "RECOVER", "SNAPSHOT_REQUEST", "VISUAL_CLEAR"].includes(input.kind);
const panelSnapshot = motionDelta || traceOnlyStep ? null : this.panelSnapshot({
task,
program,
input,
tick,
motionTrace: statePayload.motion_trace,
includeToolpath: xyzacProgramActive ? publishXyzacToolpath : !lightweightXyzacStep,
includeConfiguration: !lightweightXyzacStep,
includeStableRuntime: !lightweightXyzacStep,
});
const compactProgram = motionDelta ? {
schema: program.schema,
source: program.source,
@@ -337,8 +355,11 @@ export class SimSession {
if (!this.xyzacProgram?.source) return { status: "verified" };
const source = this.xyzacProgram.source;
const path = this.xyzacProgram.path;
const synchronizedPosition = this.wasmMotion.read().state.position;
const loaded = this.xyzacProgram.load(source, path);
if (loaded.status !== "verified") return loaded;
const synchronized = this.xyzacProgram.synchronizePosition(synchronizedPosition);
if (synchronized.status !== "verified") return synchronized;
this.xyzacProgram.setRapidVelocity(this.xyzacRapidVelocity);
const subroutines = this.loadXyzacSubroutines(path);
if (subroutines.status !== "verified") return subroutines;
@@ -415,6 +436,7 @@ export class SimSession {
schema: "axis-visual.v1",
pose: { ...position },
joints: Array.isArray(joints) ? [...joints] : [],
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,
active_progress: wasmMotion.status === "verified" ? wasmMotion.state.progress : 0,
@@ -502,6 +524,9 @@ export class SimSession {
const program = this.xyzacProgram.snapshot();
const io = this.xyzacIo?.canonical?.() ?? {};
const wasmMotion = this.wasmMotion.read();
if (input.kind === "PROGRAM_LOAD" && wasmMotion.status === "verified" && Number(wasmMotion.state.queue_depth ?? 0) > 0) {
return { action: null, enabled: false, reason: "xyzac_motion_active", taskState: task.state, interpState: "READING", wasm: { queue_depth: wasmMotion.state.queue_depth, active_id: wasmMotion.state.active_id } };
}
if (input.kind === "KINEMATICS_SELECT" && wasmMotion.status === "verified" && Number(wasmMotion.state.queue_depth ?? 0) > 0) {
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 } };
}
@@ -510,10 +535,11 @@ export class SimSession {
const ready = Boolean(io.ready) && !estop;
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
: input.kind === "SET_OVERRIDE" ? WASM_GATE_COMMANDS.OVERRIDE
: input.kind === "SPINDLE_COMMAND" || input.kind === "SPINDLE_BRAKE" ? WASM_GATE_COMMANDS.SPINDLE
: input.kind === "SPINDLE_COMMAND" || input.kind === "SPINDLE_CONSTANT" || input.kind === "SPINDLE_BRAKE" ? WASM_GATE_COMMANDS.SPINDLE
: input.kind === "COOLANT_COMMAND" ? WASM_GATE_COMMANDS.COOLANT
: input.kind === "TOOL_PREPARE" || input.kind === "TOOL_CHANGE" ? WASM_GATE_COMMANDS.TOOL
: input.kind === "PROBE_CONFIG" ? WASM_GATE_COMMANDS.PROBE
@@ -549,9 +575,6 @@ export class SimSession {
});
if (result === null) return { action, enabled: false, reason: "linuxcnc_wasm_gate_unavailable", taskState: task.state, interpState: "UNKNOWN" };
let reason = result === 0 ? "linuxcnc_wasm_authority" : WASM_GATE_REASONS[result] ?? "linuxcnc_wasm_gate_unknown_command";
if (input.kind === "PROGRAM_RUN" && Number(program.status) === 5) {
return { action, enabled: false, reason: "xyzac_program_aborted_requires_restart", taskState: task.state, interpState: "IDLE", wasm: { io_ready: ready, io_estop: estop, program_status: program.program_status, queue_depth: wasmMotion.state.queue_depth, gate_result: result } };
}
if (result === 2 && input.kind === "POWER" && input.payload?.active !== true) reason = "xyzac_power_already_off";
if (result === 3 && input.kind === "POWER") reason = "xyzac_power_already_on";
if (result === 4 && input.kind === "MDI_EXECUTE") reason = "mdi_while_program_active";
@@ -576,6 +599,44 @@ export class SimSession {
return { ...selected, kinematics: this.xyzacKinematics.snapshot(), wasm_motion: this.wasmMotion.read() };
}
xyzacTouchOff(input) {
if (!this.xyzacProgram) return { status: "error", code: "xyzac_program_unavailable" };
const payload = input.payload ?? {};
const axis = String(payload.axis ?? "").trim().toUpperCase();
if (!["X", "Y", "Z", "A", "C"].includes(axis)) return { status: "error", code: "touch_off_axis_invalid" };
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+.*)?$/);
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 formatted = Number(converted.toFixed(12));
const sourceBlock = `G10 L20 ${systemToken} ${axis}${formatted}`;
// The LinuxCNC interpreter accepts the G54..G59 word in this command;
// the selected WASM interpreter exposes the same operation through its
// canonical P-number form (G54=P1, ..., G59.3=P9).
const block = `G10 L20 P${systemNumber} ${axis}${formatted}`;
const savedMode = this.task.state.mode;
try {
if (savedMode !== "MDI") this.task.setMode("MDI");
this.wasmMotion.setPaused(false);
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 } };
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) };
}
}
stateForCheckpoint() {
const state = {
context_id: this.contextId,
@@ -600,6 +661,7 @@ export class SimSession {
wasm_home_limit: this.wasmHomeLimit.exportState(),
...(this.xyzacKinematics ? { kinematics: this.xyzacKinematics.snapshot() } : {}),
preview: this.previewState,
live_path_epoch: this.livePathEpoch,
task: this.task.export(),
scheduler: this.scheduler.export(),
authoritative: this.xyzacProgram ? { schema: "wasm_authority_boundary.v1", source: "linuxcnc-wasm-worker" } : this.authoritative.export(),
@@ -634,6 +696,7 @@ export class SimSession {
wasm_motion: this.wasmMotion.exportState(),
task_authority: this.task.authority(),
wasm_home_limit: this.wasmHomeLimit.exportState(),
live_path_epoch: this.livePathEpoch,
...(this.xyzacKinematics ? { kinematics: this.xyzacKinematics.snapshot() } : {}),
};
if (this.xyzacProgram) value.program = { ...this.xyzacProgram.snapshot(), source_text: this.xyzacProgram.source };
@@ -711,6 +774,7 @@ export class SimSession {
this.configurationState = state.configuration ?? this.configurationState;
this.halState = state.hal ?? this.halState;
this.previewState = state.preview ?? this.previewState;
this.livePathEpoch = Number.isSafeInteger(state.live_path_epoch) ? state.live_path_epoch : this.livePathEpoch;
this.python = new PythonBridge(); if (state.python) this.python.restoreState(state.python);
const liveEvents = liveTimeline.events.events;
const rebasedEvents = liveEvents.map((event, index) => index === liveEvents.length - 1 ? { ...event, tick: record.simulation_tick } : event);
@@ -806,6 +870,7 @@ export class SimSession {
if (trace.state?.configuration !== undefined) this.configurationState = trace.state.configuration;
if (trace.state?.hal !== undefined) this.halState = trace.state.hal;
if (trace.state?.preview !== undefined) this.previewState = trace.state.preview;
if (Number.isSafeInteger(trace.state?.live_path_epoch)) this.livePathEpoch = trace.state.live_path_epoch;
if (trace.state?.plc) this.plc.restore(trace.state.plc);
if (trace.state?.hal_runtime) this.hal.restore(trace.state.hal_runtime);
if (trace.state?.device_io) this.deviceIo.restore(trace.state.device_io);
@@ -856,6 +921,7 @@ export class SimSession {
this.configurationState = checkpoint.state.configuration ?? this.configurationState;
this.halState = checkpoint.state.hal ?? this.halState;
this.previewState = checkpoint.state.preview ?? this.previewState;
this.livePathEpoch = Number.isSafeInteger(checkpoint.state.live_path_epoch) ? checkpoint.state.live_path_epoch : this.livePathEpoch;
this.python = new PythonBridge(); if (checkpoint.state.python) this.python.restoreState(checkpoint.state.python);
this.timeline = new ReplayTimeline(checkpoint.timeline);
if (checkpoint.state.hal_runtime?.schema === "wasm_xyzac_hal.v1" && this.xyzacIo) this.xyzacIo.halCommand({ action: "step", ticks: checkpoint.state.hal_runtime.tick ?? 0 });
@@ -1104,6 +1170,8 @@ export class SimSession {
this.configurationState = result.state?.configuration ?? checkpoint?.state?.configuration ?? null;
this.halState = result.state?.hal ?? checkpoint?.state?.hal ?? null;
this.previewState = result.state?.preview ?? checkpoint?.state?.preview ?? null;
const recoveredLivePathEpoch = result.state?.live_path_epoch ?? checkpoint?.state?.live_path_epoch;
if (Number.isSafeInteger(recoveredLivePathEpoch)) this.livePathEpoch = recoveredLivePathEpoch;
this.python = new PythonBridge();
if (result.state?.python) this.python.restoreState(result.state.python);
this.program = new ProgramRunner(this.core);
@@ -1322,9 +1390,9 @@ 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.previewState = null;
this.livePathEpoch += 1;
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, visual_only: true, before, after, operation_id: input.operation_id ?? null }), "verified");
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");
};
switch (input.kind) {
@@ -1929,6 +1997,17 @@ export class SimSession {
case "PROGRAM_LOAD": {
if (["RUNNING", "PAUSED"].includes(this.task.state.state)) return finish(this.error(input, "task_state", "program_load_while_active"), "failed");
const source = input.payload?.source ?? input.payload?.text ?? input.payload?.program_source;
this.livePathEpoch += 1;
const savedMode = this.task.state.mode;
const restoreMode = () => {
if (savedMode !== "MANUAL" && this.task.state.mode !== savedMode) this.task.setMode(savedMode);
};
// AXIS open_file_guts() enters MANUAL before the replacement setup,
// then restores the tab-selected task mode before PLAN_OPEN. The
// final restore below mirrors set_mode_from_tab after the UI refresh.
try { if (savedMode !== "MANUAL") this.task.setMode("MANUAL"); } catch (error) {
return finish(this.error(input, "task_mode", String(error?.message ?? error)), "failed");
}
// PROGRAM_LOAD is a replacement transaction. Clear the previous
// canonical preview before asking the WASM interpreter to parse the
// new source, so a parse/subroutine error cannot re-publish the old
@@ -1952,9 +2031,12 @@ export class SimSession {
this.xyzacDeferredPhysicalPose = null;
this.xyzacDeferredPhysicalPoseApplied = false;
this.xyzacAppendDeferredOnActive = false;
restoreMode();
const synchronizedPosition = this.wasmMotion.read().state.position;
const result = this.xyzacProgram.load(source, input.payload?.path ?? null);
if (result.status === "verified") { const synchronized = this.xyzacProgram.synchronizePosition(synchronizedPosition); if (synchronized.status !== "verified") { restoreMode(); return finish(this.error(input, "program_synch", synchronized.code), "failed"); } }
if (result.status === "verified") this.xyzacProgram.setRapidVelocity(this.xyzacRapidVelocity);
if (result.status === "verified") { const subroutines = this.loadXyzacSubroutines(input.payload?.path); if (subroutines.status !== "verified") return finish(this.error(input, "program_subroutine", subroutines.code), "failed"); }
if (result.status === "verified") { const subroutines = this.loadXyzacSubroutines(input.payload?.path); if (subroutines.status !== "verified") { restoreMode(); return finish(this.error(input, "program_subroutine", subroutines.code), "failed"); } }
const preview = result.status === "verified" ? this.xyzacProgram.preview() : result;
if (result.status === "verified" && preview.status === "verified") {
this.previewState = preview;
@@ -1964,9 +2046,11 @@ export class SimSession {
if (this.task.state.state === "STOPPED") this.task.recover();
this.task.setProgram("loaded", 0);
}
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);
}
restoreMode();
const result = this.program.load(source, input.payload?.path ?? null);
if (result.status === "verified") {
this.authoritative.reset();
@@ -1978,6 +2062,7 @@ export class SimSession {
result.preview = preview.preview ?? { status: "error", code: preview.code ?? "program_preview_failed" };
this.previewState = result.preview;
}
restoreMode();
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, {
...result,
task: this.task.export(),
@@ -1990,12 +2075,14 @@ export class SimSession {
}
case "PROGRAM_RUN": {
if (this.xyzacProgram) {
const previousProgramStatus = this.xyzacProgram.snapshot().program_status;
const result = this.xyzacProgram.start();
if (result.status === "verified") { this.wasmMotion.setPaused(false); this.task.setMode("AUTO"); if (this.task.state.state === "STOPPED") this.task.recover(); this.task.transition("RUNNING"); const program = this.xyzacProgram.snapshot(); this.task.setProgram("running", 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");
if (result.status === "verified") { this.livePathEpoch += 1; this.wasmMotion.setPaused(false); this.task.setMode("AUTO"); if (this.task.state.state === "STOPPED") this.task.recover(); this.task.transition("RUNNING"); const program = this.xyzacProgram.snapshot(); this.task.setProgram("running", program.execution_source_line ?? program.current_line); }
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, reopened_taskfile: ["completed", "aborted"].includes(previousProgramStatus), start_line: 0, 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");
}
const result = this.program.start();
if (result.status === "verified") {
this.livePathEpoch += 1;
this.task.setMode("AUTO");
if (this.task.state.state === "STOPPED") this.task.recover();
if (this.motionAuthority === "wasm") this.wasmMotion.setPaused(false);
@@ -2094,14 +2181,13 @@ export class SimSession {
this.xyzacDeferredPhysicalPose = null;
this.xyzacDeferredPhysicalPoseApplied = false;
this.xyzacAppendDeferredOnActive = false;
const source = this.xyzacProgram.source;
const result = this.xyzacProgram.load(source, this.xyzacProgram.path);
if (result.status === "verified") this.xyzacProgram.setRapidVelocity(this.xyzacRapidVelocity);
if (result.status === "verified") { const subroutines = this.loadXyzacSubroutines(this.xyzacProgram.path); if (subroutines.status !== "verified") return finish(this.error(input, "program_subroutine", subroutines.code), "failed"); const preview = this.xyzacProgram.preview(); if (preview.status === "verified") this.previewState = preview; this.wasmMotion.reset(); this.wasmMotion.setPaused(false); this.task.recover(); this.task.setMode("AUTO"); this.task.setProgram("loaded", 0); }
const result = this.resetXyzacProgramToLoaded();
if (result.status === "verified") { this.livePathEpoch += 1; this.wasmMotion.abort(); this.wasmMotion.setPaused(false); this.task.recover(); this.task.setMode("AUTO"); this.task.setProgram("loaded", 0); }
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");
}
const result = this.program.restart();
if (result.status === "verified") {
this.livePathEpoch += 1;
if (["ESTOP", "FAULT"].includes(this.task.state.state)) return finish(this.error(input, "task_state", "program_restart_faulted"), "failed");
if (["RUNNING", "PAUSED"].includes(this.task.state.state)) this.task.transition("STOPPED");
if (this.task.state.state === "STOPPED") this.task.recover();
@@ -2225,6 +2311,17 @@ export class SimSession {
operation_id: input.operation_id ?? null,
}), result.status === "verified" ? "verified" : "failed");
}
case "TOUCH_OFF": case "TOOL_TOUCH_OFF": {
if (input.kind === "TOOL_TOUCH_OFF") return finish(this.error(input, "abi_capability", "blocked_by_abi_tool_offsets"), "failed");
const result = this.xyzacTouchOff(input);
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, {
...result,
task: this.task.export(),
motion_authority: this.xyzacProgram ? "wasm" : this.motionAuthority,
command: input.kind,
operation_id: input.operation_id ?? null,
}), result.status === "verified" ? "verified" : "failed");
}
case "KINEMATICS_SELECT": {
if (!this.xyzacKinematics) return finish(this.error(input, "kinematics", "xyzac_kinematics_unavailable"), "failed");
const mode = Number(input.payload?.type);
@@ -2239,7 +2336,12 @@ 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 home = this.wasmHomeLimit.home(input.seq, input.payload?.all ? null : input.payload?.axis ?? null);
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");
// 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);
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");
@@ -2291,7 +2393,10 @@ export class SimSession {
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");
this.wasmMotion.setPaused(false);
const distance = input.payload?.increment ?? 1;
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 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;
@@ -2308,7 +2413,7 @@ export class SimSession {
if (result.status === "error" && result.state?.fault && this.task.state.state === "RUNNING") this.task.fault(-1205);
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, motion: this.motionProjection(), task: this.task.export(), command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
}
case "SET_SPINDLE_OVERRIDE": case "SPINDLE_COMMAND": case "SPINDLE_BRAKE": case "SPINDLE_FAULT": case "SPINDLE_INHIBIT":
case "SET_SPINDLE_OVERRIDE": case "SPINDLE_COMMAND": case "SPINDLE_CONSTANT": case "SPINDLE_BRAKE": case "SPINDLE_FAULT": case "SPINDLE_INHIBIT":
case "SPINDLE_ORIENT": case "SPINDLE_ORIENTED": case "IO_STEP":
case "TOOL_PREPARE": case "TOOL_PREPARED": case "TOOL_CHANGE":
case "TOOL_CHANGED": case "TOOL_ABORT": case "TOOL_FAULT": case "TOOL_CLEAR_FAULT":

View File

@@ -40,10 +40,16 @@ assert.equal(loaded.payload.preview.source, "linuxcnc-wasm-sim_program");
assert.equal(loaded.payload.preview.line_count, 4711);
assert.equal(loaded.payload.preview.segments.length, 4684);
assert.equal(send("POWER", { active: true }).kind, "COMMAND_RESULT");
assert.equal(send("HOME", { all: true }).kind, "COMMAND_RESULT");
const power = send("POWER", { active: true });
assert.equal(power.kind, "COMMAND_RESULT");
assert.equal(Object.hasOwn(power.payload.panel_snapshot.visual, "toolpath"), false);
const home = send("HOME", { all: true });
assert.equal(home.kind, "COMMAND_RESULT");
assert.equal(Object.hasOwn(home.payload.panel_snapshot.visual, "toolpath"), false);
const run = send("PROGRAM_RUN");
assert.equal(run.kind, "COMMAND_RESULT");
assert.equal(Object.hasOwn(run.payload.panel_snapshot.visual, "toolpath"), false);
assert.equal(run.payload.xyzac_ledger.entries.some((entry) => Object.hasOwn(entry, "response")), false);
let stepped = null;
let executionTraceEntries = 0;
let runningQueueGaps = 0;
@@ -69,6 +75,18 @@ assert.equal(session.timeline.snapshots.snapshots.size, 0);
assert.equal(session.ledger.audit({ maxEntries: 32 }).entries.length, 32);
assert.equal(runningQueueGaps, 0);
// Replacing a large preview must leave the worker responsive for the next
// control transaction and must publish only the new program's path.
const aborted = send("PROGRAM_ABORT");
assert.equal(aborted.kind, "COMMAND_RESULT");
const replacement = send("PROGRAM_LOAD", { source: "G21 G90\nG1 X1 Y2 F100\nM2\n", path: "replacement.ngc" });
assert.equal(replacement.kind, "COMMAND_RESULT");
assert.equal(replacement.payload.program.path, "replacement.ngc");
assert.equal(replacement.payload.panel_snapshot.visual.toolpath.length, 1);
const replacementPower = send("POWER", { active: false });
assert.equal(replacementPower.kind, "COMMAND_RESULT");
assert.equal(Object.hasOwn(replacementPower.payload.panel_snapshot.visual, "toolpath"), false);
console.log(JSON.stringify({
schema: "xyzac-3d-chips-program.v1",
status: "verified",
@@ -84,4 +102,5 @@ console.log(JSON.stringify({
motion_authority: stepped.payload.motion_authority,
motion_delta_snapshots: session.timeline.snapshots.snapshots.size,
ledger_audit_window: session.ledger.audit({ maxEntries: 32 }).entries.length,
replacement_segments: replacement.payload.panel_snapshot.visual.toolpath.length,
}));

View File

@@ -0,0 +1,76 @@
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 { CONTEXT_ID, SimSession } from "./sim-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 core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
const session = new SimSession(core, { sessionId: "xyzac-live-path-lifecycle" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
let seq = 0;
const send = (kind, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: `${kind.toLowerCase()}-${seq}`, payload });
const epoch = (response) => response.payload.panel_snapshot.visual.live_path_epoch;
send("HELLO");
send("SESSION_ATTACH");
send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
const source = ["G21", "G90", ...Array.from({ length: 80 }, (_, index) => `G1 X${index % 2 ? 0 : 5} Y${index % 2 ? 0 : 3} Z${index % 2 ? 10 : 8} F300`), "M2", ""].join("\n");
const loaded = send("PROGRAM_LOAD", { source, path: "live-path.ngc" });
assert.equal(loaded.kind, "COMMAND_RESULT");
const loadEpoch = epoch(loaded);
const previewCount = loaded.payload.panel_snapshot.visual.toolpath.length;
assert.ok(loadEpoch > 0 && previewCount > 0);
send("POWER", { active: true });
send("HOME", { all: true });
const run = send("PROGRAM_RUN");
const runEpoch = epoch(run);
assert.equal(runEpoch, loadEpoch + 1);
send("SIM_STEP", { cycles: 5, response: "motion_delta" });
const pause = send("PROGRAM_PAUSE");
assert.equal(epoch(pause), runEpoch);
assert.equal(Object.hasOwn(pause.payload.panel_snapshot.visual, "toolpath"), false);
const resume = send("PROGRAM_RESUME");
assert.equal(epoch(resume), runEpoch);
assert.equal(Object.hasOwn(resume.payload.panel_snapshot.visual, "toolpath"), false);
let completed = resume;
for (let index = 0; index < 5000; index += 1) {
completed = send("SIM_STEP", { cycles: 20, response: "motion_delta" });
if (completed.payload.program?.program_status === "completed" && Number(completed.payload.wasm_motion?.state?.queue_depth ?? -1) === 0) break;
}
assert.equal(completed.payload.program.program_status, "completed");
assert.equal(epoch(completed), runEpoch);
assert.equal(Object.hasOwn(completed.payload.panel_snapshot.visual, "toolpath"), false);
const rerun = send("PROGRAM_RUN");
assert.equal(epoch(rerun), runEpoch + 1);
const abort = send("PROGRAM_ABORT");
assert.equal(epoch(abort), runEpoch + 1);
const replacement = send("PROGRAM_LOAD", { source: "G21\nG90\nG1 X1 Y1 Z1 F300\nM2\n", path: "replacement-live-path.ngc" });
assert.equal(epoch(replacement), runEpoch + 2);
assert.equal(replacement.payload.panel_snapshot.visual.toolpath.length, 1);
const clear = send("VISUAL_CLEAR");
assert.equal(epoch(clear), runEpoch + 3);
assert.equal(clear.payload.panel_snapshot.visual.toolpath.length, 1);
assert.equal(session.previewState.segments.length, 1);
console.log(JSON.stringify({
schema: "xyzac-live-path-lifecycle.v1",
status: "verified",
source: "axis.py:LivePlotter/clear_live_plot/task_pause/task_resume/task_stop",
epochs: { load: loadEpoch, run: runEpoch, pause: epoch(pause), resume: epoch(resume), completed: epoch(completed), rerun: epoch(rerun), abort: epoch(abort), replacement: epoch(replacement), clear: epoch(clear) },
static_preview: { initial_segments: previewCount, clear_plot_segments: clear.payload.panel_snapshot.visual.toolpath.length },
}));

View File

@@ -0,0 +1,86 @@
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 { fnv1a64 } from "./replay.mjs";
import { CONTEXT_ID, SimSession } from "./sim-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 programs = [
{ name: "program-a.ngc", source: "G21\nG90\nG0 X0 Y0 Z10 A0 C0\nG1 X10 Y0 Z10 A0 C0 F600\nM2\n", expected: { x: 10, y: 0, z: 10 } },
{ name: "program-b.ngc", source: "G21\nG91\nG1 X5 F600\nG1 Y4 F600\nM2\n", delta: { x: 5, y: 4, z: 0 } },
{ name: "program-c.ngc", source: "G21\nG91\nG1 X-3 Y-2 Z2 F600\nM2\n", delta: { x: -3, y: -2, z: 2 } },
];
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 });
const session = new SimSession(core, { sessionId: "xyzac-multi-program-load" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
let seq = 0;
const send = (kind, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: `${kind.toLowerCase()}-${seq}`, payload });
const close = (actual, expected, label) => assert.ok(Math.abs(Number(actual) - Number(expected)) <= 1e-7, `${label}: ${actual} != ${expected}`);
const runToCompletion = () => {
const started = send("PROGRAM_RUN");
assert.equal(started.kind, "COMMAND_RESULT", JSON.stringify(started));
let response = started;
for (let index = 0; index < 2000; index += 1) {
response = send("SIM_STEP", { cycles: 20, response: "motion_delta" });
assert.equal(response.kind, "COMMAND_RESULT", JSON.stringify(response));
if (response.payload.program?.program_status === "completed" && Number(response.payload.wasm_motion?.state?.queue_depth ?? -1) === 0) return response;
}
throw new Error(`program_completion_timeout:${JSON.stringify(response.payload?.program)}`);
};
send("HELLO");
send("SESSION_ATTACH");
assert.equal(send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }).payload.configuration.state, "TASK_READY");
assert.equal(send("POWER", { active: true }).payload.status, "verified");
assert.equal(send("HOME", { all: true }).payload.status, "verified");
const evidence = [];
let expectedPose = { x: 0, y: 0, z: 0 };
for (const program of programs) {
const before = session.wasmMotion.read().state.position;
close(before.x, expectedPose.x, `${program.name}:before.x`);
close(before.y, expectedPose.y, `${program.name}:before.y`);
close(before.z, expectedPose.z, `${program.name}:before.z`);
const loaded = send("PROGRAM_LOAD", { source: program.source, path: `browser/${program.name}` });
assert.equal(loaded.kind, "COMMAND_RESULT", JSON.stringify(loaded));
assert.equal(loaded.payload.program.source_hash, fnv1a64(program.source));
assert.equal(loaded.payload.program.current_line, 0);
const firstMotion = loaded.payload.preview.segments[0];
close(firstMotion.start.x, before.x, `${program.name}:preview.start.x`);
close(firstMotion.start.y, before.y, `${program.name}:preview.start.y`);
close(firstMotion.start.z, before.z, `${program.name}:preview.start.z`);
const completed = runToCompletion();
expectedPose = program.expected ?? {
x: before.x + program.delta.x,
y: before.y + program.delta.y,
z: before.z + program.delta.z,
};
close(completed.payload.wasm_motion.state.position.x, expectedPose.x, `${program.name}:final.x`);
close(completed.payload.wasm_motion.state.position.y, expectedPose.y, `${program.name}:final.y`);
close(completed.payload.wasm_motion.state.position.z, expectedPose.z, `${program.name}:final.z`);
evidence.push({ name: program.name, source_hash: loaded.payload.program.source_hash, preview_segments: loaded.payload.preview.segments.length, synchronized_start: { x: firstMotion.start.x, y: firstMotion.start.y, z: firstMotion.start.z }, final: expectedPose });
}
const beforeRoundTrip = session.wasmMotion.read().state.position;
const roundTrip = send("PROGRAM_LOAD", { source: programs[0].source, path: `browser/${programs[0].name}` });
assert.equal(roundTrip.payload.program.source_hash, evidence[0].source_hash);
assert.notEqual(roundTrip.payload.program.source_hash, evidence[1].source_hash);
assert.notEqual(roundTrip.payload.program.source_hash, evidence[2].source_hash);
close(roundTrip.payload.preview.segments[0].start.x, beforeRoundTrip.x, "roundtrip:start.x");
close(roundTrip.payload.preview.segments[0].start.y, beforeRoundTrip.y, "roundtrip:start.y");
close(roundTrip.payload.preview.segments[0].start.z, beforeRoundTrip.z, "roundtrip:start.z");
console.log(JSON.stringify({ schema: "xyzac-multi-program-load.v1", status: "verified", source: "axis.py:open_file_guts/task_plan_synch", programs: evidence, round_trip_hash: roundTrip.payload.program.source_hash }));

View File

@@ -136,8 +136,10 @@ record("machine", "Power after ESTOP", "POWER", "axis.py:commands.power", io.sen
const unsupported = [
{ button: "Override Limits", reason: "selected XYZAC WASM configuration owns limits; no LinuxCNC override_limits command ABI" },
{ button: "Rapid Override", reason: "blocked_by_abi_rapidrate: selected WASM TP exposes max velocity but not c.rapidrate" },
{ button: "Angular Jog Speed", reason: "blocked_by_abi_angular_jog_scale: selected WASM TP exposes one shared jog scale" },
{ button: "Block delete / Optional pause", reason: "UI toggle is not forwarded to sim_program interpreter state" },
{ button: "Tool Touch Off", reason: "UI emits fixed G43 and does not expose LinuxCNC G10 prompt/value flow" },
{ button: "Tool Touch Off", reason: "blocked_by_abi_tool_offsets: selected WASM interpreter has no canonical G10 L10/L11 tool-offset storage" },
{ button: "Edit tool data / Quit", reason: "desktop process boundary; browser intentionally host-excluded" },
];
for (const item of unsupported) rows.push({ group: "scope", button: item.button, command: null, source: "axis.py source mapped", result: "blocked_by_abi", reason: item.reason });

View File

@@ -0,0 +1,66 @@
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 { CONTEXT_ID, SimSession } from "./sim-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 });
const session = new SimSession(core, { sessionId: "xyzac-program-rerun" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
let seq = 0;
const send = (kind, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: `${kind.toLowerCase()}-${seq}`, payload });
send("HELLO");
send("SESSION_ATTACH");
assert.equal(send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }).payload.configuration.state, "TASK_READY");
const source = "G21\nG90\nG0 X0 Y0 Z10 A0 C0\nG1 X10 Y5 Z5 A15 C20 F600\nG1 X0 Y0 Z10 A0 C0 F600\nM2\n";
assert.equal(send("PROGRAM_LOAD", { source, path: "rerun.ngc" }).payload.status, "verified");
assert.equal(send("POWER", { active: true }).payload.status, "verified");
assert.equal(send("HOME", { all: true }).payload.status, "verified");
assert.equal(send("PROGRAM_RUN").payload.task.state, "RUNNING");
let completed;
for (let index = 0; index < 1000; index += 1) {
completed = send("SIM_STEP", { cycles: 20, response: "motion_delta" });
if (completed.payload.program.program_status === "completed" && completed.payload.wasm_motion.state.queue_depth === 0) break;
}
assert.equal(completed.payload.program.program_status, "completed");
assert.equal(completed.payload.task.state, "STOPPED");
const rerunCompleted = send("PROGRAM_RUN");
assert.equal(rerunCompleted.kind, "COMMAND_RESULT");
assert.equal(rerunCompleted.payload.status, "verified");
assert.equal(rerunCompleted.payload.reopened_taskfile, true);
assert.equal(rerunCompleted.payload.start_line, 0);
assert.equal(rerunCompleted.payload.program.current_line, 0);
assert.equal(rerunCompleted.payload.task.state, "RUNNING");
const rerunStep = send("SIM_STEP", { cycles: 2, response: "motion_delta" });
assert.equal(rerunStep.kind, "COMMAND_RESULT", JSON.stringify(rerunStep));
assert.equal(rerunStep.payload.program?.program_status, "running", JSON.stringify(rerunStep));
assert.equal(send("PROGRAM_ABORT").payload.task.state, "STOPPED");
const rerunAborted = send("PROGRAM_RUN");
assert.equal(rerunAborted.kind, "COMMAND_RESULT");
assert.equal(rerunAborted.payload.status, "verified");
assert.equal(rerunAborted.payload.reopened_taskfile, true);
assert.equal(rerunAborted.payload.program.current_line, 0);
assert.equal(rerunAborted.payload.task.state, "RUNNING");
console.log(JSON.stringify({
schema: "xyzac-program-rerun.v1",
status: "verified",
source: "linuxcnc-emctaskmain-auto-run",
completed_rerun: { reopened_taskfile: rerunCompleted.payload.reopened_taskfile, start_line: rerunCompleted.payload.start_line },
aborted_rerun: { reopened_taskfile: rerunAborted.payload.reopened_taskfile, start_line: rerunAborted.payload.start_line },
}));

View File

@@ -48,7 +48,10 @@ const clear = send("VISUAL_CLEAR", "visual-clear");
assert.equal(clear.kind, "COMMAND_RESULT");
assert.equal(clear.payload.visual_only, true);
assert.deepEqual(clear.payload.before, clear.payload.after);
assert.equal(send("PROGRAM_RUN", "program-run").kind, "COMMAND_RESULT");
const run = send("PROGRAM_RUN", "program-run");
assert.equal(run.kind, "COMMAND_RESULT");
assert.equal(Object.hasOwn(run.payload.panel_snapshot.visual, "toolpath"), false);
assert.equal(run.payload.xyzac_ledger.entries.some((entry) => Object.hasOwn(entry, "response")), false);
const runningSwitch = send("KINEMATICS_SELECT", "select-running", { type: 0 });
assert.equal(runningSwitch.kind, "ERROR");
assert.equal(runningSwitch.payload.gate_reason, "xyzac_interp_running");
@@ -78,7 +81,7 @@ const sessionSource = await readFile(new URL("./sim-session.mjs", import.meta.ur
const tpSource = await readFile(new URL("./wasm-linuxcnc-tp.mjs", import.meta.url), "utf8");
const combined = `${appSource}\n${panelSource}\n${contractSource}`;
for (const symbol of ["XyzacTrtPanel", "XyzacProgramPane", "XyzacKinematicsControl", "XyzacManualPanel", "XyzacLiveParameters", "XyzacDiagnostics", "Spindle", "Feed actual", "TP velocity", "Live parameters", "KINEMATICS_SELECT", "VISUAL_CLEAR", "POWER", "switchkinsXyzac", "Operation trace", "Alarms / recovery"]) assert.ok(combined.includes(symbol), symbol);
for (const symbol of ["wasmSnapshotReady", "manualReady", "sourceJogScale", "sourceVelocityScale", "spindleOverrideSource", "reopenDefaults", "gates.reset.enabled", "SET_JOG_SCALE", "SET_MAX_VELOCITY", "SET_SPINDLE_OVERRIDE", "WASM PENDING", "LinuxCNC WASM override-limits command unavailable"]) assert.ok(appSource.includes(symbol), symbol);
for (const symbol of ["wasmSnapshotReady", "manualReady", "sourceJogScale", "sourceVelocityScale", "spindleOverrideSource", "SET_JOG_SCALE", "SET_MAX_VELOCITY", "SET_SPINDLE_OVERRIDE", "WASM PENDING", "LinuxCNC WASM override-limits command unavailable"]) assert.ok(appSource.includes(symbol), symbol);
for (const symbol of ["loadUiState", "saveUiState", "UI_STATE_SAVE", "UI_STATE_LOAD"]) assert.ok(`${appSource}\n${workerClientSource}\n${opfsBridgeSource}`.includes(symbol), symbol);
for (const forbidden of [/defaultValue=\"50\"/, /defaultValue=\"100\"/, /setBlockDelete/, /setOptionalStop/, /state\.status === \"running\" \? \"READING\"/]) assert.equal(forbidden.test(appSource), false, String(forbidden));
for (const forbidden of [/_sim_/, /_malloc/, /\bHEAP(?:U|F|I)?\d*\b/, /navigator\.storage/, /localStorage/, /postMessage\s*\(/]) assert.equal(forbidden.test(combined), false, String(forbidden));

View File

@@ -0,0 +1,56 @@
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 { SimSession, CONTEXT_ID } from "./sim-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 });
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
const session = new SimSession(core, { sessionId: "xyzac-touch-off-source-flow" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
let seq = 0;
const send = (kind, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: `touch-${seq}`, payload });
send("HELLO");
send("SESSION_ATTACH");
assert.equal(send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }).payload.status, "verified");
assert.equal(send("PROGRAM_LOAD", { source: "G21 G90\nG0 X10 Y5 Z200 A30 C45\nG1 X20 Y10 Z180 A35 C50 F100\nM2\n", path: "touch.ngc" }).payload.status, "verified");
assert.equal(send("POWER", { active: true }).payload.status, "verified");
assert.equal(send("HOME", { all: true }).payload.status, "verified");
const jogStart = send("JOG_START", { axis: "X", direction: 1, increment: null, continuous: true });
assert.equal(jogStart.payload.status, "verified");
let jogSample = jogStart;
for (let sample = 0; sample < 5; sample += 1) jogSample = send("SIM_STEP", { cycles: 1, response: "motion_delta" });
assert.ok(Number(jogSample.payload.wasm_motion?.state?.position?.x ?? 0) > 0);
assert.equal(send("JOG_STOP", {}).payload.status, "verified");
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");
assert.equal(send("TOOL_PREPARE", { tool: 1, pocket: 1 }).payload.status, "verified");
assert.equal(send("TOOL_PREPARED", { prepared: true }).payload.status, "verified");
assert.equal(send("TOOL_CHANGE", {}).payload.status, "verified");
assert.equal(send("TOOL_CHANGED", { changed: true }).payload.status, "verified");
const toolTouch = send("TOOL_TOUCH_OFF", { axis: "Z", value: 3, system: "G54", units: "mm" });
assert.equal(toolTouch.kind, "ERROR");
assert.equal(toolTouch.payload.gate_reason, "blocked_by_abi_tool_offsets");
const malformed = send("TOUCH_OFF", { axis: "Q", value: 0, system: "G54", units: "mm" });
assert.equal(malformed.kind, "ERROR");
assert.equal(malformed.payload.code, "touch_off_axis_invalid");
console.log(JSON.stringify({ schema: "xyzac-touch-off-source-flow.v1", status: "verified", source_block: systemTouch.payload.touch_off.source_block, wasm_block: systemTouch.payload.touch_off.block, tool_touch_off: toolTouch.payload.gate_reason, invalid: malformed.payload.code }));

View File

@@ -78,6 +78,7 @@ export class WasmProgramRuntime {
this.executionLines = [];
this.executionLineMetadata = [];
this.rapidVelocity = 35;
this.synchronizedPosition = Object.fromEntries(AXES.map((axis) => [axis, 0]));
if (!this.available || typeof core?._malloc !== "function") return;
this.pointer = core._malloc(call(core, "sim_program_size", "number", [], []));
this.motionPointer = core._malloc(SEGMENT_BYTES);
@@ -137,6 +138,8 @@ export class WasmProgramRuntime {
try {
const loaded = temporary.load(this.source, this.path);
if (loaded.status !== "verified") return loaded;
const synchronized = temporary.synchronizePosition(this.synchronizedPosition);
if (synchronized.status !== "verified") return synchronized;
temporary.setRapidVelocity(this.rapidVelocity);
for (const subroutine of this.subroutines) {
const added = temporary.addSubroutine(subroutine.name, subroutine.source, subroutine.path);
@@ -158,6 +161,15 @@ export class WasmProgramRuntime {
}
start() { return this.invoke("sim_program_start", [], "program_start"); }
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" };
this.core.HEAPF64.set(AXES.map((axis) => position[axis]), this.samplePosePointer / 8);
const result = call(this.core, "sim_program_set_position", "number", ["number", "number"], [this.pointer, this.samplePosePointer]);
if (result !== 0) return programError("program_set_position", result);
this.synchronizedPosition = position;
return { status: "verified", command: "sim_program_set_position", position: { ...position } };
}
setRapidVelocity(velocity) {
if (!Number.isFinite(Number(velocity)) || Number(velocity) <= 0) return { status: "error", code: "program_rapid_velocity_invalid" };
this.rapidVelocity = Number(velocity);
@@ -200,6 +212,8 @@ export class WasmProgramRuntime {
const subroutines = [...this.subroutines];
const loaded = this.load(this.source, this.path);
if (loaded.status !== "verified") throw new Error(loaded.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) {
const added = this.addSubroutine(subroutine.name, subroutine.source);
if (added.status !== "verified") throw new Error(added.code);
@@ -243,6 +257,15 @@ export class WasmProgramRuntime {
const result = call(this.core, "sim_program_read_motion", "number", ["number", "number"], [this.pointer, this.motionPointer]);
if (result !== 0) return null;
const value = segment(this.core, this.motionPointer);
// LinuxCNC does not enqueue a trajectory segment when a linear/rapid
// block resolves to the current synchronized pose. This occurs naturally
// when AUTO_RUN reopens a completed/aborted file and its first positioning
// block names the position where the previous run stopped.
const linearNoop = value.motion_type === 1 && AXES.every((axis) => Math.abs(Number(value.start?.[axis] ?? 0) - Number(value.end?.[axis] ?? 0)) <= 1e-12);
if (linearNoop) {
call(this.core, "sim_program_clear_motion", "number", ["number"], [this.pointer]);
return null;
}
// LinuxCNC rapid blocks do not carry an F word. The trajectory queue
// requires a positive velocity, so use the simulation's configured rapid
// rate for that WASM-produced linear segment.

View File

@@ -139,6 +139,7 @@ export class WasmXyzacIo {
command(kind, payload = {}) {
if (kind === "RESET") return this.invoke("sim_io_reset");
if (kind === "SPINDLE_CONSTANT") return { status: "verified", command: "SPINDLE_CONSTANT", source: "linuxcnc_spindle_constant" };
if (kind === "SPINDLE_COMMAND") {
if (payload.delta !== undefined) return this.invoke("sim_io_spindle_adjust", [payload.spindle ?? 0, Number(payload.delta)]);
const requestedRpm = Number(payload.rpm ?? payload.speed ?? 0);

View File

@@ -34,7 +34,11 @@ export class XyzacSession extends SimSession {
// every previous response inside the stored response object.
const payload = { ...response.payload };
payload.xyzac_session = this.authority();
payload.xyzac_ledger = this.ledger.audit({ includeResponses: input.kind !== "SIM_STEP", maxEntries: input.kind === "SIM_STEP" ? 32 : 2048 });
// The operation ledger remains complete inside the worker for recovery.
// Browser responses need only an audit summary: including historical
// PROGRAM_LOAD responses repeatedly clones the full toolpath after every
// control click, which can starve an abort behind a large G-code preview.
payload.xyzac_ledger = this.ledger.audit({ includeResponses: false, maxEntries: input.kind === "SIM_STEP" ? 32 : 128 });
payload.xyzac_scheduler = this.scheduler.export();
return { ...response, payload };
}