Complete 3axis tutorial native and Web/WASM parity tools
This commit is contained in:
@@ -124,18 +124,16 @@ export class AuthoritativeRuntime {
|
||||
this.state.planner.queue_depth = 0;
|
||||
return { status: "verified", command: "AUTHORITATIVE_ABORT" };
|
||||
}
|
||||
if (this.state.program_status === "completed") {
|
||||
this.state.status = "completed";
|
||||
return { status: "verified", command: "AUTHORITATIVE_COMPLETE", queue_depth: this.state.planner.queue_depth };
|
||||
}
|
||||
const planner = this.state.planner;
|
||||
const last = planner.queue.at(-1)?.end ?? (planner.active_id === null ? this.state.kinematics.pose : null);
|
||||
const last = planner.queue.at(-1)?.end ?? planner.active?.end ?? (planner.active_id === null ? this.state.kinematics.pose : null);
|
||||
if (!samePose(target, last)) {
|
||||
const id = planner.next_id++;
|
||||
planner.queue.push({ id, source_line: program.line ?? null, start: clone(this.state.kinematics.pose), end: target });
|
||||
}
|
||||
planner.queue_depth = planner.queue.length + (planner.active_id === null ? 0 : 1);
|
||||
this.state.status = this.state.program_status === "completed" ? "completed" : "running";
|
||||
// M2 marks the interpreter complete, but AXIS remains visibly active until
|
||||
// the final queued motion reaches its endpoint.
|
||||
this.state.status = this.state.program_status === "completed" && planner.queue_depth === 0 ? "completed" : "running";
|
||||
return { status: "verified", command: "AUTHORITATIVE_SYNC", queue_depth: planner.queue_depth };
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,10 @@ export const LIMITS = Object.freeze({
|
||||
|
||||
export const COMMANDS = Object.freeze([
|
||||
"SESSION_ATTACH", "LOAD_WORKSPACE", "COMMAND", "SIM_STEP", "SNAPSHOT_REQUEST",
|
||||
"PROGRAM_LOAD", "PROGRAM_RUN", "PROGRAM_PAUSE", "PROGRAM_RESUME", "PROGRAM_ABORT",
|
||||
"PROGRAM_LOAD", "PROGRAM_RUN", "PROGRAM_PAUSE", "PROGRAM_RESUME", "PROGRAM_ABORT", "PROGRAM_RESTART",
|
||||
"PROGRAM_STEP", "PROGRAM_SEEK", "MDI_EXECUTE", "CHECKPOINT", "RECOVER", "DESTROY",
|
||||
"SET_MODE", "SET_OVERRIDE", "JOG_START", "JOG_STOP", "HOME", "ESTOP", "IO_RECOVER", "LIMIT_SET", "FOLLOWING_ERROR",
|
||||
"SPINDLE_COMMAND", "SPINDLE_FAULT", "SPINDLE_INHIBIT", "SPINDLE_ORIENT", "SPINDLE_ORIENTED", "TOOL_PREPARE", "TOOL_PREPARED", "TOOL_CHANGE", "TOOL_CHANGED", "TOOL_ABORT", "TOOL_FAULT", "TOOL_CLEAR_FAULT", "PROBE_CONFIG",
|
||||
"ENCODER_SET", "COOLANT_COMMAND", "COOLANT_FAULT", "DOOR_SET", "INTERLOCK_SET", "INTERLOCK_RESET", "DEVICE_ESTOP", "DEVICE_ACK_ISSUE", "DEVICE_ACK", "DEVICE_ACK_ABORT",
|
||||
"CONFIGURATION_BATCH",
|
||||
]);
|
||||
|
||||
@@ -23,6 +23,7 @@ export class MotionState {
|
||||
feed_override: 1,
|
||||
spindle_override: 1,
|
||||
homed: false,
|
||||
homed_axes: [false, false, false],
|
||||
limits: { x: false, y: false, z: false },
|
||||
following_error: 0,
|
||||
jog: null,
|
||||
@@ -42,8 +43,14 @@ export class MotionState {
|
||||
if (this.state.estop) return this.fail("motion_estop_active");
|
||||
if (target !== null && !axis(target)) return this.fail("motion_axis_invalid");
|
||||
const targets = target === null ? AXES : [axis(target)];
|
||||
for (const name of targets) { this.state.axes[name] = 0; this.state.joints[AXES.indexOf(name)] = 0; this.state.limits[name] = false; }
|
||||
this.state.homed = true;
|
||||
for (const name of targets) {
|
||||
const index = AXES.indexOf(name);
|
||||
this.state.axes[name] = 0;
|
||||
this.state.joints[index] = 0;
|
||||
this.state.homed_axes[index] = true;
|
||||
this.state.limits[name] = false;
|
||||
}
|
||||
this.state.homed = this.state.homed_axes.every(Boolean);
|
||||
this.state.fault = null;
|
||||
this.state.following_error = 0;
|
||||
this.state.tick += 1;
|
||||
@@ -57,13 +64,14 @@ export class MotionState {
|
||||
return this.ok("SET_OVERRIDE");
|
||||
}
|
||||
|
||||
startJog({ axis: requestedAxis, direction = 1, speed = 1 } = {}) {
|
||||
startJog({ axis: requestedAxis, direction = 1, speed = 1, increment = null } = {}) {
|
||||
const name = axis(requestedAxis);
|
||||
if (!name) return this.fail("motion_axis_invalid");
|
||||
if (!this.state.homed) return this.fail("motion_unhomed");
|
||||
if (this.state.estop || this.state.fault) return this.fail("motion_faulted");
|
||||
if (!Number.isSafeInteger(direction) || ![-1, 1].includes(direction) || !finite(speed) || speed <= 0 || speed > MAX_SPEED) return this.fail("motion_jog_range");
|
||||
this.state.jog = { axis: name, direction, speed };
|
||||
const jogIncrement = finite(increment) && increment > 0 ? Number(increment) : null;
|
||||
this.state.jog = { axis: name, direction, speed, ...(jogIncrement === null ? {} : { increment: jogIncrement }) };
|
||||
this.state.velocity = speed * this.state.feed_override;
|
||||
return this.ok("JOG_START");
|
||||
}
|
||||
@@ -91,6 +99,16 @@ export class MotionState {
|
||||
return this.ok("FOLLOWING_ERROR");
|
||||
}
|
||||
|
||||
resetFault() {
|
||||
if (this.state.estop) return this.fail("motion_estop_active");
|
||||
if (Object.values(this.state.limits).some(Boolean)) return this.fail("motion_limit_active");
|
||||
this.state.fault = null;
|
||||
this.state.following_error = 0;
|
||||
this.state.velocity = 0;
|
||||
this.state.jog = null;
|
||||
return this.ok("RESET");
|
||||
}
|
||||
|
||||
estop(active) {
|
||||
if (typeof active !== "boolean") return this.fail("motion_estop_argument");
|
||||
this.state.estop = active;
|
||||
@@ -105,12 +123,27 @@ export class MotionState {
|
||||
if (!this.state.jog || this.state.estop || this.state.fault) { this.state.velocity = 0; return this.export(); }
|
||||
const { axis: name, direction, speed } = this.state.jog;
|
||||
if (this.state.limits[name]) { this.state.fault = "limit"; this.state.jog = null; this.state.velocity = 0; return this.export(); }
|
||||
const delta = direction * speed * this.state.feed_override * CYCLE_DISTANCE;
|
||||
const requested = speed * this.state.feed_override * CYCLE_DISTANCE;
|
||||
const increment = this.state.jog.increment == null ? null : this.state.jog.increment;
|
||||
const delta = direction * (increment === null ? requested : Math.min(requested, increment));
|
||||
const next = this.state.axes[name] + delta;
|
||||
if (!finite(next) || Math.abs(next) > MAX_POSITION) { this.state.fault = "position_range"; this.state.jog = null; this.state.velocity = 0; return this.export(); }
|
||||
this.state.axes[name] = next;
|
||||
this.state.joints[AXES.indexOf(name)] = next;
|
||||
this.state.velocity = Math.abs(speed * this.state.feed_override);
|
||||
if (increment !== null) {
|
||||
this.state.jog.increment = increment - Math.abs(delta);
|
||||
if (this.state.jog.increment <= 1e-12) { this.state.jog = null; this.state.velocity = 0; }
|
||||
}
|
||||
return this.export();
|
||||
}
|
||||
|
||||
applyWasmPosition(position, velocity = 0, tick = this.state.tick) {
|
||||
if (!position || ![position.x, position.y, position.z].every(finite)) return this.fail("motion_wasm_position_invalid");
|
||||
this.state.axes = { x: position.x, y: position.y, z: position.z };
|
||||
this.state.joints = [position.x, position.y, position.z];
|
||||
this.state.velocity = finite(velocity) ? velocity : 0;
|
||||
this.state.tick = Math.max(this.state.tick, Number.isSafeInteger(tick) ? tick : this.state.tick);
|
||||
return this.export();
|
||||
}
|
||||
|
||||
@@ -119,7 +152,11 @@ export class MotionState {
|
||||
const unsigned = { ...value }; delete unsigned.state_hash;
|
||||
if (value.state_hash !== fnv1a64(unsigned)) throw new Error("motion_restore_hash");
|
||||
if (!finite(value.tick) || !value.axes || !Array.isArray(value.joints) || value.joints.length !== 3) throw new Error("motion_restore_shape");
|
||||
this.state = clone(unsigned);
|
||||
const homedAxes = Array.isArray(value.homed_axes)
|
||||
? value.homed_axes.slice(0, AXES.length).map(Boolean)
|
||||
: AXES.map(() => value.homed === true);
|
||||
if (homedAxes.length !== AXES.length) throw new Error("motion_restore_homed_shape");
|
||||
this.state = { ...clone(unsigned), homed_axes: homedAxes, homed: homedAxes.every(Boolean) };
|
||||
return this.export();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,39 @@ function readInterpreterState(core, pointer) {
|
||||
};
|
||||
}
|
||||
|
||||
function readInterpreterMotion(core, interpreter, sourceLine = null) {
|
||||
if (typeof core._sim_interpreter_read_motion !== "function" || typeof core._sim_interpreter_motion_size !== "function") return null;
|
||||
const size = core._sim_interpreter_motion_size();
|
||||
if (!Number.isSafeInteger(size) || size < 216 || size > 4096) throw new Error("wasm_motion_size_invalid");
|
||||
const pointer = core._malloc(size);
|
||||
if (!pointer) throw new Error("wasm_motion_allocation_failed");
|
||||
try {
|
||||
const result = call(core, "sim_interpreter_read_motion", "number", ["number", "number"], [interpreter, pointer]);
|
||||
if (result !== 0) return null;
|
||||
const doubles = core.HEAPF64.subarray(pointer / 8, pointer / 8 + 26);
|
||||
const ints = core.HEAP32.subarray(pointer / 4 + 52, pointer / 4 + 54);
|
||||
const pose = (offset) => ({
|
||||
x: doubles[offset], y: doubles[offset + 1], z: doubles[offset + 2],
|
||||
a: doubles[offset + 3], b: doubles[offset + 4], c: doubles[offset + 5],
|
||||
u: doubles[offset + 6], v: doubles[offset + 7], w: doubles[offset + 8],
|
||||
});
|
||||
return {
|
||||
source_line: sourceLine,
|
||||
start: pose(0),
|
||||
end: pose(9),
|
||||
center: { x: doubles[18], y: doubles[19], z: doubles[20] },
|
||||
normal: { x: doubles[21], y: doubles[22], z: doubles[23] },
|
||||
velocity: doubles[24],
|
||||
acceleration: doubles[25],
|
||||
motion_type: ints[0],
|
||||
turn: ints[1],
|
||||
source: "wasm_interpreter_canonical_motion",
|
||||
};
|
||||
} finally {
|
||||
core._free(pointer);
|
||||
}
|
||||
}
|
||||
|
||||
function readCanonical(core, interpreter) {
|
||||
if (typeof core._sim_interpreter_canonical !== "function") return null;
|
||||
const required = call(core, "sim_interpreter_canonical", "number", ["number", "number", "number"], [interpreter, 0, 0]);
|
||||
@@ -328,6 +361,32 @@ export class ProgramRunner {
|
||||
return this.snapshot();
|
||||
}
|
||||
|
||||
preview() {
|
||||
if (!this.loaded) return { status: "error", code: "program_not_loaded" };
|
||||
const source = this.source;
|
||||
const path = this.path;
|
||||
const segments = [];
|
||||
let result = { status: "verified" };
|
||||
try {
|
||||
for (let steps = 0; steps < MAX_EXPANDED_LINES && this.programCounter < this.lines.length; steps += 1) {
|
||||
result = this.executeNext(true);
|
||||
if (result.status !== "verified") break;
|
||||
if (result.motion) {
|
||||
const motion = result.motion;
|
||||
const linearNoop = motion.motion_type === 1
|
||||
&& ["x", "y", "z", "a", "b", "c", "u", "v", "w"].every((axis) => motion.start[axis] === motion.end[axis]);
|
||||
if (!linearNoop) segments.push(motion);
|
||||
}
|
||||
}
|
||||
if (result.status !== "verified") return { ...result, preview: { status: "error", segments } };
|
||||
return { status: "verified", preview: { status: "verified", segments } };
|
||||
} catch (error) {
|
||||
return { status: "error", code: error.message, preview: { status: "error", segments } };
|
||||
} finally {
|
||||
this.load(source, path);
|
||||
}
|
||||
}
|
||||
|
||||
pause() {
|
||||
if (this.status !== "running") return { status: "error", code: "program_not_running" };
|
||||
this.status = "paused";
|
||||
@@ -398,7 +457,7 @@ export class ProgramRunner {
|
||||
if (stateResult !== 0) return { status: "error", code: "mdi_state_read", result: stateResult };
|
||||
this.state = readInterpreterState(this.core, this.stateBuffer);
|
||||
const interpreterState = cloneState(this.state);
|
||||
return { status: "verified", block, state: interpreterState, interpreter_state: interpreterState, runner: this.snapshot() };
|
||||
return { status: "verified", block, state: interpreterState, interpreter_state: interpreterState, motion: readInterpreterMotion(this.core, this.interpreter, this.state.line), runner: this.snapshot() };
|
||||
} catch (error) {
|
||||
return { status: "error", code: error.message };
|
||||
}
|
||||
@@ -588,7 +647,7 @@ export class ProgramRunner {
|
||||
}
|
||||
if (this.state.program_end) this.status = "completed";
|
||||
else if (this.programCounter >= this.lines.length && !seeking) this.status = "completed";
|
||||
return { status: "verified", line: index + 1, text: sourceLine, state: cloneState(this.state), program_status: this.status };
|
||||
return { status: "verified", line: index + 1, text: sourceLine, state: cloneState(this.state), program_status: this.status, motion: readInterpreterMotion(this.core, this.interpreter, index + 1) };
|
||||
}
|
||||
this.status = "completed";
|
||||
return { status: "verified", line: this.currentLine, text: null, state: cloneState(this.state), program_status: this.status };
|
||||
@@ -618,6 +677,11 @@ export class ProgramRunner {
|
||||
this.lastError = this.lastError ?? { code: error.message };
|
||||
}
|
||||
}
|
||||
const unitsCode = this.state.units === 20 ? "G20" : "G21";
|
||||
const distanceCode = this.state.distance_mode === 91 ? "G91" : "G90";
|
||||
const planeCode = this.state.plane === 18 ? "G18" : this.state.plane === 19 ? "G19" : "G17";
|
||||
const motionCode = `G${this.state.motion_mode}`;
|
||||
const spindleCode = this.state.spindle_mode === 5 ? "M5" : `M${this.state.spindle_mode}`;
|
||||
return {
|
||||
status: "verified",
|
||||
program_status: this.status,
|
||||
@@ -632,6 +696,7 @@ export class ProgramRunner {
|
||||
modal_hash,
|
||||
motion_control: { ...this.motionControl },
|
||||
motion_control_hash: fnv1a64(this.motionControl),
|
||||
active_gcodes: `${unitsCode} ${distanceCode} ${planeCode} ${motionCode} ${spindleCode}`,
|
||||
error_column,
|
||||
error_code,
|
||||
error: this.lastError,
|
||||
|
||||
@@ -21,6 +21,9 @@ 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 { WasmMotionQueue } from "./wasm-motion-queue.mjs";
|
||||
import { WasmHomeLimit } from "./wasm-home-limit.mjs";
|
||||
import { axisGate } from "../app/axis-contract.mjs";
|
||||
|
||||
const PROTOCOL_SCHEMA = 1;
|
||||
const CONTEXT_ID = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1";
|
||||
@@ -40,6 +43,13 @@ const OPERATION_KINDS = new Set([
|
||||
"REMAP_REGISTER", "REMAP_CALL", "VCP_REGISTER", "VCP_UPDATE", "FILTER_EXEC",
|
||||
]);
|
||||
|
||||
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", HOME: "home", SET_OVERRIDE: "override",
|
||||
ESTOP: "estop", IO_RECOVER: "recover",
|
||||
});
|
||||
|
||||
function text(value) {
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
@@ -90,10 +100,14 @@ export class SimSession {
|
||||
this.python = new PythonBridge();
|
||||
this.program = new ProgramRunner(core);
|
||||
this.authoritative = new AuthoritativeRuntime();
|
||||
this.wasmMotion = new WasmMotionQueue(core);
|
||||
this.wasmHomeLimit = new WasmHomeLimit(core);
|
||||
this.motionAuthority = "js";
|
||||
this.task = new SimTask(core, { contextId, sessionId: sessionId || null });
|
||||
this.scheduler = new SimScheduler(core);
|
||||
this.configurationState = null;
|
||||
this.halState = null;
|
||||
this.previewState = null;
|
||||
call(core, "sim_protocol_reset");
|
||||
if (typeof core.ccall === "function") core.ccall("sim_event_reset", null, [], []);
|
||||
}
|
||||
@@ -102,6 +116,7 @@ export class SimSession {
|
||||
const statePayload = payload && typeof payload === "object" ? payload : {};
|
||||
const program = statePayload.program ?? (this.program.loaded ? this.program.snapshot() : {});
|
||||
const task = statePayload.task ?? this.task.export();
|
||||
const panelSnapshot = this.panelSnapshot({ task, program, input, tick });
|
||||
return {
|
||||
schema_version: PROTOCOL_SCHEMA,
|
||||
kind,
|
||||
@@ -110,10 +125,70 @@ export class SimSession {
|
||||
seq: input.seq,
|
||||
...(text(input.operation_id) ? { operation_id: input.operation_id } : {}),
|
||||
simulation_tick: tick,
|
||||
payload: { ...statePayload, axis_state: axisState({ task, program, action: input.kind, payload: input.payload ?? {} }) },
|
||||
payload: {
|
||||
...statePayload,
|
||||
panel_snapshot: panelSnapshot,
|
||||
motion_authority: statePayload.motion_authority ?? this.motionAuthority,
|
||||
task_authority: statePayload.task_authority ?? this.task.authority(),
|
||||
wasm_home_limit: statePayload.wasm_home_limit ?? this.wasmHomeLimit.read(),
|
||||
axis_state: axisState({ task, program, action: input.kind, payload: input.payload ?? {} }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
panelSnapshot({ task = this.task.export(), program = this.program.loaded ? this.program.snapshot() : null, input = {}, tick = this.tick } = {}) {
|
||||
const motion = this.motion.export();
|
||||
const wasmMotion = this.wasmMotion.read();
|
||||
const homeLimit = this.wasmHomeLimit.read();
|
||||
const devices = this.devices.export();
|
||||
const deviceIo = this.deviceIo.export();
|
||||
const halRuntime = this.hal.export();
|
||||
const authoritative = this.authoritative.export();
|
||||
const axis = axisState({ task, program: program ?? {}, action: input.kind, payload: input.payload ?? {} });
|
||||
const gateInput = { task, program: program ?? {}, motion, devices, taskfile: Boolean(this.program.loaded), kinematicsIdentity: this.motionAuthority === "wasm" };
|
||||
const gateActions = ["load", "run", "step", "pause", "resume", "abort", "restart", "mdi", "jogStart", "jogStop", "home", "estop", "recover", "override", "reset", "spindle", "coolant"];
|
||||
const gates = Object.fromEntries(gateActions.map((action) => [action, axisGate(action, gateInput)]));
|
||||
const position = this.motionAuthority === "wasm" && wasmMotion.status === "verified"
|
||||
? wasmMotion.state.position
|
||||
: authoritative.kinematics.pose;
|
||||
const joints = this.motionAuthority === "wasm" && wasmMotion.status === "verified"
|
||||
? [position.x, position.y, position.z]
|
||||
: authoritative.kinematics.joints;
|
||||
const preview = this.previewState?.status === "verified" ? this.previewState : null;
|
||||
const visual = {
|
||||
schema: "axis-visual.v1",
|
||||
pose: { x: position.x, y: position.y, z: position.z },
|
||||
joints: Array.isArray(joints) ? [...joints] : [],
|
||||
toolpath: (preview?.segments ?? []).map((segment, index) => ({ id: index + 1, ...segment })),
|
||||
active_segment_id: wasmMotion.status === "verified" ? wasmMotion.state.active_id : authoritative.planner.active_id,
|
||||
source: "wasm_canonical_motion",
|
||||
};
|
||||
const unsigned = {
|
||||
schema: "axis-panel-snapshot.v1",
|
||||
context_id: this.contextId,
|
||||
session_id: this.sessionId,
|
||||
simulation_tick: tick,
|
||||
task,
|
||||
program,
|
||||
motion,
|
||||
coordinates: { machine: [position.x, position.y, position.z], relative: [position.x, position.y, position.z], commanded: [position.x, position.y, position.z] },
|
||||
overrides: { feed: motion.feed_override, spindle: [motion.spindle_override] },
|
||||
wasm_motion: wasmMotion,
|
||||
home_limit: homeLimit,
|
||||
hal: { configuration: this.halState, runtime: halRuntime },
|
||||
devices,
|
||||
device_io: deviceIo,
|
||||
configuration: this.configurationState,
|
||||
axis_state: axis,
|
||||
gates,
|
||||
visual,
|
||||
authoritative,
|
||||
motion_authority: this.motionAuthority,
|
||||
task_authority: this.task.authority(),
|
||||
};
|
||||
return { ...unsigned, state_hash: fnv1a64(unsigned) };
|
||||
}
|
||||
|
||||
error(input, code, messageKey, retryable = false) {
|
||||
return this.envelope("ERROR", input, {
|
||||
status: "error",
|
||||
@@ -135,6 +210,23 @@ export class SimSession {
|
||||
return this.operations.get(input.operation_id) ?? null;
|
||||
}
|
||||
|
||||
commandGate(input) {
|
||||
const action = PANEL_GATE_ACTIONS[input.kind];
|
||||
if (!action) return { action: null, enabled: true, reason: "axis_internal_command" };
|
||||
const task = this.task.export();
|
||||
const program = this.program.loaded ? this.program.snapshot() : {};
|
||||
const motion = this.motion.export();
|
||||
const devices = this.devices.export();
|
||||
return axisGate(action, {
|
||||
task,
|
||||
program,
|
||||
motion,
|
||||
devices,
|
||||
taskfile: Boolean(this.program.loaded),
|
||||
kinematicsIdentity: this.motionAuthority === "wasm",
|
||||
});
|
||||
}
|
||||
|
||||
stateForCheckpoint() {
|
||||
const state = {
|
||||
context_id: this.contextId,
|
||||
@@ -153,6 +245,11 @@ export class SimSession {
|
||||
configuration: this.configurationState,
|
||||
hal: this.halState,
|
||||
python: this.python.exportState(),
|
||||
motion_authority: this.motionAuthority,
|
||||
wasm_motion: this.wasmMotion.exportState(),
|
||||
task_authority: this.task.authority(),
|
||||
wasm_home_limit: this.wasmHomeLimit.exportState(),
|
||||
preview: this.previewState,
|
||||
task: this.task.export(),
|
||||
scheduler: this.scheduler.export(),
|
||||
authoritative: this.authoritative.export(),
|
||||
@@ -182,6 +279,10 @@ export class SimSession {
|
||||
task: this.task.export(),
|
||||
scheduler: this.scheduler.export(),
|
||||
authoritative: this.authoritative.export(),
|
||||
motion_authority: this.motionAuthority,
|
||||
wasm_motion: this.wasmMotion.exportState(),
|
||||
task_authority: this.task.authority(),
|
||||
wasm_home_limit: this.wasmHomeLimit.exportState(),
|
||||
};
|
||||
if (this.program.loaded) value.program = this.program.exportState();
|
||||
return { ...value, state_hash: fnv1a64(value) };
|
||||
@@ -221,7 +322,17 @@ export class SimSession {
|
||||
if (trace.state?.task) this.task.restore(trace.state.task);
|
||||
if (trace.state?.scheduler) this.scheduler.restore(trace.state.scheduler);
|
||||
if (trace.state?.authoritative) this.authoritative.restore(trace.state.authoritative);
|
||||
if (trace.state?.motion_authority) this.motionAuthority = trace.state.motion_authority;
|
||||
if (trace.state?.wasm_motion) this.wasmMotion.restoreState(trace.state.wasm_motion);
|
||||
if (trace.state?.wasm_home_limit) this.wasmHomeLimit.restoreState(trace.state.wasm_home_limit);
|
||||
if (trace.state?.motion) this.motion.restore(trace.state.motion);
|
||||
if (trace.state?.program) {
|
||||
this.program = new ProgramRunner(this.core);
|
||||
this.program.restoreState(trace.state.program);
|
||||
}
|
||||
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 (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);
|
||||
@@ -235,6 +346,10 @@ export class SimSession {
|
||||
}
|
||||
|
||||
restoreCheckpoint(checkpoint, events = null, reconcile = undefined) {
|
||||
const replayMotionAuthority = checkpoint?.state?.motion_authority ?? this.motionAuthority;
|
||||
if (replayMotionAuthority === "wasm" && !checkpoint?.state?.wasm_motion) {
|
||||
return { schema: "simulation-recovery.v1", status: "blocked", context_id: this.contextId, checkpoint_hash: checkpoint?.checkpoint_hash ?? null, blocked: [{ reason: "wasm_motion_checkpoint_missing" }] };
|
||||
}
|
||||
const replayDevices = new VirtualDevices({ state: checkpoint?.state?.devices });
|
||||
const replayMotion = new MotionState();
|
||||
if (checkpoint?.state?.motion) replayMotion.restore(checkpoint.state.motion);
|
||||
@@ -256,6 +371,20 @@ export class SimSession {
|
||||
if (checkpoint?.state?.python) replayPython.restoreState(checkpoint.state.python);
|
||||
const replayAuthoritative = new AuthoritativeRuntime();
|
||||
if (checkpoint?.state?.authoritative) replayAuthoritative.restore(checkpoint.state.authoritative);
|
||||
let replayWasmMotion = null;
|
||||
let replayHomeLimit = null;
|
||||
try {
|
||||
if (replayMotionAuthority === "wasm") {
|
||||
replayWasmMotion = new WasmMotionQueue(this.core);
|
||||
replayWasmMotion.restoreState(checkpoint.state.wasm_motion);
|
||||
if (checkpoint.state.wasm_home_limit) {
|
||||
replayHomeLimit = new WasmHomeLimit(this.core);
|
||||
replayHomeLimit.restoreState(checkpoint.state.wasm_home_limit);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return { schema: "simulation-recovery.v1", status: "blocked", context_id: this.contextId, checkpoint_hash: checkpoint?.checkpoint_hash ?? null, blocked: [{ reason: String(error?.message ?? error) }] };
|
||||
}
|
||||
const result = recoverSimulation({
|
||||
checkpoint,
|
||||
contextId: this.contextId,
|
||||
@@ -263,7 +392,11 @@ export class SimSession {
|
||||
reconcile,
|
||||
apply: (state, event) => {
|
||||
if (DEVICE_COMMANDS.has(event.kind)) replayDevices.applyEvent(event.kind, event.payload);
|
||||
if (event.kind === "HOME") replayMotion.home(event.payload?.axis ?? null);
|
||||
if (event.kind === "HOME") {
|
||||
replayMotion.home(event.payload?.axis ?? null);
|
||||
const home = replayHomeLimit?.home(event.seq);
|
||||
if (home?.status === "error") throw new Error(home.code);
|
||||
}
|
||||
if (event.kind === "JOG_START") replayMotion.startJog(event.payload);
|
||||
if (event.kind === "JOG_STOP") replayMotion.stopJog();
|
||||
if (event.kind === "SET_OVERRIDE") replayMotion.setOverride(event.payload?.feed ?? event.payload?.feed_override, event.payload?.spindle ?? event.payload?.spindle_override);
|
||||
@@ -273,23 +406,61 @@ export class SimSession {
|
||||
if (event.kind === "IO_RECOVER") replayDeviceIo.interlockReset();
|
||||
if (event.kind === "SIM_STEP") replayMotion.step(event.tick);
|
||||
if (event.kind === "PROGRAM_LOAD") replayProgram.load(event.payload?.source ?? event.payload?.text ?? event.payload?.program_source, event.payload?.path ?? null);
|
||||
if (event.kind === "PROGRAM_RUN") replayProgram.start();
|
||||
if (event.kind === "PROGRAM_PAUSE") replayProgram.pause();
|
||||
if (event.kind === "PROGRAM_RESUME") replayProgram.resume();
|
||||
if (event.kind === "PROGRAM_ABORT") replayProgram.abort();
|
||||
if (event.kind === "PROGRAM_RESTART") replayProgram.restart();
|
||||
if (event.kind === "PROGRAM_RUN") { replayProgram.start(); replayWasmMotion?.setPaused(false); }
|
||||
if (event.kind === "PROGRAM_PAUSE") { replayProgram.pause(); replayWasmMotion?.setPaused(true); }
|
||||
if (event.kind === "PROGRAM_RESUME") { replayProgram.resume(); replayWasmMotion?.setPaused(false); }
|
||||
if (event.kind === "PROGRAM_ABORT") { replayProgram.abort(); replayWasmMotion?.abort(); }
|
||||
if (event.kind === "PROGRAM_RESTART") { replayProgram.restart(); replayWasmMotion?.reset(); }
|
||||
if (event.kind === "SIM_STEP" && replayProgram.loaded) {
|
||||
if (replayProgram.status === "paused" && state.task?.program_status === "running") replayProgram.resume();
|
||||
for (let replayTick = replayAuthoritative.state.tick + 1; replayTick <= event.tick; replayTick += 1) {
|
||||
replayProgram.tick(1);
|
||||
const synced = replayAuthoritative.syncProgram(replayProgram.snapshot());
|
||||
if (synced.status === "blocked") throw new Error(synced.code);
|
||||
const stepped = replayAuthoritative.step(replayTick);
|
||||
if (stepped.status === "error") throw new Error(stepped.code);
|
||||
// PROGRAM_LOAD resets the planner clock, while the session clock is
|
||||
// monotonic. Recovery must start this replay at the checkpoint tick
|
||||
// or one logical step would consume the whole historical interval.
|
||||
if (replayMotionAuthority === "wasm") {
|
||||
if (!replayWasmMotion) throw new Error("wasm_motion_replay_unavailable");
|
||||
const queueTick = replayWasmMotion.read().state.tick;
|
||||
if (replayProgram.status === "paused" && state.task?.program_status === "running") replayProgram.resume();
|
||||
const cycles = Number.isSafeInteger(event.payload?.cycles) && event.payload.cycles >= 0 ? event.payload.cycles : 1;
|
||||
for (let cycle = 0; cycle < cycles; cycle += 1) {
|
||||
const replayTick = queueTick + cycle + 1;
|
||||
const steps = replayProgram.tick(1);
|
||||
for (const step of steps) {
|
||||
if (!step.motion) continue;
|
||||
const linearNoop = step.motion.motion_type === 1 && ["x", "y", "z", "a", "b", "c", "u", "v", "w"].every((axis) => step.motion.start[axis] === step.motion.end[axis]);
|
||||
if (!linearNoop) {
|
||||
const enqueue = replayWasmMotion.enqueue(step.motion);
|
||||
if (enqueue.status === "error") throw new Error(enqueue.code);
|
||||
}
|
||||
}
|
||||
const stepped = replayWasmMotion.step();
|
||||
if (stepped.status === "error" && stepped.code !== "wasm_motion_step_-605") throw new Error(stepped.code);
|
||||
if (stepped.status === "verified") {
|
||||
const projected = replayMotion.applyWasmPosition(stepped.state.position, stepped.state.velocity, replayTick);
|
||||
if (projected.status === "error") throw new Error(projected.code);
|
||||
if (replayHomeLimit?.read().state.homed) {
|
||||
const checked = replayHomeLimit.check([stepped.state.position.x, stepped.state.position.y, stepped.state.position.z]);
|
||||
if (checked.status === "error") throw new Error(checked.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (Number.isSafeInteger(state.tick) && replayAuthoritative.state.tick < state.tick) replayAuthoritative.state.tick = state.tick;
|
||||
if (replayProgram.status === "paused" && state.task?.program_status === "running") replayProgram.resume();
|
||||
for (let replayTick = replayAuthoritative.state.tick + 1; replayTick <= event.tick; replayTick += 1) {
|
||||
replayProgram.tick(1);
|
||||
const synced = replayAuthoritative.syncProgram(replayProgram.snapshot());
|
||||
if (synced.status === "blocked") throw new Error(synced.code);
|
||||
const stepped = replayAuthoritative.step(replayTick);
|
||||
if (stepped.status === "error") throw new Error(stepped.code);
|
||||
}
|
||||
}
|
||||
} else if (event.kind === "SIM_STEP") {
|
||||
const stepped = replayAuthoritative.step(event.tick);
|
||||
if (stepped.status === "error") throw new Error(stepped.code);
|
||||
if (replayMotionAuthority === "wasm") {
|
||||
const stepped = replayWasmMotion?.step();
|
||||
if (stepped?.status === "error" && stepped.code !== "wasm_motion_step_-605") throw new Error(stepped.code);
|
||||
} else {
|
||||
const stepped = replayAuthoritative.step(event.tick);
|
||||
if (stepped.status === "error") throw new Error(stepped.code);
|
||||
}
|
||||
}
|
||||
if (event.kind === "PLC_CONFIG") replayPlc.configure(event.payload);
|
||||
if (event.kind === "PLC_SET") replayPlc.setRegister(event.payload?.register, event.payload?.value);
|
||||
@@ -352,7 +523,7 @@ export class SimSession {
|
||||
const programStatus = replayProgram.status === "completed" ? "completed" : replayProgram.status === "running" ? "running" : replayProgram.status === "paused" ? "paused" : replayProgram.status === "aborted" ? "aborted" : "loaded";
|
||||
task.current_line = replayProgram.currentLine;
|
||||
task.program_status = programStatus;
|
||||
if (programStatus === "completed" && task.state === "RUNNING") task.state = "STOPPED";
|
||||
if (programStatus === "completed" && task.state === "RUNNING" && (replayMotionAuthority !== "wasm" || replayWasmMotion?.read().state.queue_depth === 0)) task.state = "STOPPED";
|
||||
}
|
||||
const schedulerUnsigned = {
|
||||
schema: "sim_scheduler.v1",
|
||||
@@ -365,11 +536,16 @@ export class SimSession {
|
||||
last_cycles: event.kind === "SIM_STEP" && Number.isSafeInteger(event.payload?.cycles) ? event.payload.cycles : (state.scheduler?.last_cycles ?? 0),
|
||||
state_hash: fnv1a64(schedulerUnsigned),
|
||||
};
|
||||
return { ...state, tick: event.tick, task: { ...task, state_hash: fnv1a64(task) }, scheduler, devices: replayDevices.export(), motion: replayMotion.export(), authoritative: replayAuthoritative.export(), plc: replayPlc.export(), hal_runtime: replayHal.export(), device_io: replayDeviceIo.export(), configuration_batch: replayConfigurationBatch.export(), io_ack: replayIoAck.export(), callbacks: replayCallbacks.export(), python: replayPython.exportState(), ...(replayProgram.loaded ? { program: replayProgram.exportState() } : {}) };
|
||||
return { ...state, tick: event.tick, motion_authority: replayMotionAuthority, task_authority: replayMotionAuthority === "wasm" ? "wasm_task_canonical" : "js_compat", task: { ...task, state_hash: fnv1a64(task) }, scheduler, devices: replayDevices.export(), motion: replayMotion.export(), ...(replayWasmMotion ? { wasm_motion: replayWasmMotion.exportState() } : {}), ...(replayHomeLimit ? { wasm_home_limit: replayHomeLimit.exportState() } : {}), authoritative: replayAuthoritative.export(), plc: replayPlc.export(), hal_runtime: replayHal.export(), device_io: replayDeviceIo.export(), configuration_batch: replayConfigurationBatch.export(), io_ack: replayIoAck.export(), callbacks: replayCallbacks.export(), python: replayPython.exportState(), ...(replayProgram.loaded ? { program: replayProgram.exportState() } : {}) };
|
||||
},
|
||||
});
|
||||
replayWasmMotion?.destroy();
|
||||
replayHomeLimit?.destroy();
|
||||
if (result.status === "recovered" || result.status === "aborted") {
|
||||
this.tick = result.simulation_tick;
|
||||
this.motionAuthority = result.state?.motion_authority ?? checkpoint?.state?.motion_authority ?? this.motionAuthority;
|
||||
if (this.motionAuthority === "wasm" && result.state?.wasm_motion) this.wasmMotion.restoreState(result.state.wasm_motion);
|
||||
if (this.motionAuthority === "wasm" && result.state?.wasm_home_limit) this.wasmHomeLimit.restoreState(result.state.wasm_home_limit);
|
||||
if (result.state?.task) this.task.restore(result.state.task);
|
||||
if (result.state?.scheduler) this.scheduler.restore(result.state.scheduler);
|
||||
this.authoritative = new AuthoritativeRuntime();
|
||||
@@ -377,7 +553,11 @@ export class SimSession {
|
||||
this.devices = new VirtualDevices({ state: result.state?.devices ?? checkpoint?.state?.devices });
|
||||
this.motion = new MotionState();
|
||||
if (result.state?.motion) this.motion.restore(result.state.motion);
|
||||
this.authoritative.applyMotion(this.motion);
|
||||
if (this.motionAuthority !== "wasm") this.authoritative.applyMotion(this.motion);
|
||||
if (this.motionAuthority === "wasm") {
|
||||
const wasmMotion = this.wasmMotion.read();
|
||||
if (wasmMotion.status !== "verified" || !result.state?.motion || fnv1a64(result.state.motion.axes) !== fnv1a64(wasmMotion.state.position)) throw new Error("wasm_motion_recovery_projection_mismatch");
|
||||
}
|
||||
this.plc = new PlcRuntime();
|
||||
if (result.state?.plc) this.plc.restore(result.state.plc);
|
||||
this.hal = new HalRuntime();
|
||||
@@ -392,6 +572,7 @@ export class SimSession {
|
||||
if (result.state?.callbacks) this.callbacks.restore(result.state.callbacks);
|
||||
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;
|
||||
this.python = new PythonBridge();
|
||||
if (result.state?.python) this.python.restoreState(result.state.python);
|
||||
this.program = new ProgramRunner(this.core);
|
||||
@@ -478,6 +659,28 @@ export class SimSession {
|
||||
if (text(input.operation_id) && OPERATION_KINDS.has(input.kind)) {
|
||||
this.operations.set(input.operation_id, { operation_id: input.operation_id, status: "executing", seq: input.seq, kind: input.kind, result_hash: null });
|
||||
}
|
||||
const gate = this.commandGate(input);
|
||||
const handlerOwnsFault = (input.kind === "JOG_START" || input.kind === "JOG_STOP") && Boolean(this.motion.state.fault);
|
||||
const restartAfterCompletion = input.kind === "PROGRAM_RESTART" && this.program.status === "completed";
|
||||
if (!gate.enabled && !handlerOwnsFault && !restartAfterCompletion) {
|
||||
if (text(input.operation_id)) this.operations.delete(input.operation_id);
|
||||
const rejectedPayload = { command: input.kind, gate_reason: gate.reason, payload: input.payload ?? {} };
|
||||
try {
|
||||
this.timeline.appendEvent(input.seq, this.tick, "COMMAND_REJECTED", rejectedPayload);
|
||||
const rejectedEvent = appendCoreEvent(this.core, input.seq, this.tick, "COMMAND_REJECTED", rejectedPayload);
|
||||
if (rejectedEvent !== 0) return this.error(input, "event_log", "core_event_append_failed");
|
||||
} catch (error) {
|
||||
return this.error(input, "event_log", String(error?.message ?? error));
|
||||
}
|
||||
return this.envelope("ERROR", input, {
|
||||
status: "blocked",
|
||||
code: "axis_gate",
|
||||
message_key: gate.reason,
|
||||
gate_reason: gate.reason,
|
||||
retryable: false,
|
||||
operation_id: input.operation_id ?? null,
|
||||
});
|
||||
}
|
||||
if (Number.isSafeInteger(input.simulation_tick) && input.simulation_tick >= this.tick) this.tick = input.simulation_tick;
|
||||
const eventTick = input.kind === "SIM_STEP" && Number.isSafeInteger(input.payload?.cycles)
|
||||
? this.tick + input.payload.cycles : this.tick;
|
||||
@@ -560,6 +763,19 @@ export class SimSession {
|
||||
}
|
||||
try { this.task.transition("READY"); } catch (error) { return finish(this.error(input, "task_state", String(error?.message ?? error)), "failed"); }
|
||||
return finish(this.envelope("SESSION_READY", input, { state_hash: this.task.hash(), task: this.task.export(), subscriptions: [] }), "verified");
|
||||
case "SET_MODE": {
|
||||
const mode = String(input.payload?.mode ?? "").toUpperCase();
|
||||
if (!["MANUAL", "MDI", "AUTO"].includes(mode)) return finish(this.error(input, "task_mode", "task_mode_invalid"), "failed");
|
||||
try { this.task.setMode(mode); } catch (error) { return finish(this.error(input, "task_mode", String(error?.message ?? error)), "failed"); }
|
||||
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, mode, task: this.task.export(), operation_id: input.operation_id ?? null }));
|
||||
}
|
||||
case "RESET": {
|
||||
if (["RUNNING", "PAUSED"].includes(this.task.state.state)) return finish(this.error(input, "task_state", "reset_while_program_active"), "failed");
|
||||
const result = this.motion.resetFault();
|
||||
if (result.status !== "verified") return finish(this.envelope("ERROR", input, { ...result, command: input.kind, task: this.task.export() }), "failed");
|
||||
if (this.task.state.state === "FAULT" || this.task.state.state === "STOPPED") this.task.recover();
|
||||
return finish(this.envelope("COMMAND_RESULT", input, { ...result, command: input.kind, task: this.task.export(), motion: this.motion.export(), operation_id: input.operation_id ?? null }));
|
||||
}
|
||||
case "LOAD_WORKSPACE": {
|
||||
const rootPath = input.payload?.root_path;
|
||||
if (!text(rootPath) || !rootPath.startsWith("/") || rootPath.includes("..")) return finish(this.error(input, "workspace_path", "invalid_workspace_path"), "failed");
|
||||
@@ -588,7 +804,7 @@ export class SimSession {
|
||||
startup: startupReport,
|
||||
}), "failed");
|
||||
}
|
||||
this.configurationState = startupReport;
|
||||
this.configurationState = startupReport;
|
||||
this.created = true;
|
||||
startupLoaded = true;
|
||||
} else {
|
||||
@@ -610,6 +826,11 @@ export class SimSession {
|
||||
};
|
||||
}
|
||||
}
|
||||
this.motionAuthority = configuration?.entrypoint?.includes("3axis-tutorial") ? "wasm" : "js";
|
||||
if (configuration?.home_limits && this.motionAuthority === "wasm") {
|
||||
this.wasmHomeLimit.destroy();
|
||||
this.wasmHomeLimit = new WasmHomeLimit(this.core, { limits: configuration.home_limits });
|
||||
}
|
||||
if (input.payload?.hal) {
|
||||
const preflight = preflightHalConfiguration(input.payload.hal);
|
||||
if (preflight.status !== "ready") {
|
||||
@@ -636,6 +857,7 @@ export class SimSession {
|
||||
return loadResult === 0 ? finish(this.envelope("COMMAND_RESULT", input, {
|
||||
status: "verified",
|
||||
command: input.kind,
|
||||
motion_authority: this.motionAuthority,
|
||||
state_hash: this.task.hash(),
|
||||
task: this.task.export(),
|
||||
...(this.configurationState?.schema === "configuration-startup-runtime.v1" ? { configuration: this.configurationState } : {}),
|
||||
@@ -648,19 +870,30 @@ export class SimSession {
|
||||
let program_steps = [];
|
||||
try {
|
||||
this.scheduler.step(cycles, {
|
||||
task: () => {
|
||||
program_steps.push(...this.program.tick(1));
|
||||
task: () => {
|
||||
const steps = this.program.tick(1);
|
||||
program_steps.push(...steps);
|
||||
for (const step of steps) {
|
||||
if (!step.motion) continue;
|
||||
const linearNoop = step.motion.motion_type === 1 && ["x", "y", "z", "a", "b", "c", "u", "v", "w"].every((axis) => step.motion.start[axis] === step.motion.end[axis]);
|
||||
if (!linearNoop) {
|
||||
const enqueue = this.wasmMotion.enqueue(step.motion);
|
||||
if (enqueue.status === "error") throw new Error(enqueue.code);
|
||||
}
|
||||
}
|
||||
},
|
||||
tp: (nextTick) => {
|
||||
if (this.program.loaded) {
|
||||
if (this.motionAuthority !== "wasm" && this.program.loaded) {
|
||||
const sync = this.authoritative.syncProgram(this.program.snapshot());
|
||||
if (sync.status === "blocked") throw new Error(sync.code);
|
||||
}
|
||||
const planner = this.authoritative.step(nextTick);
|
||||
if (planner.status === "error") throw new Error(planner.code);
|
||||
if (this.motionAuthority !== "wasm") {
|
||||
const planner = this.authoritative.step(nextTick);
|
||||
if (planner.status === "error") throw new Error(planner.code);
|
||||
}
|
||||
},
|
||||
kinematics: () => {
|
||||
if (!["trivkins", "corexy", "tripod", "lineardelta", "rotarydelta"].includes(this.authoritative.state.kinematics.type)) throw new Error("authoritative_kinematics_unsupported");
|
||||
if (this.motionAuthority !== "wasm" && !["trivkins", "corexy", "tripod", "lineardelta", "rotarydelta"].includes(this.authoritative.state.kinematics.type)) throw new Error("authoritative_kinematics_unsupported");
|
||||
},
|
||||
motion: (nextTick) => {
|
||||
const stepResult = call(this.core, "sim_step", 1);
|
||||
@@ -668,8 +901,19 @@ export class SimSession {
|
||||
this.tick = nextTick;
|
||||
this.task.setTick(nextTick);
|
||||
const motionState = this.motion.step(nextTick);
|
||||
const wasmMotion = this.wasmMotion.step();
|
||||
if (wasmMotion.status === "error" && wasmMotion.code !== "wasm_motion_step_-605") throw new Error(wasmMotion.code);
|
||||
if (motionState.fault && this.task.state.state === "RUNNING") this.task.fault(-1205);
|
||||
if (this.program.loaded) this.authoritative.applyMotion(this.motion);
|
||||
if (this.motionAuthority === "wasm") {
|
||||
if (wasmMotion.status === "verified") {
|
||||
const position = this.motion.applyWasmPosition(wasmMotion.state.position, wasmMotion.state.velocity, nextTick);
|
||||
if (position.status === "error") throw new Error(position.code);
|
||||
}
|
||||
} else if (this.program.loaded) this.authoritative.applyMotion(this.motion);
|
||||
if (this.motionAuthority === "wasm" && this.wasmHomeLimit.read().state.homed && !motionState.fault) {
|
||||
const homeCheck = this.wasmHomeLimit.check(this.motion.state.joints);
|
||||
if (homeCheck.status === "error") throw new Error(homeCheck.code);
|
||||
}
|
||||
const halState = this.hal.step(nextTick);
|
||||
if (halState.status === "error") throw new Error(halState.code);
|
||||
const deviceIoState = this.deviceIo.step(nextTick);
|
||||
@@ -692,12 +936,12 @@ export class SimSession {
|
||||
if (program) {
|
||||
const status = program.program_status === "completed" ? "completed" : program.program_status === "running" ? "running" : program.program_status;
|
||||
this.task.setProgram(status, program.line);
|
||||
if (status === "completed" && this.task.state.state === "RUNNING") this.task.transition("STOPPED");
|
||||
if (status === "completed" && this.task.state.state === "RUNNING" && (this.motionAuthority !== "wasm" || this.wasmMotion.read().state.queue_depth === 0)) this.task.transition("STOPPED");
|
||||
}
|
||||
const snapshotState = { tick: this.tick, cycles, task: this.task.export(), scheduler: this.scheduler.export(), authoritative: this.authoritative.export(), motion: this.motion.export(), plc: this.plc.export(), hal_runtime: this.hal.export(), device_io: this.deviceIo.export(), configuration_batch: this.configurationBatch.export(), io_ack: this.ioAck.export(), callbacks: this.callbacks.export() };
|
||||
const snapshotState = { tick: this.tick, cycles, motion_authority: this.motionAuthority, task: this.task.export(), scheduler: this.scheduler.export(), authoritative: this.authoritative.export(), wasm_motion: this.wasmMotion.read(), motion: this.motion.export(), plc: this.plc.export(), hal_runtime: this.hal.export(), device_io: this.deviceIo.export(), configuration_batch: this.configurationBatch.export(), io_ack: this.ioAck.export(), callbacks: this.callbacks.export() };
|
||||
if (program) snapshotState.program = program;
|
||||
const snapshot = this.timeline.checkpoint(input.seq, this.tick, snapshotState, this.timeline.snapshots.latest()?.snapshot_id ?? null);
|
||||
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", cycles, program_steps, task: this.task.export(), scheduler: this.scheduler.export(), authoritative: this.authoritative.export(), motion: this.motion.export(), plc: this.plc.export(), hal_runtime: this.hal.export(), device_io: this.deviceIo.export(), configuration_batch: this.configurationBatch.export(), io_ack: this.ioAck.export(), callbacks: this.callbacks.export(), ...(program ? { program } : {}), state_hash: snapshot.state_hash, snapshot }));
|
||||
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", cycles, program_steps, motion_authority: this.motionAuthority, task: this.task.export(), scheduler: this.scheduler.export(), authoritative: this.authoritative.export(), wasm_motion: this.wasmMotion.read(), motion: this.motion.export(), plc: this.plc.export(), hal_runtime: this.hal.export(), device_io: this.deviceIo.export(), configuration_batch: this.configurationBatch.export(), io_ack: this.ioAck.export(), callbacks: this.callbacks.export(), ...(program ? { program } : {}), state_hash: snapshot.state_hash, snapshot }));
|
||||
}
|
||||
case "PROGRAM_LOAD": {
|
||||
if (["RUNNING", "PAUSED"].includes(this.task.state.state)) return finish(this.error(input, "task_state", "program_load_while_active"), "failed");
|
||||
@@ -705,11 +949,20 @@ export class SimSession {
|
||||
const result = this.program.load(source, input.payload?.path ?? null);
|
||||
if (result.status === "verified") {
|
||||
this.authoritative.reset();
|
||||
this.wasmMotion.reset();
|
||||
this.wasmMotion.setPaused(false);
|
||||
this.motion.applyWasmPosition(this.wasmMotion.read().state.position, 0, this.tick);
|
||||
this.task.setProgram("loaded", 0);
|
||||
const preview = this.program.preview();
|
||||
result.preview = preview.preview ?? { status: "error", code: preview.code ?? "program_preview_failed" };
|
||||
this.previewState = result.preview;
|
||||
}
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, {
|
||||
...result,
|
||||
task: this.task.export(),
|
||||
motion_authority: this.motionAuthority,
|
||||
wasm_motion: this.wasmMotion.read(),
|
||||
motion: this.motion.export(),
|
||||
command: input.kind,
|
||||
operation_id: input.operation_id ?? null,
|
||||
}), result.status === "verified" ? "verified" : "failed");
|
||||
@@ -719,12 +972,16 @@ export class SimSession {
|
||||
if (result.status === "verified") {
|
||||
this.task.setMode("AUTO");
|
||||
if (this.task.state.state === "STOPPED") this.task.recover();
|
||||
if (this.motionAuthority === "wasm") this.wasmMotion.setPaused(false);
|
||||
this.task.transition("RUNNING");
|
||||
this.task.setProgram("running", this.program.currentLine);
|
||||
}
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, {
|
||||
...result,
|
||||
task: this.task.export(),
|
||||
motion_authority: this.motionAuthority,
|
||||
wasm_motion: this.wasmMotion.read(),
|
||||
motion: this.motion.export(),
|
||||
command: input.kind,
|
||||
operation_id: input.operation_id ?? null,
|
||||
}), result.status === "verified" ? "verified" : "failed");
|
||||
@@ -733,13 +990,25 @@ export class SimSession {
|
||||
const action = input.kind === "PROGRAM_PAUSE" ? "pause" : input.kind === "PROGRAM_RESUME" ? "resume" : "abort";
|
||||
const result = this.program[action]();
|
||||
if (result.status === "verified") {
|
||||
if (action === "pause") this.task.transition("PAUSED");
|
||||
else if (action === "resume") this.task.transition("RUNNING");
|
||||
else { this.authoritative.abort(); this.task.transition("STOPPED"); this.task.setProgram("aborted", this.program.currentLine); }
|
||||
if (action === "pause") {
|
||||
if (this.motionAuthority === "wasm") this.wasmMotion.setPaused(true);
|
||||
this.task.transition("PAUSED");
|
||||
} else if (action === "resume") {
|
||||
if (this.motionAuthority === "wasm") this.wasmMotion.setPaused(false);
|
||||
this.task.transition("RUNNING");
|
||||
} else {
|
||||
if (this.motionAuthority === "wasm") this.wasmMotion.abort();
|
||||
else this.authoritative.abort();
|
||||
this.task.transition("STOPPED");
|
||||
this.task.setProgram("aborted", this.program.currentLine);
|
||||
}
|
||||
}
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, {
|
||||
...result,
|
||||
task: this.task.export(),
|
||||
motion_authority: this.motionAuthority,
|
||||
wasm_motion: this.wasmMotion.read(),
|
||||
motion: this.motion.export(),
|
||||
command: input.kind,
|
||||
operation_id: input.operation_id ?? null,
|
||||
}), result.status === "verified" ? "verified" : "failed");
|
||||
@@ -748,9 +1017,13 @@ export class SimSession {
|
||||
const result = this.program.restart();
|
||||
if (result.status === "verified") {
|
||||
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();
|
||||
this.task.setMode("AUTO");
|
||||
this.authoritative.reset();
|
||||
this.wasmMotion.reset();
|
||||
this.wasmMotion.setPaused(false);
|
||||
this.motion.applyWasmPosition(this.wasmMotion.read().state.position, 0, this.tick);
|
||||
this.task.setProgram("loaded", 0);
|
||||
}
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, {
|
||||
@@ -767,14 +1040,26 @@ export class SimSession {
|
||||
}
|
||||
const result = this.program.step();
|
||||
if (result.status === "verified") {
|
||||
if (this.motionAuthority === "wasm" && result.motion) {
|
||||
const linearNoop = result.motion.motion_type === 1 && ["x", "y", "z", "a", "b", "c", "u", "v", "w"].every((axis) => result.motion.start[axis] === result.motion.end[axis]);
|
||||
if (!linearNoop) {
|
||||
const enqueue = this.wasmMotion.enqueue(result.motion);
|
||||
if (enqueue.status === "error") return finish(this.error(input, "motion_queue", enqueue.code), "failed");
|
||||
}
|
||||
this.wasmMotion.setPaused(true);
|
||||
}
|
||||
this.task.setProgram(result.program_status === "completed" ? "completed" : "paused", result.line);
|
||||
if (result.program_status === "completed") {
|
||||
if (this.task.state.state === "RUNNING") this.task.transition("STOPPED");
|
||||
const pending = this.motionAuthority === "wasm" ? this.wasmMotion.read().state.queue_depth > 0 : this.authoritative.state.planner.queue_depth > 0;
|
||||
if (this.task.state.state === "RUNNING") this.task.transition(pending ? "PAUSED" : "STOPPED");
|
||||
} else if (this.task.state.state === "RUNNING") this.task.transition("PAUSED");
|
||||
}
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, {
|
||||
...result,
|
||||
task: this.task.export(),
|
||||
motion_authority: this.motionAuthority,
|
||||
wasm_motion: this.wasmMotion.read(),
|
||||
motion: this.motion.export(),
|
||||
command: input.kind,
|
||||
operation_id: input.operation_id ?? null,
|
||||
}), result.status === "verified" ? "verified" : "failed");
|
||||
@@ -803,16 +1088,21 @@ export class SimSession {
|
||||
}
|
||||
case "HOME": {
|
||||
const result = this.motion.home(input.payload?.axis ?? null);
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, task: this.task.export(), command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
|
||||
if (result.status === "verified" && this.motionAuthority === "wasm") {
|
||||
const home = this.wasmHomeLimit.home(input.seq);
|
||||
if (home.status === "error") return finish(this.error(input, "home_limit", home.code), "failed");
|
||||
}
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, motion: this.motion.export(), task: this.task.export(), command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
|
||||
}
|
||||
case "JOG_START": case "JOG_STOP": case "SET_OVERRIDE": case "FOLLOWING_ERROR": {
|
||||
let result;
|
||||
if (input.kind === "JOG_START" && this.motionAuthority === "wasm" && !this.wasmHomeLimit.read().state.homed) return finish(this.error(input, "motion_state", "motion_unhomed"), "failed");
|
||||
if (input.kind === "JOG_START") result = this.motion.startJog(input.payload);
|
||||
else if (input.kind === "JOG_STOP") result = this.motion.stopJog();
|
||||
else if (input.kind === "SET_OVERRIDE") result = this.motion.setOverride(input.payload?.feed ?? input.payload?.feed_override, input.payload?.spindle ?? input.payload?.spindle_override);
|
||||
else result = this.motion.setFollowingError(input.payload?.value ?? input.payload?.following_error);
|
||||
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, task: this.task.export(), command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, motion: this.motion.export(), task: this.task.export(), command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
|
||||
}
|
||||
case "SPINDLE_COMMAND": case "SPINDLE_FAULT": case "SPINDLE_INHIBIT":
|
||||
case "SPINDLE_ORIENT": case "SPINDLE_ORIENTED": case "IO_STEP":
|
||||
|
||||
@@ -74,6 +74,7 @@ export class SimTask {
|
||||
if (!this.coreTask) return;
|
||||
const result = coreCall(this.core, name, "number", ["number", ...args.map(() => "number")], [this.coreTask, ...args]);
|
||||
if (result !== 0) throw new Error(`${code}_${result}`);
|
||||
this.syncFromCore();
|
||||
}
|
||||
|
||||
mirrorCommand(seq, tick) {
|
||||
@@ -81,6 +82,36 @@ export class SimTask {
|
||||
if (!this.coreTask) return;
|
||||
const result = coreCall(this.core, "sim_task_set_command", "number", ["number", "number", "number"], [this.coreTask, seq, tick]);
|
||||
if (result !== 0) throw new Error(`task_command_${result}`);
|
||||
this.syncFromCore();
|
||||
}
|
||||
|
||||
syncFromCore() {
|
||||
if (!this.coreTask || typeof this.core?._sim_task_canonical !== "function") return false;
|
||||
const required = coreCall(this.core, "sim_task_canonical", "number", ["number", "number", "number"], [this.coreTask, 0, 0]);
|
||||
if (!Number.isSafeInteger(required) || required <= 1 || required > 4096) throw new Error("task_core_canonical_invalid");
|
||||
const pointer = this.core._malloc(required);
|
||||
if (!pointer) throw new Error("task_core_canonical_allocation_failed");
|
||||
try {
|
||||
const written = coreCall(this.core, "sim_task_canonical", "number", ["number", "number", "number"], [this.coreTask, pointer, required]);
|
||||
const canonical = JSON.parse(readCoreString(this.core, pointer, written));
|
||||
if (canonical.schema !== TASK_SCHEMA || canonical.context_id !== this.contextId || canonical.session_id !== this.sessionId) throw new Error("task_core_canonical_context");
|
||||
this.state = {
|
||||
...this.state,
|
||||
state: canonical.state,
|
||||
mode: canonical.mode,
|
||||
program_status: canonical.program_status,
|
||||
seq: canonical.seq,
|
||||
simulation_tick: canonical.simulation_tick,
|
||||
current_line: canonical.current_line,
|
||||
};
|
||||
return true;
|
||||
} finally {
|
||||
this.core._free(pointer);
|
||||
}
|
||||
}
|
||||
|
||||
authority() {
|
||||
return this.coreTask && typeof this.core?._sim_task_canonical === "function" ? "wasm_task_canonical" : "js_compat";
|
||||
}
|
||||
|
||||
setCommand(seq, tick) {
|
||||
@@ -140,6 +171,7 @@ export class SimTask {
|
||||
}
|
||||
|
||||
export() {
|
||||
this.syncFromCore();
|
||||
const unsigned = clone(this.state);
|
||||
return { ...unsigned, state_hash: fnv1a64(unsigned) };
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ const EXPECTED_FILES = [
|
||||
"linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.hal",
|
||||
"linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial",
|
||||
"linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial_mm.var",
|
||||
"linuxcnc/configs/nc_files/3dtest.ngc",
|
||||
"linuxcnc/configs/sim/axis/vismach/3axis-tutorial/tool.tbl",
|
||||
"linuxcnc/linuxcnc.gif",
|
||||
];
|
||||
@@ -30,6 +31,7 @@ assert.deepEqual(bundle.missing, []);
|
||||
assert.deepEqual(bundle.optional_missing, []);
|
||||
assert.equal(bundle.catalog.path, SELECTED_CASE);
|
||||
assert.equal(bundle.catalog.coordinates, "X Y Z");
|
||||
assert.ok(bundle.files["linuxcnc/configs/nc_files/3dtest.ngc"].includes("start xy circle"));
|
||||
for (const path of EXPECTED_FILES) assert.ok(Object.hasOwn(bundle.files, path), `missing exact case file: ${path}`);
|
||||
assert.equal(bundle.files["linuxcnc/configs/sim/axis/vismach/3axis-tutorial/tool.tbl"].includes("T1"), true);
|
||||
|
||||
|
||||
51
web/worker/test_axis3_visible_completion.mjs
Normal file
51
web/worker/test_axis3_visible_completion.mjs
Normal file
@@ -0,0 +1,51 @@
|
||||
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/3axis-tutorial/3axis-tutorial.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: "axis3-visible-completion" });
|
||||
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
|
||||
let sequence = 0;
|
||||
const send = (kind, operationId, payload = {}) => session.accept({ ...base, kind, seq: ++sequence, operation_id: operationId, payload });
|
||||
|
||||
send("HELLO", "hello");
|
||||
send("SESSION_ATTACH", "attach");
|
||||
send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
|
||||
const source = "G21 G90\nG0 X0 Y0 Z0\nG1 X10 Y5 F120\nG1 Z-1 F60\nG0 X0 Y0 Z0\nM2\n";
|
||||
send("PROGRAM_LOAD", "program", { source, path: "visible-completion.ngc" });
|
||||
send("PROGRAM_RUN", "run");
|
||||
let finish = null;
|
||||
let sawWasmMotionAuthority = false;
|
||||
for (let index = 0; index < 512; index += 1) {
|
||||
finish = send("SIM_STEP", `step-${index}`, { cycles: 1 });
|
||||
const wasmPosition = finish.payload.wasm_motion?.state?.position;
|
||||
const authoritativePosition = finish.payload.authoritative?.kinematics?.pose;
|
||||
if (wasmPosition && authoritativePosition && (wasmPosition.x !== authoritativePosition.x || wasmPosition.y !== authoritativePosition.y || wasmPosition.z !== authoritativePosition.z)) sawWasmMotionAuthority = true;
|
||||
if (finish.payload.program?.program_status === "completed" && finish.payload.task?.state === "STOPPED" && finish.payload.wasm_motion?.state?.queue_depth === 0) break;
|
||||
}
|
||||
|
||||
assert.equal(finish.payload.program.program_status, "completed");
|
||||
assert.equal(finish.payload.task.state, "STOPPED");
|
||||
assert.equal(finish.payload.authoritative.kinematics.pose.x, 0);
|
||||
assert.equal(finish.payload.authoritative.kinematics.pose.y, 0);
|
||||
assert.equal(finish.payload.authoritative.kinematics.pose.z, 0);
|
||||
assert.equal(finish.payload.motion.axes.x, 0);
|
||||
assert.equal(finish.payload.motion.axes.y, 0);
|
||||
assert.equal(finish.payload.motion.axes.z, 0);
|
||||
assert.equal(finish.payload.wasm_motion.state.source, "wasm_motion_queue");
|
||||
assert.equal(finish.payload.wasm_motion.state.queue_depth, 0);
|
||||
assert.equal(sawWasmMotionAuthority, true);
|
||||
console.log(JSON.stringify({ schema: "axis3-visible-completion.v1", status: "verified", case: CASE, final_pose: finish.payload.authoritative.kinematics.pose, task_state: finish.payload.task.state, program_status: finish.payload.program.program_status }));
|
||||
@@ -16,6 +16,11 @@ assert.equal(twin.setFollowingError(0.02).state.fault, "following_error");
|
||||
const checkpoint = twin.export();
|
||||
const restoredTwin = new MotionState();
|
||||
assert.deepEqual(restoredTwin.restore(checkpoint), checkpoint);
|
||||
const partialHome = new MotionState();
|
||||
assert.deepEqual(partialHome.home("z").state.homed_axes, [false, false, true]);
|
||||
assert.equal(partialHome.state.homed, false);
|
||||
assert.deepEqual(partialHome.home("x").state.homed_axes, [true, false, true]);
|
||||
assert.equal(partialHome.home("y").state.homed, true);
|
||||
|
||||
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
|
||||
const session = new SimSession(core, { sessionId: "motion-state" });
|
||||
@@ -25,6 +30,7 @@ send("HELLO", 1);
|
||||
send("SESSION_ATTACH", 2, "attach");
|
||||
send("LOAD_WORKSPACE", 3, "workspace", { root_path: "/workspace" });
|
||||
assert.equal(send("HOME", 4, "home").payload.state.homed, true);
|
||||
assert.deepEqual(session.motion.export().homed_axes, [true, true, true]);
|
||||
send("JOG_START", 5, "jog", { axis: "x", direction: 1, speed: 10 });
|
||||
const stepped = send("SIM_STEP", 6, "step", { cycles: 2 });
|
||||
assert.equal(stepped.payload.snapshot.state_hash, stepped.payload.state_hash);
|
||||
|
||||
65
web/worker/test_panel_gate_matrix.mjs
Normal file
65
web/worker/test_panel_gate_matrix.mjs
Normal file
@@ -0,0 +1,65 @@
|
||||
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/3axis-tutorial/3axis-tutorial.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: "panel-gates" });
|
||||
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
|
||||
let seq = 0;
|
||||
const send = (kind, operationId, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: operationId, payload });
|
||||
|
||||
send("HELLO", "hello");
|
||||
send("SESSION_ATTACH", "attach");
|
||||
send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
|
||||
const noProgram = send("PROGRAM_RUN", "run-without-program");
|
||||
assert.equal(noProgram.kind, "ERROR");
|
||||
assert.equal(noProgram.payload.code, "axis_gate");
|
||||
assert.equal(noProgram.payload.gate_reason, "axis_taskfile_missing");
|
||||
|
||||
send("PROGRAM_LOAD", "load", { path: "gate.ngc", source: "G21 G90\nG1 X1 F10\nM2\n" });
|
||||
const running = send("PROGRAM_RUN", "run");
|
||||
assert.equal(running.payload.task.state, "RUNNING");
|
||||
const homeWhileRunning = send("HOME", "home-while-running", { axis: "x" });
|
||||
assert.equal(homeWhileRunning.kind, "ERROR");
|
||||
assert.equal(homeWhileRunning.payload.code, "axis_gate");
|
||||
assert.equal(homeWhileRunning.payload.gate_reason, "axis_manual_mode_required");
|
||||
const mdiWhileRunning = send("MDI_EXECUTE", "mdi-while-running", { block: "G0 X2" });
|
||||
assert.equal(mdiWhileRunning.kind, "ERROR");
|
||||
assert.equal(mdiWhileRunning.payload.message_key, "mdi_while_program_active");
|
||||
send("PROGRAM_ABORT", "abort");
|
||||
send("SET_MODE", "manual", { mode: "MANUAL" });
|
||||
send("HOME", "home-x", { axis: "x" });
|
||||
send("HOME", "home-y", { axis: "y" });
|
||||
send("HOME", "home-z", { axis: "z" });
|
||||
const jog = send("JOG_START", "jog", { axis: "x", direction: 1, speed: 1 });
|
||||
assert.equal(jog.payload.status, "verified");
|
||||
send("JOG_STOP", "jog-stop");
|
||||
const estop = send("ESTOP", "estop", { active: true });
|
||||
assert.equal(estop.kind, "ERROR");
|
||||
const blockedRun = send("PROGRAM_RUN", "run-estop");
|
||||
assert.equal(blockedRun.kind, "ERROR");
|
||||
assert.ok(["axis_estop_active", "axis_fault_active"].includes(blockedRun.payload.gate_reason));
|
||||
send("ESTOP", "estop-reset", { active: false });
|
||||
send("IO_RECOVER", "recover");
|
||||
const reset = send("RESET", "reset");
|
||||
assert.equal(reset.payload.status, "verified");
|
||||
|
||||
console.log(JSON.stringify({
|
||||
schema: "panel-gate-matrix.v1",
|
||||
status: "verified",
|
||||
normal: ["PROGRAM_LOAD", "PROGRAM_RUN", "SET_MODE", "HOME", "JOG_START", "JOG_STOP", "RESET"],
|
||||
blocked: [noProgram.payload.gate_reason, homeWhileRunning.payload.gate_reason, blockedRun.payload.gate_reason, mdiWhileRunning.payload.message_key],
|
||||
authority: "worker_axis_gate",
|
||||
}));
|
||||
65
web/worker/test_panel_snapshot_projection.mjs
Normal file
65
web/worker/test_panel_snapshot_projection.mjs
Normal file
@@ -0,0 +1,65 @@
|
||||
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/3axis-tutorial/3axis-tutorial.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: "panel-snapshot" });
|
||||
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
|
||||
let seq = 0;
|
||||
const send = (kind, operationId, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: operationId, payload });
|
||||
|
||||
send("HELLO", "hello");
|
||||
send("SESSION_ATTACH", "attach");
|
||||
const loaded = send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
|
||||
const projection = loaded.payload.panel_snapshot;
|
||||
assert.equal(projection.schema, "axis-panel-snapshot.v1");
|
||||
assert.equal(projection.context_id, CONTEXT_ID);
|
||||
assert.equal(projection.session_id, session.sessionId);
|
||||
assert.equal(projection.motion_authority, "wasm");
|
||||
assert.equal(projection.task_authority, "wasm_task_canonical");
|
||||
assert.equal(projection.axis_state.state, "IDLE");
|
||||
assert.equal(projection.gates.run.enabled, false);
|
||||
assert.equal(projection.gates.run.reason, "axis_taskfile_missing");
|
||||
assert.equal(projection.configuration.state, "TASK_READY");
|
||||
assert.equal(projection.home_limit.state.source, "wasm_home_limit");
|
||||
assert.equal(projection.visual.source, "wasm_canonical_motion");
|
||||
assert.deepEqual(projection.visual.pose, { x: 0, y: 0, z: 0 });
|
||||
|
||||
const loadedProgram = send("PROGRAM_LOAD", "program", { path: "panel.ngc", source: "G21 G90\nG0 X1 Y2 Z-1\nM2\n" });
|
||||
assert.equal(loadedProgram.payload.panel_snapshot.program.program_status, "loaded");
|
||||
assert.ok(loadedProgram.payload.panel_snapshot.visual.toolpath.length > 0);
|
||||
const beforeHash = loadedProgram.payload.panel_snapshot.state_hash;
|
||||
send("HOME", "home-z", { axis: "z" });
|
||||
send("HOME", "home-x", { axis: "x" });
|
||||
send("HOME", "home-y", { axis: "y" });
|
||||
const jog = send("JOG_START", "jog", { axis: "x", direction: 1, speed: 2 });
|
||||
const stepped = send("SIM_STEP", "jog-step", { cycles: 2 });
|
||||
assert.notEqual(stepped.payload.panel_snapshot.state_hash, beforeHash);
|
||||
assert.equal(stepped.payload.panel_snapshot.visual.source, "wasm_canonical_motion");
|
||||
assert.equal(stepped.payload.panel_snapshot.simulation_tick, stepped.simulation_tick);
|
||||
assert.equal(stepped.payload.panel_snapshot.state_hash, session.panelSnapshot({ input: { kind: "SIM_STEP", payload: { cycles: 2 } }, tick: session.tick }).state_hash);
|
||||
assert.ok(jog.payload.panel_snapshot.home_limit.state.homed === true);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
schema: "panel-snapshot-projection.v1",
|
||||
status: "verified",
|
||||
case: CASE,
|
||||
fields: ["task", "program", "motion", "wasm_motion", "home_limit", "hal", "devices", "device_io", "configuration", "axis_state", "visual", "state_hash"],
|
||||
authority: "wasm",
|
||||
startup: projection.configuration.state,
|
||||
visual_source: projection.visual.source,
|
||||
state_hash: stepped.payload.panel_snapshot.state_hash,
|
||||
}));
|
||||
@@ -4,6 +4,7 @@ import { SIM_CASE_CATALOG } from "../workspace/sim-case-catalog.mjs";
|
||||
import { loadSimulationCase } from "../workspace/sim-case-loader.mjs";
|
||||
import { WorkspaceRuntime } from "../workspace/runtime.mjs";
|
||||
import { startConfiguration } from "../workspace/configuration-startup.mjs";
|
||||
import { classifyMissingResources } from "../workspace/linuxcnc-source-semantics.mjs";
|
||||
|
||||
const fetchImpl = async (url) => {
|
||||
try {
|
||||
@@ -25,7 +26,7 @@ async function matrixRun() {
|
||||
}
|
||||
const first = startup?.diagnostics?.[0] ?? null;
|
||||
const classification = bundle.status !== "ready"
|
||||
? "BLOCKED_RESOURCE"
|
||||
? classifyMissingResources(bundle.missing)
|
||||
: startup.status === "ready"
|
||||
? "TASK_READY"
|
||||
: first?.code === "startup_hal_blocked"
|
||||
@@ -59,7 +60,9 @@ async function matrixRun() {
|
||||
const first = await matrixRun();
|
||||
const second = await matrixRun();
|
||||
assert.equal(first.case_count, 235);
|
||||
assert.deepEqual(first.counts, { BLOCKED_HAL: 106, BLOCKED_INI: 19, BLOCKED_RESOURCE: 12, BLOCKED_STARTUP: 3, TASK_READY: 95 });
|
||||
// LinuxCNC treats DISPLAY/OPEN_FILE as an optional AXIS startup hint. Missing
|
||||
// HALFILEs remain BLOCKED_HAL, while genuine absent resources remain blocked.
|
||||
assert.deepEqual(first.counts, { BLOCKED_HAL: 106, BLOCKED_INI: 19, BLOCKED_RESOURCE: 11, BLOCKED_STARTUP: 3, TASK_READY: 96 });
|
||||
assert.deepEqual(first, second);
|
||||
for (const path of [
|
||||
"linuxcnc/configs/sim/axis/minimal_xyz.ini",
|
||||
@@ -67,6 +70,7 @@ for (const path of [
|
||||
"linuxcnc/configs/sim/axis/corexy/corexy_by_hal.ini",
|
||||
"linuxcnc/configs/sim/axis/ja_tests/xz/xz.ini",
|
||||
]) assert.equal(first.cases.find((row) => row.path === path).classification, "TASK_READY");
|
||||
assert.equal(first.cases.find((row) => row.path.endsWith("anglejog.ini")).classification, "BLOCKED_HAL");
|
||||
assert.equal(first.cases.find((row) => row.path.endsWith("histogram_demo.ini")).classification, "BLOCKED_INI");
|
||||
assert.equal(first.cases.find((row) => row.path.endsWith("anglejog.ini")).classification, "BLOCKED_HAL");
|
||||
assert.equal(first.cases.find((row) => row.path.endsWith("histogram_demo.ini")).classification, "BLOCKED_INI");
|
||||
assert.equal(first.cases.find((row) => row.path.endsWith("craftsmancnc-axis.ini")).classification, "BLOCKED_HAL");
|
||||
console.log(JSON.stringify(first));
|
||||
|
||||
68
web/worker/test_wasm_canonical_state.mjs
Normal file
68
web/worker/test_wasm_canonical_state.mjs
Normal file
@@ -0,0 +1,68 @@
|
||||
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/3axis-tutorial/3axis-tutorial.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: "wasm-canonical-state" });
|
||||
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
|
||||
let seq = 0;
|
||||
const send = (kind, operationId, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: operationId, payload });
|
||||
|
||||
send("HELLO", "hello");
|
||||
send("SESSION_ATTACH", "attach");
|
||||
const loaded = send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
|
||||
assert.equal(loaded.payload.motion_authority, "wasm");
|
||||
assert.equal(loaded.payload.task_authority, "wasm_task_canonical");
|
||||
assert.equal(loaded.payload.wasm_home_limit.state.source, "wasm_home_limit");
|
||||
|
||||
const taskBefore = session.task.export();
|
||||
session.task.state.state = "FAULT";
|
||||
const canonicalSnapshot = send("SNAPSHOT_REQUEST", "canonical-snapshot");
|
||||
assert.equal(canonicalSnapshot.payload.task.state, taskBefore.state);
|
||||
assert.equal(canonicalSnapshot.payload.task.mode, taskBefore.mode);
|
||||
assert.equal(canonicalSnapshot.payload.task.program_status, taskBefore.program_status);
|
||||
assert.equal(canonicalSnapshot.payload.task_authority, "wasm_task_canonical");
|
||||
|
||||
const home = send("HOME", "home-z", { axis: "z" });
|
||||
assert.equal(home.payload.wasm_home_limit.state.source, "wasm_home_limit");
|
||||
assert.equal(home.payload.wasm_home_limit.state.homed, true);
|
||||
assert.equal(home.payload.wasm_home_limit.state.home_sequence, 5);
|
||||
assert.equal(home.payload.wasm_home_limit.state.last_error, 0);
|
||||
|
||||
send("HOME", "home-x", { axis: "x" });
|
||||
send("HOME", "home-y", { axis: "y" });
|
||||
send("JOG_START", "jog", { axis: "x", direction: 1, speed: 2 });
|
||||
const stepped = send("SIM_STEP", "jog-step", { cycles: 3 });
|
||||
assert.equal(stepped.payload.task_authority, "wasm_task_canonical");
|
||||
assert.equal(stepped.payload.wasm_home_limit.state.source, "wasm_home_limit");
|
||||
assert.ok(stepped.payload.wasm_home_limit.state.tick > home.payload.wasm_home_limit.state.tick);
|
||||
assert.deepEqual(stepped.payload.motion.axes, { x: stepped.payload.motion.joints[0], y: stepped.payload.motion.joints[1], z: stepped.payload.motion.joints[2] });
|
||||
|
||||
const limit = send("LIMIT_SET", "limit", { axis: "x", active: true });
|
||||
assert.equal(limit.payload.wasm_home_limit.state.source, "wasm_home_limit");
|
||||
|
||||
console.log(JSON.stringify({
|
||||
schema: "wasm-canonical-state-t4.v1",
|
||||
status: "verified",
|
||||
case: CASE,
|
||||
task_authority: loaded.payload.task_authority,
|
||||
home_authority: loaded.payload.wasm_home_limit.state.source,
|
||||
home_sequence: home.payload.wasm_home_limit.state.home_sequence,
|
||||
home_tick: home.payload.wasm_home_limit.state.tick,
|
||||
motion_tick: stepped.payload.motion.tick,
|
||||
home_limit_tick: stepped.payload.wasm_home_limit.state.tick,
|
||||
js_task_tamper_ignored: canonicalSnapshot.payload.task.state === taskBefore.state,
|
||||
}));
|
||||
72
web/worker/test_wasm_canonical_state_checkpoint.mjs
Normal file
72
web/worker/test_wasm_canonical_state_checkpoint.mjs
Normal file
@@ -0,0 +1,72 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
|
||||
import { fnv1a64 } from "./replay.mjs";
|
||||
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
|
||||
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
|
||||
|
||||
const CASE = "linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.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 wasmBinary = await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url));
|
||||
|
||||
async function create(id) {
|
||||
const core = await createModule({ wasmBinary });
|
||||
const session = new SimSession(core, { sessionId: id });
|
||||
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: id };
|
||||
let seq = 0;
|
||||
const send = (kind, operationId, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: operationId, payload });
|
||||
send("HELLO", "hello");
|
||||
send("SESSION_ATTACH", "attach");
|
||||
const loaded = send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
|
||||
assert.equal(loaded.payload.task_authority, "wasm_task_canonical");
|
||||
send("HOME", "home", { axis: "z" });
|
||||
send("PROGRAM_LOAD", "program", { path: "canonical-state.ngc", source: "G21 G90\nG0 X0 Y0 Z0\nG1 X20 Y10 F120\nG1 Z-1 F60\nM2\n" });
|
||||
send("PROGRAM_RUN", "run");
|
||||
return { session, send };
|
||||
}
|
||||
|
||||
const continuous = await create("wasm-canonical-checkpoint");
|
||||
continuous.send("SIM_STEP", "step-a", { cycles: 3 });
|
||||
const checkpoint = continuous.send("CHECKPOINT", "checkpoint").payload.checkpoint;
|
||||
assert.equal(checkpoint.state.task_authority, "wasm_task_canonical");
|
||||
assert.equal(checkpoint.state.wasm_home_limit.schema, "sim_home_limit_state.v1");
|
||||
continuous.send("SIM_STEP", "step-b", { cycles: 4 });
|
||||
const continuousState = continuous.session.workspacePackageState();
|
||||
|
||||
const recovered = await create("wasm-canonical-checkpoint");
|
||||
const recovery = recovered.session.restoreCheckpoint(checkpoint, continuous.session.timeline.export(), (operation) => operation);
|
||||
assert.equal(recovery.status, "recovered");
|
||||
const recoveredState = recovered.session.workspacePackageState();
|
||||
assert.equal(fnv1a64(continuousState), fnv1a64(recoveredState));
|
||||
assert.equal(recoveredState.task.state_hash, continuousState.task.state_hash);
|
||||
assert.equal(recoveredState.wasm_home_limit.state_hash, continuousState.wasm_home_limit.state_hash);
|
||||
|
||||
const trace = continuous.session.exportTrace();
|
||||
const traced = await create("wasm-canonical-checkpoint");
|
||||
assert.equal(traced.session.importTrace(trace).status, "verified");
|
||||
assert.equal(traced.session.workspacePackageState().wasm_home_limit.state_hash, continuousState.wasm_home_limit.state_hash);
|
||||
|
||||
const damaged = structuredClone(checkpoint);
|
||||
damaged.state.wasm_home_limit.state_bytes = `${damaged.state.wasm_home_limit.state_bytes.startsWith("00") ? "ff" : "00"}${damaged.state.wasm_home_limit.state_bytes.slice(2)}`;
|
||||
damaged.state_hash = fnv1a64(damaged.state);
|
||||
assert.equal(recovered.session.restoreCheckpoint(damaged, null, (operation) => operation).status, "blocked");
|
||||
|
||||
console.log(JSON.stringify({
|
||||
schema: "wasm-canonical-state-checkpoint-t4.v1",
|
||||
status: "verified",
|
||||
case: CASE,
|
||||
checkpoint_hash: checkpoint.checkpoint_hash,
|
||||
continuous_hash: fnv1a64(continuousState),
|
||||
recovered_hash: fnv1a64(recoveredState),
|
||||
endpoint_equal: fnv1a64(continuousState) === fnv1a64(recoveredState),
|
||||
trace_import: "verified",
|
||||
damaged_home_state: "blocked",
|
||||
}));
|
||||
40
web/worker/test_wasm_feature_matrix_t5.mjs
Normal file
40
web/worker/test_wasm_feature_matrix_t5.mjs
Normal file
@@ -0,0 +1,40 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
const report = JSON.parse(await readFile(new URL("../../coverage/wasm-feature-matrix-t5.report.json", import.meta.url), "utf8"));
|
||||
assert.equal(report.schema, "wasm-feature-matrix-t5.v1");
|
||||
assert.equal(report.task_id, "T5-FULL-MATRIX-01");
|
||||
assert.equal(report.context_id, "linuxcnc-2.10.0-pre0-wasm-m1-py-c1");
|
||||
assert.equal(report.matrix.length, 9);
|
||||
assert.equal(report.status, "blocked");
|
||||
assert.equal(report.release_action, "STOP_RELEASE");
|
||||
assert.equal(report.candidate_eligible, false);
|
||||
assert.deepEqual(report.counts.statuses, { blocked: 9, queued: 0, verified: 0 });
|
||||
for (const row of report.matrix) {
|
||||
assert.ok(row.owner);
|
||||
assert.ok(row.inputs.length > 0);
|
||||
assert.ok(row.tests.length > 0);
|
||||
assert.ok(row.differences.length > 0);
|
||||
assert.ok(row.stop_reason);
|
||||
assert.ok(["verified", "blocked", "queued"].includes(row.status));
|
||||
}
|
||||
assert.equal(report.checks.no_selected_case_promotion.status, "verified");
|
||||
const selfHash = report.report_sha256;
|
||||
const canonical = (value) => {
|
||||
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
||||
if (value && typeof value === "object") return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
|
||||
return JSON.stringify(value);
|
||||
};
|
||||
const copy = structuredClone(report);
|
||||
delete copy.report_sha256;
|
||||
assert.equal(selfHash, createHash("sha256").update(canonical(copy)).digest("hex"));
|
||||
console.log(JSON.stringify({
|
||||
schema: report.schema,
|
||||
status: "verified",
|
||||
task_id: report.task_id,
|
||||
domains: report.matrix.length,
|
||||
blocked_domains: report.counts.statuses.blocked,
|
||||
blocked_tests: report.counts.test_blocked,
|
||||
release_action: report.release_action,
|
||||
}));
|
||||
33
web/worker/test_wasm_function_closure_t6.mjs
Normal file
33
web/worker/test_wasm_function_closure_t6.mjs
Normal file
@@ -0,0 +1,33 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
const report = JSON.parse(await readFile(new URL("../../coverage/wasm-function-closure-t6.report.json", import.meta.url), "utf8"));
|
||||
assert.equal(report.schema, "wasm-function-closure-t6.v1");
|
||||
assert.equal(report.task_id, "T6-FUNCTION-CLOSURE-01");
|
||||
assert.equal(report.status, "blocked");
|
||||
assert.equal(report.matrix.length, 8);
|
||||
assert.equal(report.counts.blocked, 8);
|
||||
assert.equal(report.counts.test_blocked, 0);
|
||||
assert.equal(report.failure_samples.length, 4);
|
||||
assert.ok(report.failure_samples.some((sample) => sample.code === "official_kinematics_oracle_missing"));
|
||||
for (const row of report.matrix) {
|
||||
assert.ok(row.owner);
|
||||
assert.ok(row.source_refs.length > 0);
|
||||
assert.ok(row.verified_slice.length > 0);
|
||||
assert.ok(row.stop_reason);
|
||||
assert.ok(row.next_action);
|
||||
assert.equal(row.status, "blocked");
|
||||
}
|
||||
assert.equal(report.checks.no_js_fallback.status, "verified");
|
||||
assert.equal(report.checks.selected_case_boundary.status, "verified");
|
||||
const canonical = (value) => {
|
||||
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
||||
if (value && typeof value === "object") return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
|
||||
return JSON.stringify(value);
|
||||
};
|
||||
const copy = structuredClone(report);
|
||||
const expected = copy.report_sha256;
|
||||
delete copy.report_sha256;
|
||||
assert.equal(expected, createHash("sha256").update(canonical(copy)).digest("hex"));
|
||||
console.log(JSON.stringify({ schema: report.schema, status: "verified", rows: report.matrix.length, blocked: report.counts.blocked, failure_samples: report.failure_samples.length, release_action: report.release_action }));
|
||||
74
web/worker/test_wasm_motion_authority.mjs
Normal file
74
web/worker/test_wasm_motion_authority.mjs
Normal file
@@ -0,0 +1,74 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
|
||||
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
|
||||
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
|
||||
|
||||
const CASE = "linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.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: "wasm-motion-authority" });
|
||||
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
|
||||
let seq = 0;
|
||||
const send = (kind, operationId, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: operationId, payload });
|
||||
|
||||
send("HELLO", "hello");
|
||||
send("SESSION_ATTACH", "attach");
|
||||
const loaded = send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
|
||||
assert.equal(loaded.payload.motion_authority, "wasm");
|
||||
|
||||
let jsAuthorityCalls = 0;
|
||||
for (const method of ["syncProgram", "step", "applyMotion"]) {
|
||||
session.authoritative[method] = () => {
|
||||
jsAuthorityCalls += 1;
|
||||
throw new Error(`js_motion_authority_called:${method}`);
|
||||
};
|
||||
}
|
||||
|
||||
send("PROGRAM_LOAD", "program", { path: "authority.ngc", source: "G21 G90\nG0 X0 Y0 Z0\nG1 X40 Y20 F120\nG1 Z-2 F60\nM2\n" });
|
||||
assert.equal(send("PROGRAM_RUN", "run").payload.task.state, "RUNNING");
|
||||
const moving = send("SIM_STEP", "moving", { cycles: 3 });
|
||||
assert.equal(moving.payload.wasm_motion.state.source, "wasm_motion_queue");
|
||||
assert.deepEqual(moving.payload.motion.axes, moving.payload.wasm_motion.state.position);
|
||||
assert.ok(moving.payload.wasm_motion.state.queue_depth > 0);
|
||||
|
||||
const pausedPosition = { ...moving.payload.wasm_motion.state.position };
|
||||
const paused = send("PROGRAM_PAUSE", "pause");
|
||||
assert.equal(paused.payload.task.state, "PAUSED");
|
||||
assert.deepEqual(paused.payload.wasm_motion.state.position, pausedPosition);
|
||||
assert.deepEqual(paused.payload.motion.axes, pausedPosition);
|
||||
assert.equal(paused.payload.wasm_motion.state.velocity, 0);
|
||||
assert.equal(send("PROGRAM_RESUME", "resume").payload.task.state, "RUNNING");
|
||||
const resumed = send("SIM_STEP", "resumed-step", { cycles: 2 });
|
||||
assert.notDeepEqual(resumed.payload.wasm_motion.state.position, pausedPosition);
|
||||
|
||||
send("PROGRAM_RESTART", "restart");
|
||||
const stepped = send("PROGRAM_STEP", "single-step");
|
||||
assert.equal(stepped.payload.task.state, "PAUSED");
|
||||
assert.ok(stepped.payload.wasm_motion.state.queue_depth >= 0);
|
||||
|
||||
const aborted = send("PROGRAM_ABORT", "abort");
|
||||
assert.equal(aborted.payload.task.program_status, "aborted");
|
||||
assert.equal(aborted.payload.wasm_motion.state.queue_depth, 0);
|
||||
assert.deepEqual(aborted.payload.motion.axes, aborted.payload.wasm_motion.state.position);
|
||||
assert.equal(jsAuthorityCalls, 0);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
schema: "wasm-motion-authority-t3.v1",
|
||||
status: "verified",
|
||||
case: CASE,
|
||||
motion_authority: loaded.payload.motion_authority,
|
||||
paused_position: pausedPosition,
|
||||
resumed_position: resumed.payload.wasm_motion.state.position,
|
||||
abort_queue_depth: aborted.payload.wasm_motion.state.queue_depth,
|
||||
js_authority_calls: jsAuthorityCalls,
|
||||
}));
|
||||
83
web/worker/test_wasm_motion_checkpoint.mjs
Normal file
83
web/worker/test_wasm_motion_checkpoint.mjs
Normal file
@@ -0,0 +1,83 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
|
||||
import { fnv1a64 } from "./replay.mjs";
|
||||
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
|
||||
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
|
||||
|
||||
const CASE = "linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.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 wasmBinary = await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url));
|
||||
const source = "G21 G90\nG0 X0 Y0 Z0\nG1 X40 Y20 F120\nG1 Z-2 F60\nG0 X0 Y0 Z0\nM2\n";
|
||||
|
||||
async function create(id) {
|
||||
const core = await createModule({ wasmBinary });
|
||||
const session = new SimSession(core, { sessionId: id });
|
||||
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: id };
|
||||
let seq = 0;
|
||||
const send = (kind, operationId, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: operationId, payload });
|
||||
send("HELLO", "hello");
|
||||
send("SESSION_ATTACH", "attach");
|
||||
const loaded = send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
|
||||
assert.equal(loaded.payload.motion_authority, "wasm");
|
||||
send("PROGRAM_LOAD", "program", { path: "checkpoint.ngc", source });
|
||||
send("PROGRAM_RUN", "run");
|
||||
return { session, send };
|
||||
}
|
||||
|
||||
const continuous = await create("wasm-motion-checkpoint");
|
||||
const before = continuous.send("SIM_STEP", "before", { cycles: 3 });
|
||||
assert.ok(before.payload.wasm_motion.state.queue_depth > 0);
|
||||
const checkpoint = continuous.send("CHECKPOINT", "checkpoint").payload.checkpoint;
|
||||
assert.equal(checkpoint.state.wasm_motion.schema, "sim_motion_queue_state.v1");
|
||||
assert.equal(checkpoint.state.motion_authority, "wasm");
|
||||
assert.equal(checkpoint.state_hash, fnv1a64(checkpoint.state));
|
||||
|
||||
continuous.send("SIM_STEP", "after", { cycles: 4 });
|
||||
const continuousState = continuous.session.workspacePackageState();
|
||||
|
||||
const recovered = await create("wasm-motion-checkpoint");
|
||||
for (const method of ["syncProgram", "step", "applyMotion"]) {
|
||||
recovered.session.authoritative[method] = () => { throw new Error(`js_motion_authority_called:${method}`); };
|
||||
}
|
||||
const recovery = recovered.session.restoreCheckpoint(checkpoint, continuous.session.timeline.export(), (operation) => operation);
|
||||
assert.equal(recovery.status, "recovered");
|
||||
const recoveredState = recovered.session.workspacePackageState();
|
||||
assert.equal(fnv1a64(continuousState), fnv1a64(recoveredState));
|
||||
assert.equal(recoveredState.wasm_motion.state.queue_depth, continuousState.wasm_motion.state.queue_depth);
|
||||
assert.deepEqual(recoveredState.wasm_motion.state.position, continuousState.wasm_motion.state.position);
|
||||
|
||||
const trace = continuous.session.exportTrace();
|
||||
const traced = await create("wasm-motion-checkpoint");
|
||||
const traceImport = traced.session.importTrace(trace);
|
||||
assert.equal(traceImport.status, "verified");
|
||||
assert.equal(traced.session.workspacePackageState().wasm_motion.state_hash, continuousState.wasm_motion.state_hash);
|
||||
assert.deepEqual(traced.session.workspacePackageState().wasm_motion.state.position, continuousState.wasm_motion.state.position);
|
||||
|
||||
const damaged = structuredClone(checkpoint);
|
||||
damaged.state.wasm_motion.bytes = `${damaged.state.wasm_motion.bytes.startsWith("00") ? "ff" : "00"}${damaged.state.wasm_motion.bytes.slice(2)}`;
|
||||
damaged.state_hash = fnv1a64(damaged.state);
|
||||
const blocked = recovered.session.restoreCheckpoint(damaged, null, (operation) => operation);
|
||||
assert.equal(blocked.status, "blocked");
|
||||
|
||||
console.log(JSON.stringify({
|
||||
schema: "wasm-motion-checkpoint-t3.v1",
|
||||
status: "verified",
|
||||
case: CASE,
|
||||
checkpoint_hash: checkpoint.checkpoint_hash,
|
||||
continuous_hash: fnv1a64(continuousState),
|
||||
recovered_hash: fnv1a64(recoveredState),
|
||||
endpoint_equal: fnv1a64(continuousState) === fnv1a64(recoveredState),
|
||||
trace_import: traceImport.status,
|
||||
checkpoint_queue_depth: checkpoint.state.wasm_motion.state.queue_depth,
|
||||
blocked_damaged_checkpoint: blocked.status,
|
||||
}));
|
||||
24
web/worker/test_wasm_motion_event.mjs
Normal file
24
web/worker/test_wasm_motion_event.mjs
Normal file
@@ -0,0 +1,24 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
|
||||
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
|
||||
|
||||
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
|
||||
const session = new SimSession(core, { sessionId: "wasm-motion-event" });
|
||||
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
|
||||
let sequence = 0;
|
||||
const send = (kind, operationId, payload = {}) => session.accept({ ...base, kind, seq: ++sequence, operation_id: operationId, payload });
|
||||
|
||||
send("HELLO", "hello");
|
||||
send("SESSION_ATTACH", "attach");
|
||||
send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace" });
|
||||
send("PROGRAM_LOAD", "program", { source: "G21 G90\nG0 X0 Y0 Z0\nG1 X10 Y5 F120\nG17 G02 I1 J1\nM2\n", path: "wasm-motion-event.ngc" });
|
||||
send("PROGRAM_RUN", "run");
|
||||
const first = send("SIM_STEP", "step-1", { cycles: 4 });
|
||||
const events = first.payload.program_steps.filter((step) => step.motion?.source === "wasm_interpreter_canonical_motion");
|
||||
assert.ok(events.length >= 1);
|
||||
assert.equal(events[0].motion.start.x, 0);
|
||||
assert.equal(events[0].motion.end.x, 0);
|
||||
assert.equal(events.at(-1).motion.motion_type, 2);
|
||||
assert.equal(events.at(-1).motion.source_line, 4);
|
||||
console.log(JSON.stringify({ schema: "wasm-motion-event.v1", status: "verified", events: events.length, last_motion: events.at(-1).motion }));
|
||||
26
web/worker/test_wasm_preview.mjs
Normal file
26
web/worker/test_wasm_preview.mjs
Normal file
@@ -0,0 +1,26 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
|
||||
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
|
||||
|
||||
const sourcePath = "linuxcnc/nc_files/3dtest.ngc";
|
||||
const source = await readFile(new URL(`../../${sourcePath}`, import.meta.url), "utf8");
|
||||
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
|
||||
const session = new SimSession(core, { sessionId: "wasm-preview" });
|
||||
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
|
||||
let sequence = 0;
|
||||
const send = (kind, operationId, payload = {}) => session.accept({ ...base, kind, seq: ++sequence, operation_id: operationId, payload });
|
||||
|
||||
send("HELLO", "hello");
|
||||
send("SESSION_ATTACH", "attach");
|
||||
send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace" });
|
||||
const loaded = send("PROGRAM_LOAD", "program", { source, path: sourcePath });
|
||||
assert.equal(loaded.payload.preview?.status, "verified");
|
||||
const segments = loaded.payload.preview.segments;
|
||||
assert.equal(segments.length, 49);
|
||||
const arcs = segments.filter((segment) => [6, 23, 39].includes(segment.source_line));
|
||||
assert.equal(arcs.length, 3);
|
||||
assert.ok(arcs.every((segment) => segment.motion_type === 2));
|
||||
assert.deepEqual(arcs.map((segment) => segment.normal), [{ x: 0, y: 0, z: 1 }, { x: 0, y: 1, z: 0 }, { x: 1, y: 0, z: 0 }]);
|
||||
assert.ok(arcs.every((segment) => segment.source === "wasm_interpreter_canonical_motion"));
|
||||
console.log(JSON.stringify({ schema: "wasm-preview.v1", status: "verified", source_path: sourcePath, source_lines: source.split(/\r?\n/).length, segment_count: segments.length, arc_lines: [6, 23, 39], source: "wasm_interpreter_canonical_motion" }));
|
||||
136
web/worker/wasm-home-limit.mjs
Normal file
136
web/worker/wasm-home-limit.mjs
Normal file
@@ -0,0 +1,136 @@
|
||||
import { fnv1a64 } from "./replay.mjs";
|
||||
|
||||
const HOME_LIMIT_SCHEMA = "sim_home_limit_state.v1";
|
||||
const KIN_TYPE_TRIVKINS = 1;
|
||||
const KIN_CONFIG_SIZE = 1608;
|
||||
const KIN_SIZE = 1760;
|
||||
const HOME_LIMIT_SIZE = 184;
|
||||
const HOME_STATE_SIZE = 176;
|
||||
const HOME_STATE_OFFSET = 8;
|
||||
const CONFIG_OFFSETS = Object.freeze({ type: 0, mode: 4, joint_count: 8, kinematics_joint_count: 12, extra_joint_count: 16, coordinates: 20, limit_enabled: 1448, joint_min: 1464, joint_max: 1536 });
|
||||
const HOME_STATE_OFFSETS = Object.freeze({ pose: 0, joints: 72, joint_count: 144, homed: 148, home_sequence: 152, tick: 156, last_error: 160, limit_error: 164, kinematics_error: 168 });
|
||||
|
||||
function call(core, name, returnType, argTypes, args) {
|
||||
const direct = core?.[`_${name}`] ?? core?.[name];
|
||||
if (typeof direct === "function") return direct(...args);
|
||||
if (typeof core?.ccall === "function") return core.ccall(name, returnType, argTypes, args);
|
||||
throw new Error(`missing core export: ${name}`);
|
||||
}
|
||||
|
||||
function bytesToHex(bytes) {
|
||||
let output = "";
|
||||
for (const byte of bytes) output += byte.toString(16).padStart(2, "0");
|
||||
return output;
|
||||
}
|
||||
|
||||
function hexToBytes(value) {
|
||||
if (typeof value !== "string" || value.length !== HOME_STATE_SIZE * 2 || !/^[0-9a-f]+$/i.test(value)) throw new Error("wasm_home_state_bytes_invalid");
|
||||
const bytes = new Uint8Array(HOME_STATE_SIZE);
|
||||
for (let index = 0; index < bytes.length; index += 1) bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function pose(heap, offset) {
|
||||
const doubles = heap.HEAPF64.subarray(offset / 8, offset / 8 + 9);
|
||||
return { x: doubles[0], y: doubles[1], z: doubles[2], a: doubles[3], b: doubles[4], c: doubles[5], u: doubles[6], v: doubles[7], w: doubles[8] };
|
||||
}
|
||||
|
||||
function finitePose(value) {
|
||||
return value && [value.x, value.y, value.z, value.a, value.b, value.c, value.u, value.v, value.w].every((entry) => typeof entry === "number" && Number.isFinite(entry));
|
||||
}
|
||||
|
||||
export class WasmHomeLimit {
|
||||
constructor(core, { jointCount = 3, limits = {} } = {}) {
|
||||
this.core = core;
|
||||
this.available = ["sim_kinematics_init", "sim_kinematics_home_limit_init", "sim_kinematics_home_limit_reset", "sim_kinematics_home_limit_home", "sim_kinematics_home_limit_check", "sim_kinematics_home_limit_read"].every((name) => typeof core?.[`_${name}`] === "function" || typeof core?.[name] === "function");
|
||||
this.jointCount = jointCount;
|
||||
this.config = this.kinematics = this.homeLimit = this.state = this.joints = 0;
|
||||
this.stateBuffer = 0;
|
||||
this.limits = { x: [-500, 500], y: [-100, 100], z: [-240, 0.001], ...limits };
|
||||
if (!this.available || typeof core?._malloc !== "function") return;
|
||||
this.config = core._malloc(KIN_CONFIG_SIZE);
|
||||
this.kinematics = core._malloc(KIN_SIZE);
|
||||
this.homeLimit = core._malloc(HOME_LIMIT_SIZE);
|
||||
this.stateBuffer = core._malloc(HOME_STATE_SIZE);
|
||||
this.joints = core._malloc(9 * 8);
|
||||
if (![this.config, this.kinematics, this.homeLimit, this.stateBuffer, this.joints].every(Boolean)) throw new Error("wasm_home_limit_allocation_failed");
|
||||
this.core.HEAPU8.fill(0, this.config, this.config + KIN_CONFIG_SIZE);
|
||||
this.core.HEAPU8.fill(0, this.kinematics, this.kinematics + KIN_SIZE);
|
||||
this.core.HEAPU8.fill(0, this.homeLimit, this.homeLimit + HOME_LIMIT_SIZE);
|
||||
this.core.HEAPU8.fill(0, this.stateBuffer, this.stateBuffer + HOME_STATE_SIZE);
|
||||
this.core.HEAPU8.fill(0, this.joints, this.joints + 9 * 8);
|
||||
const configView = new DataView(this.core.HEAPU8.buffer, this.config, KIN_CONFIG_SIZE);
|
||||
configView.setInt32(CONFIG_OFFSETS.type, KIN_TYPE_TRIVKINS, true);
|
||||
configView.setInt32(CONFIG_OFFSETS.mode, 0, true);
|
||||
configView.setUint32(CONFIG_OFFSETS.joint_count, jointCount, true);
|
||||
configView.setUint32(CONFIG_OFFSETS.kinematics_joint_count, jointCount, true);
|
||||
configView.setUint32(CONFIG_OFFSETS.extra_joint_count, 0, true);
|
||||
for (const [index, axis] of ["X", "Y", "Z"].entries()) this.core.HEAPU8[this.config + CONFIG_OFFSETS.coordinates + index] = axis.charCodeAt(0);
|
||||
for (const [index, axis] of ["x", "y", "z"].entries()) {
|
||||
const limit = this.limits[axis] ?? [-1e9, 1e9];
|
||||
this.core.HEAPU8[this.config + CONFIG_OFFSETS.limit_enabled + index] = 1;
|
||||
configView.setFloat64(CONFIG_OFFSETS.joint_min + index * 8, limit[0], true);
|
||||
configView.setFloat64(CONFIG_OFFSETS.joint_max + index * 8, limit[1], true);
|
||||
}
|
||||
const initialized = call(core, "sim_kinematics_init", "number", ["number", "number"], [this.kinematics, this.config]);
|
||||
if (initialized !== 0) throw new Error(`wasm_home_kinematics_init_${initialized}`);
|
||||
const homeInitialized = call(core, "sim_kinematics_home_limit_init", "number", ["number", "number", "number"], [this.homeLimit, this.kinematics, jointCount]);
|
||||
if (homeInitialized !== 0) throw new Error(`wasm_home_limit_init_${homeInitialized}`);
|
||||
}
|
||||
|
||||
read() {
|
||||
if (!this.available) return { status: "blocked", state: { schema: HOME_LIMIT_SCHEMA, source: "wasm_home_limit_unavailable", homed: false, home_sequence: 0, tick: 0, joints: [0, 0, 0], pose: { x: 0, y: 0, z: 0 }, last_error: -840, limit_error: 0, kinematics_error: 0 } };
|
||||
const result = call(this.core, "sim_kinematics_home_limit_read", "number", ["number", "number"], [this.homeLimit, this.stateBuffer]);
|
||||
if (result !== 0) return { status: "error", code: `wasm_home_limit_read_${result}` };
|
||||
const ints = this.core.HEAPU32;
|
||||
const signed = this.core.HEAP32;
|
||||
const joints = [...this.core.HEAPF64.subarray(this.stateBuffer / 8 + 9, this.stateBuffer / 8 + 12)];
|
||||
return { status: "verified", state: { schema: HOME_LIMIT_SCHEMA, source: "wasm_home_limit", pose: pose(this.core, this.stateBuffer), joints, joint_count: ints[this.stateBuffer / 4 + 36], homed: Boolean(ints[this.stateBuffer / 4 + 37]), home_sequence: ints[this.stateBuffer / 4 + 38], tick: ints[this.stateBuffer / 4 + 39], last_error: signed[this.stateBuffer / 4 + 40], limit_error: signed[this.stateBuffer / 4 + 41], kinematics_error: signed[this.stateBuffer / 4 + 42] } };
|
||||
}
|
||||
|
||||
home(sequence = 0) {
|
||||
if (!this.available) return this.read();
|
||||
const result = call(this.core, "sim_kinematics_home_limit_home", "number", ["number", "number"], [this.homeLimit, sequence]);
|
||||
if (result !== 0) return { status: "error", code: `wasm_home_${result}`, state: this.read().state };
|
||||
return this.read();
|
||||
}
|
||||
|
||||
check(joints) {
|
||||
if (!this.available) return this.read();
|
||||
if (!Array.isArray(joints) || joints.length < this.jointCount || joints.some((value) => typeof value !== "number" || !Number.isFinite(value))) return { status: "error", code: "wasm_home_joints_invalid" };
|
||||
this.core.HEAPF64.set(joints.slice(0, this.jointCount), this.joints / 8);
|
||||
const result = call(this.core, "sim_kinematics_home_limit_check", "number", ["number", "number", "number"], [this.homeLimit, this.joints, this.jointCount]);
|
||||
return result === 0 ? this.read() : { status: "error", code: `wasm_home_check_${result}`, state: this.read().state };
|
||||
}
|
||||
|
||||
reset() {
|
||||
if (!this.available) return this.read();
|
||||
const result = call(this.core, "sim_kinematics_home_limit_reset", "number", ["number"], [this.homeLimit]);
|
||||
if (result !== 0) throw new Error(`wasm_home_limit_reset_${result}`);
|
||||
return this.read();
|
||||
}
|
||||
|
||||
exportState() {
|
||||
const current = this.read();
|
||||
const stateBytes = this.available ? bytesToHex(this.core.HEAPU8.slice(this.homeLimit + HOME_STATE_OFFSET, this.homeLimit + HOME_STATE_OFFSET + HOME_STATE_SIZE)) : null;
|
||||
const unsigned = { schema: HOME_LIMIT_SCHEMA, available: this.available, joint_count: this.jointCount, state: current.state, state_bytes: stateBytes };
|
||||
return { ...unsigned, state_hash: fnv1a64(unsigned) };
|
||||
}
|
||||
|
||||
restoreState(value) {
|
||||
if (!value || value.schema !== HOME_LIMIT_SCHEMA) throw new Error("wasm_home_restore_schema");
|
||||
const unsigned = structuredClone(value);
|
||||
delete unsigned.state_hash;
|
||||
if (value.state_hash !== fnv1a64(unsigned)) throw new Error("wasm_home_restore_hash");
|
||||
if (!this.available || value.available !== true || value.joint_count !== this.jointCount) throw new Error("wasm_home_restore_unavailable");
|
||||
const bytes = hexToBytes(value.state_bytes);
|
||||
this.core.HEAPU8.set(bytes, this.homeLimit + HOME_STATE_OFFSET);
|
||||
const actual = this.read();
|
||||
if (actual.status !== "verified" || fnv1a64(actual.state) !== fnv1a64(value.state)) throw new Error("wasm_home_restore_state_mismatch");
|
||||
return this.exportState();
|
||||
}
|
||||
|
||||
destroy() { for (const pointer of [this.config, this.kinematics, this.homeLimit, this.stateBuffer, this.joints]) if (pointer && typeof this.core?._free === "function") this.core._free(pointer); this.config = this.kinematics = this.homeLimit = this.stateBuffer = this.joints = 0; }
|
||||
}
|
||||
|
||||
export { HOME_LIMIT_SCHEMA };
|
||||
240
web/worker/wasm-motion-queue.mjs
Normal file
240
web/worker/wasm-motion-queue.mjs
Normal file
@@ -0,0 +1,240 @@
|
||||
import { fnv1a64 } from "./replay.mjs";
|
||||
|
||||
const POSE_DOUBLES = 9;
|
||||
const SEGMENT_DOUBLES = POSE_DOUBLES * 2 + 3 + 3 + 2;
|
||||
const SEGMENT_INT_OFFSET = SEGMENT_DOUBLES * 2;
|
||||
const QUEUE_HEADER_BYTES = 32;
|
||||
const STATE_POSITION_BYTES = 72;
|
||||
const MOTION_QUEUE_SCHEMA = "sim_motion_queue_state.v1";
|
||||
const DEFAULT_CONTROLS = Object.freeze({ profile: 0, lookahead: 1, acceleration_limit: 1e9, jerk_limit: 1e12, paused: false });
|
||||
|
||||
function call(core, name, returnType, argTypes, args) {
|
||||
const fn = core[`_${name}`] ?? core[name];
|
||||
if (typeof fn === "function") return fn(...args);
|
||||
if (typeof core.ccall === "function") {
|
||||
try { return core.ccall(name, returnType, argTypes, args); } catch (error) { throw new Error(`missing core export: ${name}`, { cause: error }); }
|
||||
}
|
||||
throw new Error(`missing core export: ${name}`);
|
||||
}
|
||||
|
||||
function optionalCall(core, name, returnType, argTypes, args) {
|
||||
const fn = core[`_${name}`] ?? core[name];
|
||||
if (typeof fn === "function") return fn(...args);
|
||||
if (typeof core.ccall === "function") {
|
||||
try { return core.ccall(name, returnType, argTypes, args); } catch { return null; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function poseToArray(pose = {}) {
|
||||
return ["x", "y", "z", "a", "b", "c", "u", "v", "w"].map((axis) => Number(pose[axis] ?? 0));
|
||||
}
|
||||
|
||||
function bytesToHex(bytes) {
|
||||
let output = "";
|
||||
for (const byte of bytes) output += byte.toString(16).padStart(2, "0");
|
||||
return output;
|
||||
}
|
||||
|
||||
function hexToBytes(value) {
|
||||
if (typeof value !== "string" || value.length % 2 !== 0 || !/^[0-9a-f]*$/i.test(value)) throw new Error("wasm_motion_state_bytes_invalid");
|
||||
const bytes = new Uint8Array(value.length / 2);
|
||||
for (let index = 0; index < bytes.length; index += 1) bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function controls(value = {}) {
|
||||
const result = {
|
||||
profile: value.profile ?? DEFAULT_CONTROLS.profile,
|
||||
lookahead: value.lookahead ?? DEFAULT_CONTROLS.lookahead,
|
||||
acceleration_limit: value.acceleration_limit ?? DEFAULT_CONTROLS.acceleration_limit,
|
||||
jerk_limit: value.jerk_limit ?? DEFAULT_CONTROLS.jerk_limit,
|
||||
paused: value.paused ?? DEFAULT_CONTROLS.paused,
|
||||
};
|
||||
if (![0, 1].includes(result.profile) || !Number.isSafeInteger(result.lookahead) || result.lookahead < 1 || !Number.isFinite(result.acceleration_limit) || result.acceleration_limit <= 0 || !Number.isFinite(result.jerk_limit) || result.jerk_limit <= 0 || typeof result.paused !== "boolean") throw new Error("wasm_motion_controls_invalid");
|
||||
return result;
|
||||
}
|
||||
|
||||
export class WasmMotionQueue {
|
||||
constructor(core, { cycleTime = 0.01, velocityLimit = 1e9 } = {}) {
|
||||
this.core = core;
|
||||
this.available = ["sim_motion_size", "sim_motion_command_size", "sim_motion_state_size", "sim_motion_init", "sim_motion_enqueue", "sim_motion_step", "sim_motion_read_state"].every((name) => typeof core?.[`_${name}`] === "function" || typeof core?.[name] === "function");
|
||||
this.queue = 0;
|
||||
this.command = 0;
|
||||
this.state = 0;
|
||||
this.commandCapacity = 0;
|
||||
this.stateCapacity = 0;
|
||||
this.queueSize = 0;
|
||||
this.queueCapacity = 0;
|
||||
this.cycleTime = cycleTime;
|
||||
this.velocityLimit = velocityLimit;
|
||||
this.controls = controls();
|
||||
if (!this.available) return;
|
||||
const queueSize = call(core, "sim_motion_size", "number", [], []);
|
||||
this.queueSize = queueSize;
|
||||
this.commandCapacity = call(core, "sim_motion_command_size", "number", [], []);
|
||||
this.stateCapacity = call(core, "sim_motion_state_size", "number", [], []);
|
||||
if (![queueSize, this.commandCapacity, this.stateCapacity].every((value) => Number.isSafeInteger(value) && value > 0)) throw new Error("wasm_motion_queue_size_invalid");
|
||||
this.queueCapacity = (queueSize - QUEUE_HEADER_BYTES - this.stateCapacity) / this.commandCapacity;
|
||||
if (!Number.isSafeInteger(this.queueCapacity) || this.queueCapacity < 1) throw new Error("wasm_motion_queue_capacity_invalid");
|
||||
this.queue = core._malloc(queueSize);
|
||||
this.command = core._malloc(this.commandCapacity);
|
||||
this.state = core._malloc(this.stateCapacity);
|
||||
if (!this.queue || !this.command || !this.state) throw new Error("wasm_motion_queue_allocation_failed");
|
||||
this.core.HEAPU8.fill(0, this.queue, this.queue + queueSize);
|
||||
const result = call(core, "sim_motion_init", "number", ["number", "number", "number"], [this.queue, cycleTime, velocityLimit]);
|
||||
if (result !== 0) throw new Error(`wasm_motion_init_${result}`);
|
||||
}
|
||||
|
||||
enqueue(segment) {
|
||||
if (!this.available) return { status: "skipped", code: "wasm_motion_unavailable" };
|
||||
if (!segment) return { status: "skipped" };
|
||||
const heap = this.core.HEAPU8;
|
||||
heap.fill(0, this.command, this.command + this.commandCapacity);
|
||||
const doubles = this.core.HEAPF64;
|
||||
const ints = this.core.HEAP32;
|
||||
const base = this.command / 8;
|
||||
[...poseToArray(segment.start), ...poseToArray(segment.end), Number(segment.center?.x ?? 0), Number(segment.center?.y ?? 0), Number(segment.center?.z ?? 0), Number(segment.normal?.x ?? 0), Number(segment.normal?.y ?? 0), Number(segment.normal?.z ?? 0), Number(segment.velocity ?? 0), Number(segment.acceleration ?? 0)].forEach((value, index) => { doubles[base + index] = value; });
|
||||
ints[this.command / 4 + SEGMENT_INT_OFFSET] = Number(segment.motion_type ?? 1);
|
||||
ints[this.command / 4 + SEGMENT_INT_OFFSET + 1] = Number(segment.turn ?? 0);
|
||||
const result = call(this.core, "sim_motion_enqueue", "number", ["number", "number", "number"], [this.queue, this.command, 0]);
|
||||
return result === 0 ? { status: "verified", source_line: segment.source_line ?? null } : { status: "error", code: `wasm_motion_enqueue_${result}` };
|
||||
}
|
||||
|
||||
reset() {
|
||||
if (!this.available) return this.read();
|
||||
this.core.HEAPU8.fill(0, this.queue, this.queue + this.queueSize);
|
||||
const result = call(this.core, "sim_motion_init", "number", ["number", "number", "number"], [this.queue, this.cycleTime, this.velocityLimit]);
|
||||
if (result !== 0) throw new Error(`wasm_motion_reset_${result}`);
|
||||
this.controls = controls();
|
||||
return this.read();
|
||||
}
|
||||
|
||||
setProfile(profile) {
|
||||
const next = controls({ ...this.controls, profile });
|
||||
if (!this.available) { this.controls = next; return this.read(); }
|
||||
const result = optionalCall(this.core, "sim_motion_set_profile", "number", ["number", "number"], [this.queue, next.profile]);
|
||||
if (result !== null && result !== 0) throw new Error(`wasm_motion_profile_${result}`);
|
||||
this.controls = next;
|
||||
return this.read();
|
||||
}
|
||||
|
||||
setLookahead(lookahead) {
|
||||
const next = controls({ ...this.controls, lookahead });
|
||||
if (!this.available) { this.controls = next; return this.read(); }
|
||||
const result = optionalCall(this.core, "sim_motion_set_lookahead", "number", ["number", "number"], [this.queue, next.lookahead]);
|
||||
if (result !== null && result !== 0) throw new Error(`wasm_motion_lookahead_${result}`);
|
||||
this.controls = next;
|
||||
return this.read();
|
||||
}
|
||||
|
||||
setDynamics(accelerationLimit, jerkLimit) {
|
||||
const next = controls({ ...this.controls, acceleration_limit: accelerationLimit, jerk_limit: jerkLimit });
|
||||
if (!this.available) { this.controls = next; return this.read(); }
|
||||
const result = optionalCall(this.core, "sim_motion_set_dynamics", "number", ["number", "number", "number"], [this.queue, next.acceleration_limit, next.jerk_limit]);
|
||||
if (result !== null && result !== 0) throw new Error(`wasm_motion_dynamics_${result}`);
|
||||
this.controls = next;
|
||||
return this.read();
|
||||
}
|
||||
|
||||
setPaused(paused) {
|
||||
const next = controls({ ...this.controls, paused: Boolean(paused) });
|
||||
if (!this.available) { this.controls = next; return this.read(); }
|
||||
const result = optionalCall(this.core, "sim_motion_set_paused", "number", ["number", "number"], [this.queue, next.paused ? 1 : 0]);
|
||||
if (result !== null && result !== 0) throw new Error(`wasm_motion_pause_${result}`);
|
||||
this.controls = next;
|
||||
return this.read();
|
||||
}
|
||||
|
||||
abort() {
|
||||
if (!this.available) return this.read();
|
||||
const current = this.read();
|
||||
if (current.status !== "verified") throw new Error("wasm_motion_abort_state");
|
||||
const previous = this.core.HEAPU8.slice(this.queue, this.queue + this.queueSize);
|
||||
const headerOffset = this.queueCapacity * this.commandCapacity;
|
||||
const stateOffset = headerOffset + QUEUE_HEADER_BYTES;
|
||||
const nextId = new DataView(previous.buffer, previous.byteOffset, previous.byteLength).getUint32(8, true);
|
||||
this.reset();
|
||||
const bytes = new Uint8Array(this.queueSize);
|
||||
const view = new DataView(bytes.buffer);
|
||||
bytes.set(previous.subarray(16, 32), 16);
|
||||
view.setUint32(8, nextId, true);
|
||||
view.setUint32(12, current.state.tick, true);
|
||||
bytes.set(previous.subarray(stateOffset, stateOffset + STATE_POSITION_BYTES), stateOffset);
|
||||
view.setFloat64(stateOffset + 72, 0, true);
|
||||
view.setFloat64(stateOffset + 80, 0, true);
|
||||
view.setUint32(stateOffset + 88, current.state.tick, true);
|
||||
view.setUint32(stateOffset + 92, 0, true);
|
||||
view.setUint32(stateOffset + 96, 0, true);
|
||||
view.setUint32(stateOffset + 100, 0, true);
|
||||
view.setInt32(stateOffset + 104, 0, true);
|
||||
view.setInt32(stateOffset + 108, 0, true);
|
||||
this.core.HEAPU8.set(bytes, this.queue);
|
||||
return this.read();
|
||||
}
|
||||
|
||||
step() {
|
||||
if (!this.available) return { status: "error", code: "wasm_motion_step_-605" };
|
||||
if (this.controls.paused) return { status: "error", code: "wasm_motion_step_-605" };
|
||||
const result = call(this.core, "sim_motion_step", "number", ["number"], [this.queue]);
|
||||
if (result !== 0) return { status: "error", code: `wasm_motion_step_${result}` };
|
||||
return this.read();
|
||||
}
|
||||
|
||||
read() {
|
||||
if (!this.available) return { status: "blocked", state: { position: { x: 0, y: 0, z: 0 }, velocity: 0, progress: 0, tick: 0, queue_depth: 0, active_id: 0, active_tag: 0, motion_type: 0, status: -605, source: "wasm_motion_queue_unavailable" } };
|
||||
const result = call(this.core, "sim_motion_read_state", "number", ["number", "number"], [this.queue, this.state]);
|
||||
if (result !== 0) return { status: "error", code: `wasm_motion_read_${result}` };
|
||||
const doubles = this.core.HEAPF64.subarray(this.state / 8, this.state / 8 + 11);
|
||||
const ints = this.core.HEAPU32.subarray(this.state / 4 + 22, this.state / 4 + 26);
|
||||
return { status: "verified", state: { position: { x: doubles[0], y: doubles[1], z: doubles[2] }, velocity: this.controls.paused ? 0 : doubles[9], progress: doubles[10], tick: ints[0], queue_depth: ints[1], active_id: ints[2], active_tag: ints[3], motion_type: this.core.HEAP32[this.state / 4 + 26], status: this.core.HEAP32[this.state / 4 + 27], source: "wasm_motion_queue" } };
|
||||
}
|
||||
|
||||
exportState() {
|
||||
if (!this.available) {
|
||||
const unsigned = { schema: MOTION_QUEUE_SCHEMA, available: false, controls: { ...this.controls }, state: this.read().state };
|
||||
return { ...unsigned, state_hash: fnv1a64(unsigned) };
|
||||
}
|
||||
const current = this.read();
|
||||
if (current.status !== "verified") throw new Error("wasm_motion_state_unavailable");
|
||||
const unsigned = {
|
||||
schema: MOTION_QUEUE_SCHEMA,
|
||||
queue_size: this.queueSize,
|
||||
command_size: this.commandCapacity,
|
||||
state_size: this.stateCapacity,
|
||||
queue_capacity: this.queueCapacity,
|
||||
cycle_time: this.cycleTime,
|
||||
velocity_limit: this.velocityLimit,
|
||||
controls: { ...this.controls },
|
||||
state: current.state,
|
||||
bytes: bytesToHex(this.core.HEAPU8.slice(this.queue, this.queue + this.queueSize)),
|
||||
};
|
||||
return { ...unsigned, state_hash: fnv1a64(unsigned) };
|
||||
}
|
||||
|
||||
restoreState(value) {
|
||||
if (!this.available) throw new Error("wasm_motion_restore_unavailable");
|
||||
if (!value || value.schema !== MOTION_QUEUE_SCHEMA) throw new Error("wasm_motion_restore_schema");
|
||||
const unsigned = structuredClone(value);
|
||||
delete unsigned.state_hash;
|
||||
if (value.state_hash !== fnv1a64(unsigned)) throw new Error("wasm_motion_restore_hash");
|
||||
if (value.queue_size !== this.queueSize || value.command_size !== this.commandCapacity || value.state_size !== this.stateCapacity || value.queue_capacity !== this.queueCapacity || value.cycle_time !== this.cycleTime || value.velocity_limit !== this.velocityLimit) throw new Error("wasm_motion_restore_layout");
|
||||
const bytes = hexToBytes(value.bytes);
|
||||
if (bytes.length !== this.queueSize) throw new Error("wasm_motion_restore_bytes");
|
||||
const nextControls = controls(value.controls);
|
||||
this.core.HEAPU8.fill(0, this.queue, this.queue + this.queueSize);
|
||||
const initialized = call(this.core, "sim_motion_init", "number", ["number", "number", "number"], [this.queue, this.cycleTime, this.velocityLimit]);
|
||||
if (initialized !== 0) throw new Error(`wasm_motion_restore_init_${initialized}`);
|
||||
this.controls = controls();
|
||||
this.setProfile(nextControls.profile);
|
||||
this.setLookahead(nextControls.lookahead);
|
||||
this.setDynamics(nextControls.acceleration_limit, nextControls.jerk_limit);
|
||||
this.setPaused(nextControls.paused);
|
||||
this.core.HEAPU8.set(bytes, this.queue);
|
||||
const actual = this.read();
|
||||
if (actual.status !== "verified" || fnv1a64(actual.state) !== fnv1a64(value.state)) throw new Error("wasm_motion_restore_state_mismatch");
|
||||
return this.exportState();
|
||||
}
|
||||
|
||||
destroy() { for (const pointer of [this.queue, this.command, this.state]) if (pointer && typeof this.core._free === "function") this.core._free(pointer); this.queue = this.command = this.state = 0; }
|
||||
}
|
||||
Reference in New Issue
Block a user