Make machine configuration authoritative and restore native checkpoints exactly
This commit is contained in:
@@ -1,17 +1,19 @@
|
||||
import { fnv1a64 } from "./replay.mjs";
|
||||
import { WEB_SETTINGS } from "../workspace/web-settings.mjs";
|
||||
const CONFIG = WEB_SETTINGS.runtime.compatibility;
|
||||
|
||||
export const AUTHORITATIVE_RUNTIME_SCHEMA = "sim_authoritative_runtime.v1";
|
||||
const AXES = Object.freeze(["x", "y", "z"]);
|
||||
const STEP_FRACTION = 0.25;
|
||||
const STEP_FRACTION = CONFIG.step_fraction;
|
||||
const KINEMATICS = new Set(["trivkins", "corexy", "tripod", "lineardelta", "rotarydelta"]);
|
||||
const KINEMATICS_ALIASES = new Map([["corexykins", "corexy"], ["tripodkins", "tripod"], ["lineardeltakins", "lineardelta"], ["rotarydeltakins", "rotarydelta"]]);
|
||||
const TRIPOD_BASE = Object.freeze([[0, 0], [10, 0], [5, 7.071]]);
|
||||
const LINEAR_DELTA_RADIUS = 130.25;
|
||||
const LINEAR_DELTA_ROD = 269;
|
||||
const ROTARY_DELTA_PLATFORM = 10;
|
||||
const ROTARY_DELTA_THIGH = 10;
|
||||
const ROTARY_DELTA_SHIN = 14;
|
||||
const ROTARY_DELTA_FOOT = 6;
|
||||
const TRIPOD_BASE = CONFIG.tripod_base;
|
||||
const LINEAR_DELTA_RADIUS = CONFIG.linear_delta.radius;
|
||||
const LINEAR_DELTA_ROD = CONFIG.linear_delta.rod;
|
||||
const ROTARY_DELTA_PLATFORM = CONFIG.rotary_delta.platform;
|
||||
const ROTARY_DELTA_THIGH = CONFIG.rotary_delta.thigh;
|
||||
const ROTARY_DELTA_SHIN = CONFIG.rotary_delta.shin;
|
||||
const ROTARY_DELTA_FOOT = CONFIG.rotary_delta.foot;
|
||||
const DEG_TO_RAD = Math.PI / 180;
|
||||
const RAD_TO_DEG = 180 / Math.PI;
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { fnv1a64 } from "./replay.mjs";
|
||||
import { WEB_SETTINGS } from "../workspace/web-settings.mjs";
|
||||
const CONFIG = WEB_SETTINGS.runtime.compatibility;
|
||||
|
||||
export const MOTION_SCHEMA = "sim_motion.v1";
|
||||
const AXES = Object.freeze(["x", "y", "z"]);
|
||||
const MAX_POSITION = 1e9;
|
||||
const MAX_SPEED = 1e6;
|
||||
const CYCLE_DISTANCE = 0.001;
|
||||
const MAX_POSITION = CONFIG.max_position;
|
||||
const MAX_SPEED = CONFIG.max_speed;
|
||||
const CYCLE_DISTANCE = CONFIG.cycle_time;
|
||||
|
||||
function clone(value) { return structuredClone(value); }
|
||||
function finite(value) { return typeof value === "number" && Number.isFinite(value); }
|
||||
|
||||
55
web/worker/native-checkpoint.mjs
Normal file
55
web/worker/native-checkpoint.mjs
Normal file
@@ -0,0 +1,55 @@
|
||||
import { fnv1a64 } from "./replay.mjs";
|
||||
|
||||
function buildId(core) {
|
||||
if (typeof core._sim_checkpoint_build_id !== "function") throw new Error("native_checkpoint_build_id_missing");
|
||||
const pointer = core._sim_checkpoint_build_id();
|
||||
const id = new TextDecoder().decode(core.HEAPU8.subarray(pointer, pointer + 64));
|
||||
if (!/^[a-f0-9]{64}$/.test(id)) throw new Error("native_checkpoint_unversioned_build");
|
||||
return id;
|
||||
}
|
||||
|
||||
// Copy opaque native state with zero-filled blocks omitted. No arithmetic or
|
||||
// state reconstruction occurs here; each C adapter owns its layout/validation.
|
||||
export function exportNativeCheckpoint(core, prefix, owner = null) {
|
||||
const length = core[`_${prefix}_checkpoint_size`]();
|
||||
const pointer = core._malloc(length);
|
||||
if (!pointer) throw new Error(`${prefix}_checkpoint_allocation`);
|
||||
try {
|
||||
const args = owner === null ? [pointer, length] : [owner, pointer, length];
|
||||
const code = core[`_${prefix}_checkpoint_export`](...args);
|
||||
if (code !== 0) throw new Error(`${prefix}_checkpoint_export_${code}`);
|
||||
const bytes = core.HEAPU8.subarray(pointer, pointer + length), blocks = [];
|
||||
for (let offset = 0; offset < length; offset += 256) {
|
||||
const block = bytes.subarray(offset, Math.min(offset + 256, length));
|
||||
if (block.some((value) => value !== 0)) blocks.push([offset, Array.from(block, (value) => value.toString(16).padStart(2, "0")).join("")]);
|
||||
}
|
||||
const value = { schema: "native-checkpoint.v1", component: prefix, build_id: buildId(core), byte_length: length, blocks };
|
||||
return { ...value, state_hash: fnv1a64(value) };
|
||||
} finally { core._free(pointer); }
|
||||
}
|
||||
|
||||
export function validateNativeCheckpoint(core, prefix, value) {
|
||||
if (value?.build_id !== buildId(core)) throw new Error(`${prefix}_checkpoint_build_mismatch`);
|
||||
const length = core[`_${prefix}_checkpoint_size`]();
|
||||
if (!value || value.schema !== "native-checkpoint.v1" || value.component !== prefix || value.byte_length !== length || !Array.isArray(value.blocks) || fnv1a64(Object.fromEntries(Object.entries(value).filter(([key]) => key !== "state_hash"))) !== value.state_hash) throw new Error(`${prefix}_checkpoint_invalid`);
|
||||
const bytes = new Uint8Array(length); let end = 0;
|
||||
for (const [offset, hex] of value.blocks) {
|
||||
if (!Number.isSafeInteger(offset) || offset < end || typeof hex !== "string" || !/^(?:[0-9a-f]{2})+$/.test(hex) || offset + hex.length / 2 > length) throw new Error(`${prefix}_checkpoint_blocks`);
|
||||
for (let index = 0; index < hex.length; index += 2) bytes[offset + index / 2] = Number.parseInt(hex.slice(index, index + 2), 16);
|
||||
end = offset + hex.length / 2;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function restoreNativeCheckpoint(core, prefix, value, owner = null) {
|
||||
const bytes = validateNativeCheckpoint(core, prefix, value);
|
||||
const length = bytes.length;
|
||||
const pointer = core._malloc(length);
|
||||
if (!pointer) throw new Error(`${prefix}_checkpoint_allocation`);
|
||||
try {
|
||||
core.HEAPU8.set(bytes, pointer);
|
||||
const args = owner === null ? [pointer, length] : [owner, pointer, length];
|
||||
const code = core[`_${prefix}_checkpoint_restore`](...args);
|
||||
if (code !== 0) throw new Error(`${prefix}_checkpoint_restore_${code}`);
|
||||
} finally { core._free(pointer); }
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fnv1a64 } from "./replay.mjs";
|
||||
import { DEFAULT_MACHINE } from "../workspace/machine-definitions.mjs";
|
||||
|
||||
export const OPFS_UI_STATE_SCHEMA = "linuxcnc-ui-temp-json.v1";
|
||||
export const OPFS_UI_STATE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
@@ -77,7 +78,7 @@ async function writeFile(project, name, value) {
|
||||
return { status: "verified", bytes: bytes.byteLength };
|
||||
}
|
||||
|
||||
export async function loadUiState({ contextId, key = "xyzac-trt", storage } = {}) {
|
||||
export async function loadUiState({ contextId, key = DEFAULT_MACHINE.id, storage } = {}) {
|
||||
const resolved = await handles(key, false, storage).catch((error) => ({ status: "blocked", code: "opfs_open_failed", reason: error?.name ?? "unknown" }));
|
||||
if (resolved.status === "blocked" && resolved.reason === "NotFoundError") return emptyResult(contextId, key);
|
||||
if (resolved.status !== "verified") return { schema: OPFS_UI_STATE_SCHEMA, ...resolved, context_id: contextId, key };
|
||||
@@ -92,7 +93,7 @@ export async function loadUiState({ contextId, key = "xyzac-trt", storage } = {}
|
||||
return { schema: OPFS_UI_STATE_SCHEMA, status: "verified", storage: "opfs", context_id: contextId, key, generation: selected.generation, state: selected.state, state_hash: selected.state_hash };
|
||||
}
|
||||
|
||||
export async function saveUiState({ contextId, key = "xyzac-trt", state, storage } = {}) {
|
||||
export async function saveUiState({ contextId, key = DEFAULT_MACHINE.id, state, storage } = {}) {
|
||||
if (!validObject(state) || !finiteTree(state)) return { schema: OPFS_UI_STATE_SCHEMA, status: "blocked", code: "opfs_ui_state_invalid", context_id: contextId, key };
|
||||
const resolved = await handles(key, true, storage).catch((error) => ({ status: "blocked", code: "opfs_open_failed", reason: error?.name ?? "unknown" }));
|
||||
if (resolved.status !== "verified") return { schema: OPFS_UI_STATE_SCHEMA, ...resolved, context_id: contextId, key };
|
||||
@@ -108,23 +109,23 @@ export async function saveUiState({ contextId, key = "xyzac-trt", state, storage
|
||||
return { schema: OPFS_UI_STATE_SCHEMA, status: "verified", storage: "opfs", context_id: contextId, key, generation, slot, state_hash: next.state_hash, bytes: written.bytes };
|
||||
}
|
||||
|
||||
export async function clearUiState({ contextId, key = "xyzac-trt", storage } = {}) {
|
||||
export async function clearUiState({ contextId, key = DEFAULT_MACHINE.id, storage } = {}) {
|
||||
const resolved = await handles(key, false, storage).catch((error) => ({ status: "blocked", code: "opfs_open_failed", reason: error?.name ?? "unknown" }));
|
||||
if (resolved.status !== "verified") return { schema: OPFS_UI_STATE_SCHEMA, ...resolved, context_id: contextId, key };
|
||||
for (const name of [...SLOT_NAMES, MANIFEST_NAME]) { try { await resolved.project.removeEntry(name); } catch {} }
|
||||
return { schema: OPFS_UI_STATE_SCHEMA, status: "verified", storage: "opfs", context_id: contextId, key, cleared: true };
|
||||
}
|
||||
|
||||
export async function savePowerOffCheckpoint({ contextId, checkpoint, key = "xyzac-trt", storage } = {}) {
|
||||
export async function savePowerOffCheckpoint({ contextId, checkpoint, key = DEFAULT_MACHINE.id, storage } = {}) {
|
||||
return saveUiState({ contextId, key: `${key}-power-off`, state: { kind: "power_off_checkpoint", checkpoint }, storage });
|
||||
}
|
||||
|
||||
export async function loadPowerOffCheckpoint({ contextId, key = "xyzac-trt", storage } = {}) {
|
||||
export async function loadPowerOffCheckpoint({ contextId, key = DEFAULT_MACHINE.id, storage } = {}) {
|
||||
const result = await loadUiState({ contextId, key: `${key}-power-off`, storage });
|
||||
if (result.status !== "verified" || result.state?.kind !== "power_off_checkpoint" || !result.state.checkpoint) return { ...result, checkpoint: null };
|
||||
return { ...result, checkpoint: result.state.checkpoint };
|
||||
}
|
||||
|
||||
export async function clearPowerOffCheckpoint({ contextId, key = "xyzac-trt", storage } = {}) {
|
||||
export async function clearPowerOffCheckpoint({ contextId, key = DEFAULT_MACHINE.id, storage } = {}) {
|
||||
return clearUiState({ contextId, key: `${key}-power-off`, storage });
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
savePowerOffCheckpoint,
|
||||
saveUiState,
|
||||
} from "./opfs-ui-state.mjs";
|
||||
import { DEFAULT_MACHINE, machineDefinition } from "../workspace/machine-definitions.mjs";
|
||||
|
||||
const UI_COMMANDS = new Set(["UI_STATE_SAVE", "UI_STATE_LOAD", "UI_STATE_CLEAR"]);
|
||||
|
||||
@@ -34,7 +35,7 @@ export async function handleWorkerMessage(session, input) {
|
||||
// UI cache is a Worker storage service, not a LinuxCNC command. It never
|
||||
// consumes the WASM protocol sequence or changes task/motion authority.
|
||||
const payload = object(input.payload);
|
||||
const options = { contextId: input.context_id, key: payload.key ?? "xyzac-trt" };
|
||||
const options = { contextId: input.context_id, key: payload.key ?? session.machineDefinition?.id ?? DEFAULT_MACHINE.id };
|
||||
const result = input.kind === "UI_STATE_SAVE"
|
||||
? await saveUiState({ ...options, state: payload.state })
|
||||
: input.kind === "UI_STATE_LOAD"
|
||||
@@ -44,18 +45,32 @@ export async function handleWorkerMessage(session, input) {
|
||||
}
|
||||
|
||||
let request = input;
|
||||
if (input?.kind === "LOAD_WORKSPACE" && !object(input.payload).recovery_checkpoint) {
|
||||
const persisted = await loadPowerOffCheckpoint({ contextId: input.context_id, key: "xyzac-trt" });
|
||||
if (persisted.status === "verified" && persisted.checkpoint) request = { ...input, payload: { ...object(input.payload), recovery_checkpoint: persisted.checkpoint } };
|
||||
let recoveryNotice = null;
|
||||
const configuration = object(input.payload).configuration;
|
||||
const requestedMachine = machineDefinition(configuration?.entrypoint);
|
||||
const key = requestedMachine?.id ?? session.machineDefinition?.id ?? DEFAULT_MACHINE.id;
|
||||
// Automatic restoration belongs to a new Worker session. Explicit INI
|
||||
// changes and machine switches must not be overwritten by saved state.
|
||||
if (input?.kind === "LOAD_WORKSPACE" && !session.machineDefinition && (!configuration?.entrypoint || requestedMachine) && !object(input.payload).recovery_checkpoint) {
|
||||
const persisted = await loadPowerOffCheckpoint({ contextId: input.context_id, key });
|
||||
if (persisted.status === "verified" && persisted.checkpoint) {
|
||||
try {
|
||||
if (configuration && session.core && typeof session.validateRecoveryGeometry === "function") session.validateRecoveryGeometry(persisted.checkpoint.state, configuration);
|
||||
request = { ...input, payload: { ...object(input.payload), recovery_checkpoint: persisted.checkpoint } };
|
||||
} catch (error) {
|
||||
recoveryNotice = { status: "ignored", code: String(error?.message ?? error), reason: "saved_checkpoint_incompatible_with_current_configuration" };
|
||||
}
|
||||
}
|
||||
}
|
||||
const response = session.accept(request);
|
||||
const payload = object(response?.payload);
|
||||
if (recoveryNotice) payload.recovery_persistence = recoveryNotice;
|
||||
if (input?.kind === "POWER" && response?.kind === "COMMAND_RESULT" && payload.status === "verified") {
|
||||
if (payload.active === false && payload.recovery_checkpoint) {
|
||||
const persisted = await savePowerOffCheckpoint({ contextId: input.context_id, checkpoint: payload.recovery_checkpoint, key: "xyzac-trt" });
|
||||
const persisted = await savePowerOffCheckpoint({ contextId: input.context_id, checkpoint: payload.recovery_checkpoint, key });
|
||||
payload.recovery_persistence = persisted;
|
||||
} else if (payload.active === true) {
|
||||
payload.recovery_persistence = await clearPowerOffCheckpoint({ contextId: input.context_id, key: "xyzac-trt" });
|
||||
payload.recovery_persistence = await clearPowerOffCheckpoint({ contextId: input.context_id, key });
|
||||
}
|
||||
}
|
||||
return response;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { exportNativeCheckpoint, restoreNativeCheckpoint, validateNativeCheckpoint } from "./native-checkpoint.mjs";
|
||||
import { ReplayTimeline, fnv1a64 } from "./replay.mjs";
|
||||
import { capabilities, validateLimit } from "./capabilities.mjs";
|
||||
import { mapCoreError } from "./error-map.mjs";
|
||||
@@ -10,7 +11,7 @@ import { PythonBridge } from "./python-bridge.mjs";
|
||||
import { ProgramRunner } from "./program-runner.mjs";
|
||||
import { SimTask } from "./sim-task.mjs";
|
||||
import { SimScheduler, SCHEDULER_PHASES } from "./sim-scheduler.mjs";
|
||||
import { OperationLedger } from "./operation-ledger.mjs";
|
||||
import { OperationLedger, LEDGER_SCHEMA } from "./operation-ledger.mjs";
|
||||
import { MotionState } from "./motion-state.mjs";
|
||||
import { PlcRuntime } from "./plc-runtime.mjs";
|
||||
import { HalRuntime } from "./hal-runtime.mjs";
|
||||
@@ -31,8 +32,12 @@ import { WasmXyzacIo } from "./wasm-xyzac-io.mjs";
|
||||
import { WasmXyzacDeviceIo } from "./wasm-xyzac-device-io.mjs";
|
||||
import { WasmProgramRuntime } from "./wasm-program-runtime.mjs";
|
||||
import { axisGate, xyzacGate, xyzacGateMatrix } from "../app/axis-contract.mjs";
|
||||
import { machineDefinition } from "../workspace/machine-definitions.mjs";
|
||||
import { readTrtGeometry, TRT_GEOMETRY_KEYS } from "../workspace/trt-geometry.mjs";
|
||||
import { machineDefinition, DEFAULT_MACHINE } from "../workspace/machine-definitions.mjs";
|
||||
import { WEB_SETTINGS } from "../workspace/web-settings.mjs";
|
||||
import { TRT_GEOMETRY_KEYS } from "../workspace/trt-geometry.mjs";
|
||||
import { readTrtConfiguration } from "../workspace/trt-configuration.mjs";
|
||||
|
||||
const TRT_EXECUTION_KEYS = Object.freeze(["planner", "xyzacProgramKinematicsMode", "pendingProgramControl", "pendingTaskSync", "pendingXyzacMotion", "xyzacStepTargetId", "pendingServoSync", "xyzacExecutionWaitNextMotion", "xyzacExecutionWaitSamples", "xyzacLastEnqueuedKinematicsMode", "xyzacDeferredPhysicalSwitchMode", "xyzacDeferredBlockedActiveId", "xyzacDeferredControlMotionType", "xyzacQueuedLookaheadTargetId", "xyzacAllowQueuedLookahead", "xyzacLastServoActiveId", "xyzacLastQueuedMotion", "xyzacDeferredPhysicalPose", "xyzacDeferredPhysicalPoseApplied", "xyzacAppendDeferredOnActive", "xyzacDeferredCompletionActiveId"]);
|
||||
|
||||
const PROTOCOL_SCHEMA = 1;
|
||||
const CONTEXT_ID = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1";
|
||||
@@ -138,10 +143,11 @@ export class SimSession {
|
||||
this.xyzacDeviceIo = null;
|
||||
this.xyzacProgram = null;
|
||||
this.machineDefinition = null;
|
||||
this.machineConfigurationHash = null;
|
||||
this.homingHistory = [];
|
||||
this.xyzacProgramKinematicsMode = XYZAC_MODES.identity;
|
||||
this.xyzacRapidVelocity = 35;
|
||||
this.xyzacMotionCycleTime = 0.01;
|
||||
this.xyzacRapidVelocity = null;
|
||||
this.xyzacMotionCycleTime = WEB_SETTINGS.runtime.io.cycle_time;
|
||||
this.xyzacTaskCycleTicks = 1;
|
||||
this.xyzacAxisVelocityLimits = {};
|
||||
this.xyzacAxisAccelerationLimits = {};
|
||||
@@ -300,7 +306,7 @@ export class SimSession {
|
||||
velocity: wasmMotion.status === "verified" ? wasmMotion.state.velocity : 0,
|
||||
velocity_mm_min: Number(wasmMotion.state?.velocity_mm_min ?? 0),
|
||||
feed_command_mm_min: Number(sourceProgram?.feed ?? 0),
|
||||
feed_override: Number(wasmMotion.state?.feed_override ?? 1),
|
||||
feed_override: Number(wasmMotion.state?.feed_override ?? this.wasmMotion.controls?.feed_override ?? 1),
|
||||
spindle_override: Number(ioState?.spindles?.[0]?.override ?? 1),
|
||||
spindle_speed_rpm: Number(spindle?.speed_rpm ?? 0),
|
||||
spindle_command_rpm: Number(spindle?.command_rpm ?? 0),
|
||||
@@ -310,6 +316,7 @@ export class SimSession {
|
||||
jog_scale: Number(wasmMotion.state?.jog_scale ?? 1),
|
||||
jog_velocity_linear: Number(wasmMotion.state?.jog_velocity_linear ?? 0),
|
||||
jog_velocity_angular: Number(wasmMotion.state?.jog_velocity_angular ?? 0),
|
||||
max_feed_override: this.wasmMotion.maxFeedOverride ?? null,
|
||||
max_velocity: Number(wasmMotion.state?.velocity_limit ?? 0),
|
||||
homed: Boolean(home.state?.homed),
|
||||
homed_axes: Array.from({ length: 5 }, (_, index) => Boolean(Number(home.state?.homed_mask ?? 0) & (1 << index))),
|
||||
@@ -330,7 +337,7 @@ export class SimSession {
|
||||
if (this.xyzacProgram) return this.wasmMotionProjection();
|
||||
if (this.motionAuthority !== "wasm") return this.motion.export();
|
||||
const wasm = this.wasmMotion.read();
|
||||
return wasm.status === "verified" ? { ...this.motion.export(), axes: { ...wasm.state.position } } : this.motion.export();
|
||||
return wasm.status === "verified" ? { ...this.motion.export(), axes: { ...wasm.state.position }, queue_depth: wasm.state.queue_depth } : this.motion.export();
|
||||
}
|
||||
|
||||
wasmDeviceProjection({ includeBytes = false } = {}) {
|
||||
@@ -359,7 +366,7 @@ export class SimSession {
|
||||
if (requirement.status !== "verified") return requirement;
|
||||
if (!requirement.required) return { status: "verified", count: 0 };
|
||||
const basePath = String(programPath ?? "").split("/").slice(0, -1).join("/");
|
||||
const coordinates = this.machineDefinition?.coordinates ?? "XYZAC";
|
||||
const coordinates = this.machineDefinition?.coordinates ?? DEFAULT_MACHINE.coordinates;
|
||||
const stem = coordinates.toLowerCase();
|
||||
const rows = [[`remap_subs/${stem}_switchkins_sub.ngc`, `${stem}_switchkins_sub`], [`remap_subs/helix_${stem.slice(3)}.ngc`, `helix_${stem.slice(3)}`]];
|
||||
for (const [suffix, name] of rows) {
|
||||
@@ -407,7 +414,7 @@ export class SimSession {
|
||||
? { schema: "wasm_authority_boundary.v1", source: "linuxcnc-wasm-worker", state_hash: fnv1a64({ motion: wasmMotion, kinematics: this.xyzacKinematics?.snapshot() ?? null, task }) }
|
||||
: this.authoritative.export();
|
||||
const axis = axisState({ task, program: program ?? {}, motion, devices, io: this.xyzacIo ? this.xyzacIo.export() : {}, device_io: deviceIo });
|
||||
const xyzac = this.xyzacKinematics ? { coordinates: this.machineDefinition?.coordinates ?? "XYZAC", joint_count: 5, hal_pin_vars: true, switchkins_net: true, finite_offsets: true, userk_capability: true, active_type: this.xyzacKinematics.snapshot().active_type } : null;
|
||||
const xyzac = this.xyzacKinematics ? { coordinates: this.machineDefinition?.coordinates ?? DEFAULT_MACHINE.coordinates, joint_count: this.xyzacKinematics.coordinates.length, hal_pin_vars: true, switchkins_net: true, finite_offsets: true, userk_capability: true, active_type: this.xyzacKinematics.snapshot().active_type } : null;
|
||||
const gateInput = { task, program: program ?? {}, motion, devices, io: this.xyzacIo ? this.xyzacIo.export() : {}, device_io: deviceIo, xyzac, taskfile: Boolean(this.xyzacProgram ? this.xyzacProgram.snapshot().status !== 0 : this.program.loaded), kinematicsIdentity: this.motionAuthority === "wasm" };
|
||||
const gateActions = ["load", "run", "runLine", "step", "pause", "resume", "abort", "restart", "reload", "edit", "mdi", "jogStart", "jogStop", "home", "estop", "recover", "override", "overrideLimits", "reset", "spindle", "coolant", "touchOff", "toolTouchOff"];
|
||||
const ui = axisSourceUi({ task_state: axis.source_task_state, interp_state: axis.source_interp_state,
|
||||
@@ -473,7 +480,7 @@ export class SimSession {
|
||||
// renderer can audit that its geometry matches the loaded LinuxCNC case.
|
||||
const modelResource = this.configurationState?.exact_resources?.find?.((row) => row.role === "vismach_source");
|
||||
const machineDefinition = this.machineDefinition;
|
||||
const modelSourcePath = this.machineDefinition?.source_path ?? "linuxcnc/src/hal/user_comps/vismach/xyzac-trt-gui.py";
|
||||
const modelSourcePath = this.machineDefinition?.source_path ?? DEFAULT_MACHINE.source_path;
|
||||
const modelSourceText = this.workspaceFiles?.[modelSourcePath];
|
||||
const machineModel = this.xyzacProgram ? {
|
||||
schema: "linuxcnc-vismach-model.v1",
|
||||
@@ -481,7 +488,7 @@ export class SimSession {
|
||||
source: "linuxcnc-wasm-worker",
|
||||
source_path: modelResource?.path ?? modelSourcePath,
|
||||
source_hash: modelResource?.data_hash ?? this.visualModelSourceHash ?? (typeof modelSourceText === "string" ? fnv1a64(modelSourceText) : null),
|
||||
coordinate_system: machineDefinition?.coordinates ?? "XYZAC",
|
||||
coordinate_system: machineDefinition?.coordinates ?? DEFAULT_MACHINE.coordinates,
|
||||
geometry_variant: machineDefinition?.coordinates === "XYZBC" ? "moving-head-y-tilt" : "moving-knee-x-tilt",
|
||||
tilt_axis: machineDefinition?.coordinates === "XYZBC" ? "Y" : "X",
|
||||
rotary_axis: "Z",
|
||||
@@ -507,7 +514,7 @@ export class SimSession {
|
||||
coordinate_offset: program?.coordinate_context?.offsets?.[(program?.coordinate_context?.system ?? 1) - 1]?.slice(0, 3) ?? null,
|
||||
...(machineModel ? { machine_model: machineModel } : {}),
|
||||
};
|
||||
const coordinateAxes = machineDefinition?.axes ?? ["x", "y", "z", "a", "c"];
|
||||
const coordinateAxes = machineDefinition?.axes ?? DEFAULT_MACHINE.axes;
|
||||
const axisCoordinates = coordinateAxes.map((axis) => position[axis] ?? 0);
|
||||
const coordinateContext = program?.coordinate_context;
|
||||
const activeOffsets = coordinateContext?.offsets?.[coordinateContext.system - 1] ?? Array(9).fill(0);
|
||||
@@ -540,7 +547,7 @@ export class SimSession {
|
||||
authoritative,
|
||||
motion_authority: this.motionAuthority,
|
||||
task_authority: this.task.authority(),
|
||||
...(this.xyzacKinematics ? { kinematics: this.xyzacKinematics.snapshot() } : {}),
|
||||
...(this.xyzacKinematics ? { kinematics: this.xyzacKinematics.snapshot(), machine_configuration_hash: this.machineConfigurationHash } : {}),
|
||||
...(includeStableRuntime && this.xyzacIo ? { io: this.xyzacIo.export() } : {}),
|
||||
...(xyzac ? { xyzac: { ...xyzac, coordinates: machineDefinition?.coordinates ?? xyzac.coordinates } } : {}),
|
||||
};
|
||||
@@ -571,7 +578,7 @@ export class SimSession {
|
||||
commandGate(input) {
|
||||
const task = this.task.export();
|
||||
const program = this.xyzacProgram ? this.xyzacProgram.snapshot() : this.program.loaded ? this.program.snapshot() : {};
|
||||
const motion = this.xyzacProgram ? this.wasmMotionProjection() : this.motion.export();
|
||||
const motion = this.motionProjection();
|
||||
const devices = this.wasmDeviceProjection();
|
||||
// The selected XYZAC case is governed by the native WASM task/IO/program
|
||||
// handlers. JS only receives the result; it must not pre-reject a command
|
||||
@@ -582,7 +589,7 @@ export class SimSession {
|
||||
const type = Number(input.payload?.type);
|
||||
const action = input.kind === "VISUAL_CLEAR" ? "vismachClear" : type === 0 ? "switchkinsIdentity" : type === 1 ? "switchkinsXyzac" : type === 2 ? "switchkinsUserk" : null;
|
||||
if (!action) return { action: null, enabled: false, reason: "xyzac_kinematics_type_invalid" };
|
||||
return xyzacGate(action, { task, program, motion, devices, xyzac: this.xyzacKinematics ? { coordinates: this.machineDefinition?.coordinates ?? "XYZAC", joint_count: 5, hal_pin_vars: true, switchkins_net: true, finite_offsets: true, userk_capability: true } : null });
|
||||
return xyzacGate(action, { task, program, motion, devices, xyzac: this.xyzacKinematics ? { coordinates: this.machineDefinition?.coordinates ?? DEFAULT_MACHINE.coordinates, joint_count: this.xyzacKinematics.coordinates.length, hal_pin_vars: true, switchkins_net: true, finite_offsets: true, userk_capability: true } : null });
|
||||
}
|
||||
const action = PANEL_GATE_ACTIONS[input.kind];
|
||||
if (!action) return { action: null, enabled: true, reason: "axis_internal_command" };
|
||||
@@ -688,7 +695,7 @@ export class SimSession {
|
||||
if (!this.xyzacProgram) return { status: "error", code: "xyzac_program_unavailable" };
|
||||
const payload = input.payload ?? {};
|
||||
const axis = String(payload.axis ?? "").trim().toUpperCase();
|
||||
const coordinateAxes = this.machineDefinition?.axes ?? ["x", "y", "z", "a", "c"];
|
||||
const coordinateAxes = this.machineDefinition?.axes ?? DEFAULT_MACHINE.axes;
|
||||
if (!coordinateAxes.includes(axis.toLowerCase())) return { status: "error", code: "touch_off_axis_invalid" };
|
||||
const value = Number(payload.value);
|
||||
if (!Number.isFinite(value)) return { status: "error", code: "touch_off_value_invalid" };
|
||||
@@ -732,6 +739,14 @@ export class SimSession {
|
||||
}
|
||||
}
|
||||
|
||||
checkpointLedger() {
|
||||
// Durable receipts must not recursively contain older checkpoints or
|
||||
// complete UI snapshots. Preserve intent/result identity for deduplication.
|
||||
const omitted = new Set(["panel_snapshot", "configuration", "program", "motion", "wasm_motion", "wasm_home_limit", "device_io", "devices", "hal_runtime", "snapshot", "state", "checkpoint", "recovery_checkpoint", "preview", "operations", "ledger"]);
|
||||
const entries = [...this.ledger.entries.values()].map((entry) => ({ ...entry, response: entry.response ? { ...entry.response, payload: Object.fromEntries(Object.entries(entry.response.payload ?? {}).filter(([key]) => !omitted.has(key))) } : null }));
|
||||
return { schema: LEDGER_SCHEMA, entries: structuredClone(entries) };
|
||||
}
|
||||
|
||||
stateForCheckpoint() {
|
||||
const state = {
|
||||
context_id: this.contextId,
|
||||
@@ -742,7 +757,7 @@ export class SimSession {
|
||||
devices: this.wasmDeviceProjection({ includeBytes: true }),
|
||||
motion: this.xyzacProgram ? this.wasmMotionProjection() : this.motion.export(),
|
||||
plc: this.plc.export(),
|
||||
hal_runtime: this.xyzacIo ? this.xyzacIo.halExport() : this.hal.export(),
|
||||
hal_runtime: this.xyzacIo ? this.xyzacIo.halExport({ includeState: true }) : this.hal.export(),
|
||||
device_io: this.xyzacDeviceIo ? this.xyzacDeviceIo.export() : this.xyzacIo ? this.xyzacIo.export() : this.deviceIo.export(),
|
||||
configuration_batch: this.configurationBatch.export(),
|
||||
io_ack: this.ioAck.export(),
|
||||
@@ -754,15 +769,19 @@ export class SimSession {
|
||||
wasm_motion: this.wasmMotion.exportState(),
|
||||
task_authority: this.task.authority(),
|
||||
wasm_home_limit: this.wasmHomeLimit.exportState(),
|
||||
...(this.xyzacKinematics ? { kinematics: this.xyzacKinematics.snapshot() } : {}),
|
||||
...(this.xyzacKinematics ? { kinematics: this.xyzacKinematics.snapshot(), machine_configuration_hash: this.machineConfigurationHash } : {}),
|
||||
preview: this.previewState,
|
||||
live_path_epoch: this.livePathEpoch,
|
||||
task: this.task.export(),
|
||||
scheduler: this.scheduler.export(),
|
||||
authoritative: this.xyzacProgram ? { schema: "wasm_authority_boundary.v1", source: "linuxcnc-wasm-worker" } : this.authoritative.export(),
|
||||
ledger: this.ledger.export(),
|
||||
ledger: this.xyzacProgram ? this.checkpointLedger() : this.ledger.export(),
|
||||
};
|
||||
if (this.xyzacProgram) state.program = { ...this.xyzacProgram.snapshot(), source_text: this.xyzacProgram.source };
|
||||
if (this.xyzacProgram) {
|
||||
state.program = { ...this.xyzacProgram.checkpoint(), source_text: this.xyzacProgram.source };
|
||||
state.trt_execution = Object.fromEntries(TRT_EXECUTION_KEYS.map((key) => [key, structuredClone(this[key])]));
|
||||
state.native_runtime = Object.fromEntries(["sim_core", "sim_protocol", "sim_event"].map((prefix) => [prefix, exportNativeCheckpoint(this.core, prefix)]));
|
||||
}
|
||||
else if (this.program.loaded) state.program = this.program.exportState();
|
||||
return state;
|
||||
}
|
||||
@@ -792,7 +811,7 @@ export class SimSession {
|
||||
task_authority: this.task.authority(),
|
||||
wasm_home_limit: this.wasmHomeLimit.exportState(),
|
||||
live_path_epoch: this.livePathEpoch,
|
||||
...(this.xyzacKinematics ? { kinematics: this.xyzacKinematics.snapshot() } : {}),
|
||||
...(this.xyzacKinematics ? { kinematics: this.xyzacKinematics.snapshot(), machine_configuration_hash: this.machineConfigurationHash } : {}),
|
||||
};
|
||||
if (this.xyzacProgram) value.program = { ...this.xyzacProgram.snapshot(), source_text: this.xyzacProgram.source };
|
||||
else if (this.program.loaded) value.program = this.program.exportState();
|
||||
@@ -824,6 +843,8 @@ export class SimSession {
|
||||
// recovery command, but are redundant for a power-off restart. Omitting
|
||||
// them keeps the browser durable record bounded during long JOG runs.
|
||||
delete state.ledger;
|
||||
state.power_off_preview_hash = state.preview?.preview_hash ?? null;
|
||||
delete state.preview;
|
||||
const compact = {
|
||||
schema: "xyzac-power-off-checkpoint.v1",
|
||||
context_id: this.contextId,
|
||||
@@ -834,10 +855,51 @@ export class SimSession {
|
||||
return { ...compact, checkpoint_hash: fnv1a64(compact) };
|
||||
}
|
||||
|
||||
validateRecoveryGeometry(state) {
|
||||
if (!this.xyzacKinematics) return;
|
||||
const current = this.xyzacKinematics.snapshot();
|
||||
restoreTrtExecution(state, transport = true) {
|
||||
if (!state.trt_execution || !state.native_runtime || !TRT_EXECUTION_KEYS.every((key) => Object.hasOwn(state.trt_execution, key))) throw new Error("trt_checkpoint_execution_required");
|
||||
for (const key of TRT_EXECUTION_KEYS) this[key] = structuredClone(state.trt_execution[key]);
|
||||
restoreNativeCheckpoint(this.core, "sim_core", state.native_runtime.sim_core);
|
||||
if (transport) for (const prefix of ["sim_protocol", "sim_event"]) restoreNativeCheckpoint(this.core, prefix, state.native_runtime[prefix]);
|
||||
this.xyzacIo.restoreHalState(state.hal_runtime);
|
||||
}
|
||||
|
||||
validateRecoveryGeometry(state, configuration = null) {
|
||||
if (!this.xyzacKinematics && !configuration) return;
|
||||
const supplied = configuration ? readTrtConfiguration(configuration) : null;
|
||||
const expectedHash = supplied ? fnv1a64({ kinematics: supplied.kinematics, home_limits: supplied.home_limits, motion_scale_min: WEB_SETTINGS.ui.motion_scale_min }) : this.machineConfigurationHash;
|
||||
if (state?.machine_configuration_hash !== expectedHash) throw new Error("trt_recovery_configuration_mismatch");
|
||||
const current = supplied?.kinematics ?? this.xyzacKinematics.snapshot();
|
||||
if (!state?.kinematics || state.kinematics.coordinates !== current.coordinates || TRT_GEOMETRY_KEYS.some((key) => Number(state.kinematics.offsets?.[key] ?? 0) !== current.offsets[key])) throw new Error("trt_recovery_geometry_mismatch");
|
||||
if (!state.trt_execution || !TRT_EXECUTION_KEYS.every((key) => Object.hasOwn(state.trt_execution, key))) throw new Error("trt_checkpoint_execution_required");
|
||||
for (const [prefix, value] of [["sim_program", state.program?.native_state], ["sim_linuxcnc_tp", state.wasm_motion?.native_state], ["sim_hal", state.hal_runtime?.native_state], ...["sim_core", "sim_protocol", "sim_event"].map((key) => [key, state.native_runtime?.[key]])]) validateNativeCheckpoint(this.core, prefix, value);
|
||||
}
|
||||
|
||||
restoreTrtProgram(state) {
|
||||
if (typeof state.program?.source_text !== "string") throw new Error("trt_checkpoint_program_missing");
|
||||
const previous = this.xyzacProgram;
|
||||
const candidate = new WasmProgramRuntime(this.core);
|
||||
try {
|
||||
if (state.program.source_text) {
|
||||
const loaded = candidate.load(state.program.source_text, state.program.path ?? null);
|
||||
if (loaded.status !== "verified") throw new Error(loaded.code);
|
||||
if (Array.isArray(state.program.subroutine_sources)) {
|
||||
for (const sub of state.program.subroutine_sources) {
|
||||
const added = candidate.addSubroutine(sub.name, sub.source, sub.path);
|
||||
if (added.status !== "verified") throw new Error(added.code);
|
||||
}
|
||||
} else {
|
||||
this.xyzacProgram = candidate;
|
||||
const subroutines = this.loadXyzacSubroutines(state.program.path ?? null);
|
||||
if (subroutines.status !== "verified") throw new Error(subroutines.code);
|
||||
}
|
||||
}
|
||||
const result = candidate.restoreState(state.program);
|
||||
if (result.status === "error") throw new Error(result.code ?? "trt_checkpoint_program_restore_failed");
|
||||
this.xyzacProgram = candidate;
|
||||
previous.destroy();
|
||||
} catch (error) {
|
||||
this.xyzacProgram = previous; candidate.destroy(); throw error;
|
||||
}
|
||||
}
|
||||
|
||||
restoreCompactPowerOff(record, transportSeq) {
|
||||
@@ -850,17 +912,13 @@ export class SimSession {
|
||||
const liveOperations = [...this.operations.values()];
|
||||
try {
|
||||
this.validateRecoveryGeometry(state);
|
||||
if (!this.xyzacProgram || !state.program?.source_text) throw new Error("power_off_program_missing");
|
||||
const loaded = this.xyzacProgram.load(state.program.source_text, state.program.path ?? null);
|
||||
if (loaded.status !== "verified") throw new Error(loaded.code);
|
||||
const subroutines = this.loadXyzacSubroutines(state.program.path ?? null);
|
||||
if (subroutines.status !== "verified") throw new Error(subroutines.code);
|
||||
if (this.xyzacProgram.restoreState(state.program).status === "error") throw new Error("power_off_program_restore_failed");
|
||||
this.restoreTrtProgram(state);
|
||||
if (state.wasm_motion) this.wasmMotion.restoreState(state.wasm_motion);
|
||||
if (state.wasm_home_limit) this.wasmHomeLimit.restoreState(state.wasm_home_limit);
|
||||
if (state.device_io && this.xyzacDeviceIo) this.xyzacDeviceIo.restoreState(state.device_io);
|
||||
if (state.devices && this.xyzacIo) this.xyzacIo.restoreState(state.devices);
|
||||
if (state.kinematics && this.xyzacKinematics) this.xyzacKinematics.restoreState(state.kinematics);
|
||||
this.restoreTrtExecution(state, false);
|
||||
if (state.task) {
|
||||
const task = { ...state.task, session_id: this.sessionId, seq: transportSeq, simulation_tick: record.simulation_tick };
|
||||
delete task.state_hash;
|
||||
@@ -875,7 +933,12 @@ export class SimSession {
|
||||
this.callbacks = new PythonCallbacks(); if (state.callbacks) this.callbacks.restore(state.callbacks);
|
||||
this.configurationState = state.configuration ?? this.configurationState;
|
||||
this.halState = state.hal ?? this.halState;
|
||||
this.previewState = state.preview ?? this.previewState;
|
||||
if (state.preview) this.previewState = state.preview;
|
||||
else if (state.program.source_text) {
|
||||
const preview = this.xyzacProgram.preview();
|
||||
if (preview.status !== "verified" || state.power_off_preview_hash && state.power_off_preview_hash !== preview.preview_hash) throw new Error("power_off_preview_restore_mismatch");
|
||||
this.previewState = preview;
|
||||
} else this.previewState = null;
|
||||
this.livePathEpoch = Number.isSafeInteger(state.live_path_epoch) ? state.live_path_epoch : this.livePathEpoch;
|
||||
this.python = new PythonBridge(); if (state.python) this.python.restoreState(state.python);
|
||||
const liveEvents = liveTimeline.events.events;
|
||||
@@ -897,6 +960,7 @@ export class SimSession {
|
||||
|
||||
restorePersistedPowerOff(checkpoint, transportSeq) {
|
||||
if (!checkpoint || !Number.isSafeInteger(transportSeq) || transportSeq < 1) return { status: "blocked", code: "runtime_checkpoint_input_invalid", reason: "checkpoint_or_sequence_invalid" };
|
||||
if (Number.isSafeInteger(checkpoint.simulation_tick) && checkpoint.simulation_tick < this.timeline.events.tick) return { status: "blocked", code: "runtime_checkpoint_requires_new_session", reason: "recovery_must_not_rewrite_transport_history" };
|
||||
if (checkpoint.schema === "xyzac-power-off-checkpoint.v1") return this.restoreCompactPowerOff(checkpoint, transportSeq);
|
||||
const liveTimeline = this.timeline.export();
|
||||
const liveLedger = this.ledger;
|
||||
@@ -914,7 +978,7 @@ export class SimSession {
|
||||
const unsigned = Object.fromEntries(Object.entries(checkpointForRestore).filter(([key]) => key !== "checkpoint_hash"));
|
||||
checkpointForRestore.checkpoint_hash = fnv1a64(unsigned);
|
||||
}
|
||||
const restored = this.restoreCheckpoint(checkpointForRestore);
|
||||
const restored = this.restoreCheckpoint(checkpointForRestore, null, undefined, false);
|
||||
if (restored.status !== "recovered") return restored;
|
||||
const restoredTick = this.tick;
|
||||
const restoredTask = this.task.export();
|
||||
@@ -953,6 +1017,12 @@ export class SimSession {
|
||||
importTrace(trace) {
|
||||
const validated = validateTrace(trace, { contextId: this.contextId });
|
||||
this.validateRecoveryGeometry(trace.state);
|
||||
if (this.xyzacProgram && trace.state?.program?.native_state) {
|
||||
const checkpoint = createCheckpoint({ contextId: this.contextId, timeline: validated.timeline, state: trace.state, operations: trace.state.ledger?.entries ?? [] });
|
||||
const result = this.restoreCheckpoint(checkpoint);
|
||||
if (result.status !== "recovered") throw new Error(result.blocked?.[0]?.reason ?? "trt_trace_restore_failed");
|
||||
return { status: "verified", trace_hash: trace.trace_hash, state_hash: fnv1a64(trace.state), seq: trace.seq, tick: trace.tick };
|
||||
}
|
||||
this.timeline = validated.timeline;
|
||||
this.created = Boolean(trace.state?.created);
|
||||
if (trace.state?.ledger?.entries) this.ledger = new OperationLedger(trace.state.ledger.entries);
|
||||
@@ -986,27 +1056,22 @@ export class SimSession {
|
||||
return { status: "verified", trace_hash: trace.trace_hash, state_hash: fnv1a64(trace.state), seq: trace.seq, tick: trace.tick };
|
||||
}
|
||||
|
||||
restoreCheckpoint(checkpoint, events = null, reconcile = undefined) {
|
||||
restoreCheckpoint(checkpoint, events = null, reconcile = undefined, transport = true) {
|
||||
try {
|
||||
validateCheckpoint(checkpoint, { contextId: this.contextId });
|
||||
this.validateRecoveryGeometry(checkpoint.state);
|
||||
} 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 tailEvents = Array.isArray(events) ? events : events?.events?.events ?? events?.events ?? [];
|
||||
if (this.xyzacProgram && Array.isArray(tailEvents) && tailEvents.some((event) => event.seq > checkpoint.event_cursor)) return { schema: "simulation-recovery.v1", status: "blocked", context_id: this.contextId, blocked: [{ reason: "trt_checkpoint_tail_requires_explicit_commands" }] };
|
||||
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" }] };
|
||||
}
|
||||
if (this.xyzacProgram && checkpoint?.state?.program?.source_text) {
|
||||
if (this.xyzacProgram && checkpoint?.state?.program) {
|
||||
try {
|
||||
const loaded = this.xyzacProgram.load(checkpoint.state.program.source_text, checkpoint.state.program.path ?? null);
|
||||
if (loaded.status !== "verified") throw new Error(loaded.code);
|
||||
const subroutines = this.loadXyzacSubroutines(checkpoint.state.program.path ?? null);
|
||||
if (subroutines.status !== "verified") throw new Error(subroutines.code);
|
||||
const targetLine = Number.isSafeInteger(checkpoint.state.program.current_line) ? checkpoint.state.program.current_line : 0;
|
||||
const seek = this.xyzacProgram.seek(targetLine);
|
||||
if (seek.status !== "verified") throw new Error(seek.code);
|
||||
if (checkpoint.state.program) this.xyzacProgram.restoreState(checkpoint.state.program);
|
||||
this.restoreTrtProgram(checkpoint.state);
|
||||
if (checkpoint.state.wasm_motion) this.wasmMotion.restoreState(checkpoint.state.wasm_motion);
|
||||
if (checkpoint.state.wasm_home_limit) this.wasmHomeLimit.restoreState(checkpoint.state.wasm_home_limit);
|
||||
let restoredWasmDeviceIo = false;
|
||||
@@ -1028,11 +1093,11 @@ export class SimSession {
|
||||
this.livePathEpoch = Number.isSafeInteger(checkpoint.state.live_path_epoch) ? checkpoint.state.live_path_epoch : this.livePathEpoch;
|
||||
this.python = new PythonBridge(); if (checkpoint.state.python) this.python.restoreState(checkpoint.state.python);
|
||||
this.timeline = new ReplayTimeline(checkpoint.timeline);
|
||||
if (checkpoint.state.hal_runtime?.schema === "wasm_xyzac_hal.v1" && this.xyzacIo) this.xyzacIo.halCommand({ action: "step", ticks: checkpoint.state.hal_runtime.tick ?? 0 });
|
||||
this.restoreTrtExecution(checkpoint.state, transport);
|
||||
if (!restoredWasmDeviceIo && checkpoint.state.device_io?.schema === "wasm_xyzac_device_io.v1" && this.xyzacDeviceIo) this.xyzacDeviceIo.command("IO_STEP", { ticks: checkpoint.state.device_io.state?.tick ?? 0 });
|
||||
const currentOperations = [...this.operations.values()];
|
||||
this.operations = new Map([...checkpoint.operations ?? [], ...currentOperations].map((operation) => [operation.operation_id, operation]));
|
||||
const checkpointEntries = checkpoint.operations ?? [];
|
||||
const checkpointEntries = checkpoint.state.ledger?.entries ?? checkpoint.operations ?? [];
|
||||
const currentEntries = this.ledger.export().entries.filter((entry) => !checkpointEntries.some((operation) => operation.operation_id === entry.operation_id));
|
||||
this.ledger = new this.ledger.constructor();
|
||||
for (const operation of [...checkpointEntries, ...currentEntries]) {
|
||||
@@ -1317,6 +1382,7 @@ export class SimSession {
|
||||
let ledgerPreparation;
|
||||
try {
|
||||
ledgerPreparation = this.ledger.prepare(input);
|
||||
if (ledgerPreparation.replay && !ledgerPreparation.entry?.response) throw new Error("ledger_replay_result_missing");
|
||||
if (ledgerPreparation.replay && ledgerPreparation.entry?.response) {
|
||||
const replayTick = Number.isSafeInteger(input.simulation_tick) && input.simulation_tick >= this.tick ? input.simulation_tick : this.tick;
|
||||
this.timeline.appendEvent(input.seq, replayTick, "OPERATION_REPLAY", { operation_id: input.operation_id, original_seq: ledgerPreparation.entry.seq });
|
||||
@@ -1324,7 +1390,9 @@ export class SimSession {
|
||||
if (replayEventResult !== 0) return this.error(input, "event_log", "core_event_append_failed");
|
||||
this.tick = replayTick;
|
||||
try { this.task.setCommand(input.seq, replayTick); } catch (error) { return this.error(input, "task_state", String(error?.message ?? error)); }
|
||||
return { ...ledgerPreparation.entry.response, replayed: true, seq: input.seq, ledger: this.ledger.reconcile(input.operation_id) };
|
||||
const cached = ledgerPreparation.entry.response;
|
||||
const payload = this.xyzacProgram ? { ...cached.payload, task: this.task.export(), program: this.xyzacProgram.snapshot(), motion: this.wasmMotionProjection(), kinematics: this.xyzacKinematics.snapshot() } : cached.payload;
|
||||
return { ...this.envelope(cached.kind, input, payload), replayed: true, ledger: this.ledger.reconcile(input.operation_id) };
|
||||
}
|
||||
if (ledgerPreparation.entry) this.ledger.execute(input.operation_id);
|
||||
} catch (error) {
|
||||
@@ -1610,8 +1678,7 @@ export class SimSession {
|
||||
let configuration = input.payload?.configuration ?? null;
|
||||
if (machineDefinition(configuration?.entrypoint) || machineDefinition(configuration?.kinematics?.type)) {
|
||||
try {
|
||||
const geometry = readTrtGeometry(configuration);
|
||||
configuration = { ...configuration, kinematics: { ...configuration.kinematics, offsets: geometry.offsets } };
|
||||
configuration = { ...configuration, ...readTrtConfiguration(configuration) };
|
||||
} catch (error) {
|
||||
return finish(this.error(input, "trt_geometry", error.message), "failed");
|
||||
}
|
||||
@@ -1619,7 +1686,7 @@ export class SimSession {
|
||||
if (configuration) {
|
||||
this.machineDefinition = machineDefinition(configuration.entrypoint) ?? machineDefinition(configuration.kinematics?.type) ?? this.machineDefinition;
|
||||
this.workspaceFiles = { ...(configuration.files ?? configuration.workspace ?? configuration.workspace_files ?? {}) };
|
||||
const visualSourcePath = (this.machineDefinition ?? machineDefinition(configuration.entrypoint))?.source_path ?? "linuxcnc/src/hal/user_comps/vismach/xyzac-trt-gui.py";
|
||||
const visualSourcePath = (this.machineDefinition ?? machineDefinition(configuration.entrypoint))?.source_path ?? DEFAULT_MACHINE.source_path;
|
||||
this.visualModelSourceHash = typeof this.workspaceFiles[visualSourcePath] === "string" ? fnv1a64(this.workspaceFiles[visualSourcePath]) : null;
|
||||
const startupWorkspace = configuration.workspace ?? configuration.files ?? configuration.workspace_files;
|
||||
if (startupWorkspace) {
|
||||
@@ -1672,22 +1739,23 @@ export class SimSession {
|
||||
this.xyzacKinematics?.destroy();
|
||||
this.xyzacKinematics = new WasmXyzacKinematics(this.core, { ...configuration.kinematics.offsets, coordinates: configuredMachine?.coordinates ?? configuration.kinematics.coordinates ?? "XYZAC", limits: configuration.home_limits });
|
||||
this.xyzacIo?.destroy();
|
||||
this.xyzacIo = new WasmXyzacIo(this.core, { spindleCount: 2, toolCapacity: 1001 });
|
||||
this.xyzacIo = new WasmXyzacIo(this.core, { spindleCount: configuration.kinematics.io.spindle_count, toolCapacity: configuration.kinematics.io.tool_capacity, cycleTime: configuration.kinematics.io.cycle_time, spindleAcceleration: configuration.kinematics.io.spindle_acceleration, spindleTolerance: configuration.kinematics.io.spindle_tolerance });
|
||||
const initialPower = this.xyzacIo.command("POWER", { active: false });
|
||||
if (initialPower.status !== "verified") throw new Error(initialPower.code ?? "wasm_power_off_init");
|
||||
this.xyzacDeviceIo?.destroy();
|
||||
this.xyzacDeviceIo = new WasmXyzacDeviceIo(this.core, { encoderCount: 2 });
|
||||
this.xyzacDeviceIo = new WasmXyzacDeviceIo(this.core, { encoderCount: configuration.kinematics.io.encoder_count, cycleTime: configuration.kinematics.io.cycle_time });
|
||||
this.xyzacProgram?.destroy();
|
||||
this.wasmMotion.destroy();
|
||||
this.xyzacMotionCycleTime = Number(configuration.kinematics?.servo_cycle_time ?? configuration.kinematics?.cycle_time ?? 0.01);
|
||||
this.xyzacMotionCycleTime = configuration.kinematics.servo_cycle_time;
|
||||
if (!Number.isFinite(this.xyzacMotionCycleTime) || this.xyzacMotionCycleTime <= 0) throw new Error("wasm_xyzac_cycle_time_invalid");
|
||||
const taskCycleTime = Number(configuration.kinematics?.task_cycle_time ?? this.xyzacMotionCycleTime);
|
||||
this.xyzacTaskCycleTicks = Math.max(1, Math.round(taskCycleTime / this.xyzacMotionCycleTime));
|
||||
const taskCycleTime = configuration.kinematics.task_cycle_time;
|
||||
this.xyzacTaskCycleTicks = Math.round(taskCycleTime / this.xyzacMotionCycleTime);
|
||||
this.xyzacRapidVelocity = configuration.kinematics.max_linear_velocity;
|
||||
// The selected XYZAC TRT case always uses the upstream LinuxCNC TP;
|
||||
// a caller must not silently downgrade it to the compatibility
|
||||
// motion queue by omitting the planner option.
|
||||
this.wasmMotion = isTrt
|
||||
? new WasmLinuxcncTp(this.core, { cycleTime: this.xyzacMotionCycleTime, velocityLimit: this.xyzacRapidVelocity, accelerationLimit: Number(configuration.kinematics?.max_acceleration ?? 300), limits: configuration.home_limits ?? {} })
|
||||
? new WasmLinuxcncTp(this.core, { cycleTime: this.xyzacMotionCycleTime, velocityLimit: this.xyzacRapidVelocity, accelerationLimit: configuration.kinematics.max_acceleration, limits: configuration.kinematics.axis_limits, axisVelocities: configuration.kinematics.axis_velocity_limits, axisAccelerations: configuration.kinematics.axis_acceleration_limits, maxFeedOverride: configuration.kinematics.max_feed_override, blend: configuration.kinematics.blend })
|
||||
: new WasmMotionQueue(this.core, { cycleTime: this.xyzacMotionCycleTime, velocityLimit: 1e9 });
|
||||
this.xyzacProgram = new WasmProgramRuntime(this.core);
|
||||
this.previewState = null;
|
||||
@@ -1696,14 +1764,8 @@ export class SimSession {
|
||||
this.homingHistory = [];
|
||||
this.xyzacProgramKinematicsMode = XYZAC_MODES.identity;
|
||||
if (typeof this.wasmMotion.setKinematics === "function") this.wasmMotion.setKinematics(this.xyzacKinematics);
|
||||
this.xyzacRapidVelocity = Number(configuration.kinematics?.max_linear_velocity ?? 35);
|
||||
const linearLimit = Number(configuration.kinematics?.axis_linear_velocity_limit ?? 20);
|
||||
const angularLimit = Number(configuration.kinematics?.axis_angular_velocity_limit ?? 30);
|
||||
const rotaryAxis = (configuredMachine?.coordinates ?? "XYZAC")[3].toLowerCase();
|
||||
this.xyzacAxisVelocityLimits = { x: linearLimit, y: linearLimit, z: linearLimit, [rotaryAxis]: angularLimit, c: angularLimit };
|
||||
const accelerationLimit = Number(configuration.kinematics?.max_acceleration ?? 300);
|
||||
this.xyzacAxisAccelerationLimits = { x: accelerationLimit, y: accelerationLimit, z: accelerationLimit, [rotaryAxis]: accelerationLimit, c: accelerationLimit };
|
||||
this.wasmMotion.setDynamics(Number(configuration.kinematics?.max_acceleration ?? 300), Number(configuration.kinematics?.max_jerk ?? 1000000000));
|
||||
this.xyzacAxisVelocityLimits = configuration.kinematics.axis_velocity_limits;
|
||||
this.xyzacAxisAccelerationLimits = configuration.kinematics.axis_acceleration_limits;
|
||||
this.xyzacProgram.setRapidVelocity(this.xyzacRapidVelocity);
|
||||
}
|
||||
if (configuration?.home_limits && this.motionAuthority === "wasm") {
|
||||
@@ -1734,6 +1796,7 @@ export class SimSession {
|
||||
};
|
||||
}
|
||||
const configuredKinematics = input.payload?.kinematics ?? configuration?.kinematics?.type ?? null;
|
||||
if (isTrt) this.machineConfigurationHash = fnv1a64({ kinematics: configuration.kinematics, home_limits: configuration.home_limits, motion_scale_min: WEB_SETTINGS.ui.motion_scale_min });
|
||||
if (configuredKinematics && !(this.motionAuthority === "wasm" && isTrt)) {
|
||||
const kinematics = this.authoritative.setKinematics(configuredKinematics);
|
||||
if (kinematics.status === "blocked") return finish(this.envelope("ERROR", input, { status: "blocked", code: kinematics.code, kinematics: this.authoritative.export() }), "failed");
|
||||
@@ -1757,7 +1820,7 @@ export class SimSession {
|
||||
motion_authority: this.motionAuthority,
|
||||
state_hash: this.task.hash(),
|
||||
task: this.task.export(),
|
||||
...(powerOffRecovery ? { power_off_recovery: powerOffRecovery } : {}),
|
||||
...(powerOffRecovery ? { power_off_recovery: { ...powerOffRecovery, program: { source: this.xyzacProgram.source, path: this.xyzacProgram.path } } } : {}),
|
||||
...(this.configurationState?.schema === "configuration-startup-runtime.v1" ? { configuration: this.configurationState } : {}),
|
||||
})) : finish(this.coreError(input, loadResult, "sim_load_workspace"), "failed");
|
||||
}
|
||||
@@ -2081,7 +2144,7 @@ export class SimSession {
|
||||
motion_trace.push({
|
||||
tick: nextTick,
|
||||
pose: { ...traceState.position },
|
||||
joints: traceKinematics?.joints?.length === 5 ? [...traceKinematics.joints] : [],
|
||||
joints: Array.isArray(traceState.joint_commanded) ? [...traceState.joint_commanded] : traceKinematics?.joints?.length === 5 ? [...traceKinematics.joints] : [],
|
||||
active_segment_id: traceState.active_id,
|
||||
active_progress: traceState.progress,
|
||||
});
|
||||
@@ -2618,7 +2681,7 @@ export class SimSession {
|
||||
}
|
||||
if (input.kind === "SET_OVERRIDE") {
|
||||
const feed = Number(input.payload?.feed ?? input.payload?.feed_override);
|
||||
if (!Number.isFinite(feed) || feed < 0 || feed > 2) return finish(this.error(input, "motion_override", "motion_override_range"), "failed");
|
||||
if (!Number.isFinite(feed) || feed < 0 || feed > (this.wasmMotion.maxFeedOverride ?? WEB_SETTINGS.runtime.planner.max_feed_override)) return finish(this.error(input, "motion_override", "motion_override_range"), "failed");
|
||||
try {
|
||||
this.wasmMotion.setFeedOverride(feed);
|
||||
} catch (error) {
|
||||
@@ -2629,7 +2692,7 @@ export class SimSession {
|
||||
}
|
||||
if (input.kind === "SET_JOG_SCALE" || input.kind === "SET_MAX_VELOCITY") {
|
||||
const scale = Number(input.payload?.scale ?? Number(input.payload?.percent) / 100);
|
||||
if (!Number.isFinite(scale) || scale < 0.06 || scale > 1) return finish(this.error(input, "motion_scale", input.kind === "SET_JOG_SCALE" ? "jog_scale_range" : "max_velocity_scale_range"), "failed");
|
||||
if (!Number.isFinite(scale) || scale < WEB_SETTINGS.ui.motion_scale_min || scale > 1) return finish(this.error(input, "motion_scale", input.kind === "SET_JOG_SCALE" ? "jog_scale_range" : "max_velocity_scale_range"), "failed");
|
||||
try {
|
||||
if (input.kind === "SET_JOG_SCALE") this.wasmMotion.setJogScale(scale);
|
||||
else this.wasmMotion.setVelocityScale(scale);
|
||||
@@ -2641,7 +2704,7 @@ export class SimSession {
|
||||
}
|
||||
if (input.kind === "FOLLOWING_ERROR") return finish(this.error(input, "motion_following_error", "wasm_following_error_unavailable"), "failed");
|
||||
const axis = String(input.payload?.axis ?? "").toLowerCase();
|
||||
const availableAxes = this.machineDefinition?.axes ?? ["x", "y", "z", "a", "c"];
|
||||
const availableAxes = this.machineDefinition?.axes ?? DEFAULT_MACHINE.axes;
|
||||
if (!availableAxes.includes(axis)) return finish(this.error(input, "motion_axis", "motion_axis_invalid"), "failed");
|
||||
const direction = input.payload?.direction === -1 ? -1 : input.payload?.direction === 1 ? 1 : null;
|
||||
if (direction === null) return finish(this.error(input, "motion_jog", "motion_jog_range"), "failed");
|
||||
@@ -2790,7 +2853,13 @@ export class SimSession {
|
||||
}
|
||||
}
|
||||
case "RECOVER": {
|
||||
const result = this.restoreCheckpoint(input.payload?.checkpoint, input.payload?.events, input.payload?.reconcile);
|
||||
const recoveryEvents = input.payload?.events;
|
||||
const tailEvents = Array.isArray(recoveryEvents) ? recoveryEvents : recoveryEvents?.events?.events ?? recoveryEvents?.events ?? [];
|
||||
if (this.xyzacProgram && Array.isArray(tailEvents) && tailEvents.some((event) => event.seq > (input.payload?.checkpoint?.event_cursor ?? 0))) return finish(this.envelope("RECOVERY_STATUS", input, { status: "blocked", code: "trt_checkpoint_tail_requires_explicit_commands" }), "failed");
|
||||
let result = this.xyzacProgram
|
||||
? this.restorePersistedPowerOff(input.payload?.checkpoint, input.seq)
|
||||
: this.restoreCheckpoint(input.payload?.checkpoint, input.payload?.events, input.payload?.reconcile);
|
||||
if (this.xyzacProgram && result.status === "verified") result = { ...result, schema: "simulation-recovery.v1", status: "recovered", storage: "checkpoint", state: this.stateForCheckpoint() };
|
||||
return finish(this.envelope("RECOVERY_STATUS", input, result), result.status === "blocked" ? "failed" : "verified");
|
||||
}
|
||||
case "CANCEL": {
|
||||
|
||||
@@ -39,9 +39,10 @@ const wasm = await createModule({ wasmBinary: await readFile(new URL("../../buil
|
||||
const session = new SimSession(wasm, { sessionId: "axis3-selected-case" });
|
||||
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
|
||||
let sequence = 0;
|
||||
const send = (kind, operationId, payload = {}) => {
|
||||
const send = (kind, operationId, payload = {}, expected = "ok") => {
|
||||
const response = session.accept({ ...base, kind, seq: ++sequence, operation_id: operationId, payload });
|
||||
assert.notEqual(response.kind, "ERROR", `${kind}:${response.payload?.code ?? "error"}`);
|
||||
if (expected === "error") assert.equal(response.kind, "ERROR");
|
||||
else assert.notEqual(response.kind, "ERROR", `${kind}:${response.payload?.code ?? "error"}`);
|
||||
return response;
|
||||
};
|
||||
|
||||
@@ -67,6 +68,12 @@ const checkpoint = send("CHECKPOINT", "checkpoint").payload.checkpoint;
|
||||
send("SIM_STEP", "program-step", { cycles: 8 });
|
||||
const programSnapshot = send("SNAPSHOT_REQUEST", "program-snapshot");
|
||||
assert.ok(["running", "completed"].includes(programSnapshot.payload.snapshot?.state?.program?.program_status ?? "running"));
|
||||
assert.equal(send("SPINDLE_COMMAND", "spindle-during-motion", { spindle: 0, rpm: 600, enabled: true }, "error").payload.code, "axis_gate");
|
||||
assert.equal(send("TOOL_PREPARE", "tool-during-motion", { tool: 1, pocket: 1 }, "error").payload.code, "axis_gate");
|
||||
for (let tick = 0; tick < 200 && (session.program.snapshot().program_status !== "completed" || session.wasmMotion.read().state.queue_depth > 0); tick++) send("SIM_STEP", `finish-program-${tick}`, { cycles: 100 });
|
||||
assert.equal(session.program.snapshot().program_status, "completed");
|
||||
assert.equal(session.wasmMotion.read().state.queue_depth, 0);
|
||||
send("SET_MODE", "manual-after-program", { mode: "MANUAL" });
|
||||
|
||||
assert.equal(send("SPINDLE_COMMAND", "spindle-on", { spindle: 0, rpm: 600, enabled: true }).payload.status, "verified");
|
||||
assert.equal(send("IO_STEP", "spindle-step", { ticks: 5 }).payload.status, "verified");
|
||||
|
||||
@@ -46,6 +46,20 @@ await handleWorkerMessage(session, { ...base, kind: "LOAD_WORKSPACE", seq: 2, op
|
||||
assert.deepEqual(lastAccepted.payload.recovery_checkpoint, checkpoint);
|
||||
assert.equal(accepted, 2);
|
||||
assert.equal((await loadUiState({ contextId, key: "xyzac-trt-power-off" })).status, "verified");
|
||||
session.machineDefinition = { id: "xyzbc-trt" };
|
||||
const bcCheckpoint = { ...checkpoint, state_hash: "bc-test" };
|
||||
await handleWorkerMessage(session, { ...base, kind: "POWER", seq: 3, operation_id: "bc-off", payload: { active: false, checkpoint: bcCheckpoint } });
|
||||
assert.equal((await loadUiState({ contextId, key: "xyzbc-trt-power-off" })).state.checkpoint.state_hash, "bc-test");
|
||||
assert.equal((await loadUiState({ contextId, key: "xyzac-trt-power-off" })).state.checkpoint.state_hash, "test");
|
||||
await handleWorkerMessage(session, { ...base, kind: "LOAD_WORKSPACE", seq: 4, operation_id: "reload", payload: { root_path: "/workspace" } });
|
||||
assert.equal(lastAccepted.payload.recovery_checkpoint, undefined, "active-session configuration reload must not inject old cached state");
|
||||
delete session.machineDefinition;
|
||||
session.core = {};
|
||||
session.validateRecoveryGeometry = () => { throw new Error("sim_program_checkpoint_build_mismatch"); };
|
||||
const incompatible = await handleWorkerMessage(session, { ...base, kind: "LOAD_WORKSPACE", seq: 5, operation_id: "incompatible", payload: { root_path: "/workspace", configuration: { entrypoint: "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini" } } });
|
||||
assert.equal(lastAccepted.payload.recovery_checkpoint, undefined);
|
||||
assert.equal(incompatible.payload.recovery_persistence.status, "ignored");
|
||||
assert.equal(incompatible.payload.recovery_persistence.code, "sim_program_checkpoint_build_mismatch");
|
||||
|
||||
if (previousNavigator === undefined) delete globalThis.navigator;
|
||||
else Object.defineProperty(globalThis, "navigator", { configurable: true, value: previousNavigator });
|
||||
|
||||
142
web/worker/test_trt_checkpoint_exact.mjs
Normal file
142
web/worker/test_trt_checkpoint_exact.mjs
Normal file
@@ -0,0 +1,142 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile, mkdir, writeFile } from "node:fs/promises";
|
||||
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
|
||||
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
|
||||
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
|
||||
import { MACHINE_DEFINITIONS } from "../workspace/machine-definitions.mjs";
|
||||
import { fnv1a64 } from "./replay.mjs";
|
||||
|
||||
const root = new URL("../../", import.meta.url);
|
||||
const wasmBinary = await readFile(new URL("build-wasm/linuxcnc-sim-core.wasm", root));
|
||||
const fetchImpl = async (url) => {
|
||||
try { const text = await readFile(new URL(String(url).replace(/^\//, ""), root), "utf8"); return { ok: true, text: async () => text }; }
|
||||
catch { return { ok: false }; }
|
||||
};
|
||||
async function open(machine) {
|
||||
const bundle = await loadSimulationCase({ path: machine.entrypoint, fetchImpl });
|
||||
const subroutine = machine.subroutines[0];
|
||||
const subPath = machine.entrypoint.replace(/[^/]+$/, `remap_subs/${subroutine}.ngc`);
|
||||
bundle.files[subPath] = `O<${subroutine}> sub\n#31=[#31+#1]\nG1 X#31 Y4 Z2 ${machine.coordinates[3]}8 C12 F500\nO<${subroutine}> endsub\n`;
|
||||
const core = await createModule({ wasmBinary });
|
||||
// Give each module a different heap layout; native restore must rebind pointers.
|
||||
core._malloc(4096 + rows.length * 128);
|
||||
const session = new SimSession(core, { sessionId: `exact-${machine.id}`, planner: "linuxcnc-tp" });
|
||||
let seq = 0;
|
||||
const send = (kind, payload = {}, operationId = null) => {
|
||||
const result = session.accept({ schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId, kind, seq: ++seq, operation_id: operationId ?? `exact-${seq}`, payload });
|
||||
assert.notEqual(result.kind, "ERROR", `${kind}: ${result.payload?.message_key ?? result.payload?.code ?? JSON.stringify({ preview: result.payload?.preview?.code, line: result.payload?.preview?.line, program_error: result.payload?.program?.last_error })}`);
|
||||
return result;
|
||||
};
|
||||
send("HELLO"); send("SESSION_ATTACH"); send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
|
||||
send("POWER", { active: true }); send("HOME", { all: true });
|
||||
for (let i = 0; i < 500 && !session.wasmHomeLimit.read().state.homed; i++) send("SIM_STEP", { cycles: 1 });
|
||||
assert.ok(session.wasmHomeLimit.read().state.homed);
|
||||
return { session, core, send, adoptSequence: (value) => { seq = value; } };
|
||||
}
|
||||
const rows = [];
|
||||
for (const machine of MACHINE_DEFINITIONS) {
|
||||
const original = await open(machine);
|
||||
const idle = original.send("CHECKPOINT").payload.checkpoint;
|
||||
const idleRestored = await open(machine);
|
||||
assert.equal(idleRestored.session.restoreCheckpoint(idle).status, "recovered");
|
||||
assert.equal(fnv1a64(idleRestored.session.workspacePackageState()), fnv1a64(original.session.workspacePackageState()), "idle machine without a loaded program");
|
||||
const rotary = machine.coordinates[3];
|
||||
const source = `G21 G90 G64\nM428\n#31=3\nG1 X6 Y0 Z2 ${rotary}5 C8 F600\nG3 X0 Y6 I-6 J0 ${rotary}10 C16\nO<${machine.subroutines[0]}> call [3]\nM429\nG1 X2 Y3 Z4 F600\nM2\n`;
|
||||
original.send("PROGRAM_LOAD", { source, path: machine.program.replace(/[^/]+$/, "checkpoint-exact.ngc") }); original.send("PROGRAM_RUN");
|
||||
const seen = new Set(); let samples = 0;
|
||||
for (let index = 0; index < 500; index++) {
|
||||
original.send("SIM_STEP", { cycles: 5, response: "motion_delta" });
|
||||
const current = original.session.wasmMotion.read().state;
|
||||
const program = original.session.xyzacProgram.snapshot();
|
||||
const phase = program.call_depth > 0 ? "subroutine" : current.motion_type === 3 ? "arc" : current.queue_depth > 0 ? "line" : program.program_status === "completed" ? "completed" : "barrier";
|
||||
if (!seen.has(phase)) {
|
||||
seen.add(phase);
|
||||
const checkpoint = original.send("CHECKPOINT").payload.checkpoint;
|
||||
assert.ok(checkpoint?.state.program.native_state);
|
||||
assert.ok(!JSON.stringify(checkpoint.state.ledger).includes('"checkpoint":'), "durable ledger must not recursively contain earlier checkpoints");
|
||||
const restarted = await open(machine);
|
||||
restarted.session.workspaceFiles[machine.entrypoint.replace(/[^/]+$/, `remap_subs/${machine.subroutines[0]}.ngc`)] = "unrelated new subroutine contents";
|
||||
const result = restarted.session.restoreCheckpoint(checkpoint);
|
||||
assert.equal(result.status, "recovered", JSON.stringify(result.blocked));
|
||||
restarted.adoptSequence(checkpoint.event_cursor);
|
||||
assert.equal(fnv1a64(restarted.session.workspacePackageState()), fnv1a64(original.session.workspacePackageState()), `${machine.id} ${phase}: initial state`);
|
||||
for (let step = 0; step < 12; step++) {
|
||||
original.send("SIM_STEP", { cycles: 1, response: "motion_delta" }); restarted.send("SIM_STEP", { cycles: 1, response: "motion_delta" });
|
||||
const expected = original.session.workspacePackageState(), actual = restarted.session.workspacePackageState();
|
||||
assert.equal(fnv1a64(actual), fnv1a64(expected), `${machine.id} ${phase}: divergent tick ${original.session.tick}`);
|
||||
samples++;
|
||||
}
|
||||
rows.push({ machine: machine.id, phase, checkpoint_tick: checkpoint.simulation_tick, continuation_tick: original.session.tick, state_hash: fnv1a64(original.session.workspacePackageState()), samples: 12, first_divergent_tick: null });
|
||||
if (phase === "subroutine") {
|
||||
original.send("PROGRAM_PAUSE");
|
||||
const paused = original.send("CHECKPOINT").payload.checkpoint;
|
||||
const resumed = await open(machine);
|
||||
assert.equal(resumed.session.restoreCheckpoint(paused).status, "recovered");
|
||||
resumed.adoptSequence(paused.event_cursor);
|
||||
const transported = await open(machine);
|
||||
assert.equal(transported.send("RECOVER", { checkpoint: paused }).payload.status, "recovered");
|
||||
original.send("SIM_STEP", { cycles: 2 }); resumed.send("SIM_STEP", { cycles: 2 });
|
||||
assert.equal(fnv1a64(resumed.session.workspacePackageState()), fnv1a64(original.session.workspacePackageState()), "paused state must stay unchanged");
|
||||
original.send("PROGRAM_RESUME"); resumed.send("PROGRAM_RESUME"); transported.send("PROGRAM_RESUME");
|
||||
for (let step = 0; step < 12; step++) {
|
||||
original.send("SIM_STEP", { cycles: 1, response: "motion_delta" }); resumed.send("SIM_STEP", { cycles: 1, response: "motion_delta" });
|
||||
transported.send("SIM_STEP", { cycles: 1, response: "motion_delta" });
|
||||
assert.equal(fnv1a64(resumed.session.workspacePackageState()), fnv1a64(original.session.workspacePackageState()), `paused resume divergent tick ${original.session.tick}`);
|
||||
assert.deepEqual(transported.session.wasmMotion.read(), original.session.wasmMotion.read(), "new transport must continue the same physical trajectory");
|
||||
}
|
||||
rows.push({ machine: machine.id, phase: "paused-resumed", checkpoint_tick: paused.simulation_tick, continuation_tick: original.session.tick, state_hash: fnv1a64(original.session.workspacePackageState()), samples: 12, new_transport_recovery: true, first_divergent_tick: null });
|
||||
}
|
||||
}
|
||||
if (original.session.xyzacProgram.snapshot().program_status === "completed" && original.session.wasmMotion.read().state.queue_depth === 0) break;
|
||||
}
|
||||
assert.equal(original.session.xyzacProgram.snapshot().program_status, "completed");
|
||||
assert.ok(seen.has("line") && seen.has("arc"), [...seen].join(","));
|
||||
assert.ok(samples >= 24);
|
||||
const trace = original.session.exportTrace();
|
||||
const imported = await open(machine);
|
||||
assert.equal(imported.session.importTrace(trace).status, "verified");
|
||||
assert.equal(fnv1a64(imported.session.workspacePackageState()), fnv1a64(original.session.workspacePackageState()), "native trace import");
|
||||
const checkpoint = original.send("CHECKPOINT").payload.checkpoint;
|
||||
const invalid = structuredClone(checkpoint);
|
||||
invalid.state.wasm_motion.native_state.blocks[0][1] = "invalid";
|
||||
invalid.state_hash = fnv1a64(invalid.state);
|
||||
invalid.checkpoint_hash = fnv1a64(Object.fromEntries(Object.entries(invalid).filter(([key]) => key !== "checkpoint_hash")));
|
||||
const unchanged = fnv1a64(original.session.workspacePackageState());
|
||||
assert.equal(original.session.restoreCheckpoint(invalid).status, "blocked");
|
||||
assert.equal(fnv1a64(original.session.workspacePackageState()), unchanged, "corrupt native blob rejected before mutation");
|
||||
const foreignBuild = structuredClone(checkpoint);
|
||||
foreignBuild.state.wasm_motion.native_state.build_id = "0".repeat(64);
|
||||
foreignBuild.state_hash = fnv1a64(foreignBuild.state);
|
||||
foreignBuild.checkpoint_hash = fnv1a64(Object.fromEntries(Object.entries(foreignBuild).filter(([key]) => key !== "checkpoint_hash")));
|
||||
const rejectedBuild = original.session.restoreCheckpoint(foreignBuild);
|
||||
assert.equal(rejectedBuild.status, "blocked");
|
||||
assert.match(rejectedBuild.blocked[0].reason, /checkpoint_build_mismatch/);
|
||||
assert.equal(fnv1a64(original.session.workspacePackageState()), unchanged);
|
||||
const transported = await open(machine);
|
||||
const recovery = transported.send("RECOVER", { checkpoint });
|
||||
assert.equal(recovery.payload.status, "recovered", JSON.stringify(recovery.payload));
|
||||
const next = transported.send("SIM_STEP", { cycles: 1 });
|
||||
assert.equal(next.payload.program.program_status, "completed", "transport sequence continues after recovery");
|
||||
assert.deepEqual(next.payload.wasm_motion.state.position, original.session.wasmMotion.read().state.position);
|
||||
rows.push({ machine: machine.id, phase: "trace-and-transport", state_hash: fnv1a64(original.session.workspacePackageState()), first_divergent_tick: null });
|
||||
transported.send("SET_MODE", { mode: "MANUAL" });
|
||||
transported.send("PROGRAM_LOAD", { source: "G21 G90\nM2\n", path: "jog-after-recovery.ngc" });
|
||||
transported.send("JOG_START", { axis: "x", direction: 1, increment: .1 });
|
||||
let jogSamples = 0;
|
||||
while (transported.session.wasmMotion.read().state.queue_depth > 0 && jogSamples < 100) {
|
||||
const delta = transported.send("SIM_STEP", { cycles: 1, response: "motion_delta" });
|
||||
assert.deepEqual(delta.payload.motion_sample.joints, transported.session.wasmMotion.read().state.joint_commanded, "identity motion delta must use the current planner joints");
|
||||
jogSamples++;
|
||||
}
|
||||
assert.equal(transported.session.wasmMotion.read().state.queue_depth, 0);
|
||||
rows.push({ machine: machine.id, phase: "post-recovery-jog", samples: jogSamples, first_divergent_tick: null });
|
||||
const beforeRewind = transported.session.wasmMotion.read();
|
||||
const rewind = transported.send("RECOVER", { checkpoint: idle });
|
||||
assert.equal(rewind.payload.status, "blocked");
|
||||
assert.equal(rewind.payload.code, "runtime_checkpoint_requires_new_session");
|
||||
assert.deepEqual(transported.session.wasmMotion.read(), beforeRewind);
|
||||
}
|
||||
const report = { report_id: "trt-checkpoint-exact-v1", status: "verified", safeguards: { corrupt_native_rejected: true, foreign_build_rejected: true, frozen_subroutines: true, heap_relocation_checked: true, receipts_nonrecursive: true, backward_transport_restore_rejected: true }, rows, state_hash: fnv1a64(rows) };
|
||||
await mkdir(new URL("coverage/recovery/", root), { recursive: true });
|
||||
await writeFile(new URL("coverage/recovery/exact-checkpoint.report.json", root), JSON.stringify(report, null, 2) + "\n");
|
||||
console.log(JSON.stringify(report));
|
||||
98
web/worker/test_trt_configuration.mjs
Normal file
98
web/worker/test_trt_configuration.mjs
Normal file
@@ -0,0 +1,98 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile, mkdir, writeFile } from "node:fs/promises";
|
||||
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
|
||||
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
|
||||
import { readTrtConfiguration } from "../workspace/trt-configuration.mjs";
|
||||
import { MACHINE_DEFINITIONS } from "../workspace/machine-definitions.mjs";
|
||||
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
|
||||
import { fnv1a64 } from "./replay.mjs";
|
||||
|
||||
const root = new URL("../../", import.meta.url);
|
||||
const fetchImpl = async (url) => {
|
||||
try { const source = await readFile(new URL(String(url).replace(/^\//, ""), root), "utf8"); return { ok: true, text: async () => source }; }
|
||||
catch { return { ok: false }; }
|
||||
};
|
||||
const wasmBinary = await readFile(new URL("build-wasm/linuxcnc-sim-core.wasm", root));
|
||||
function edit(bundle, values) {
|
||||
let section = "";
|
||||
const content = bundle.files[bundle.entrypoint].split("\n").map((line) => {
|
||||
const heading = /^\s*\[([^\]]+)\]/.exec(line); if (heading) section = heading[1];
|
||||
const assignment = /^\s*([A-Z_]+)\s*=/.exec(line);
|
||||
const value = values[`${section}.${assignment?.[1]}`];
|
||||
return value === undefined ? line : `${assignment[1]} = ${value}`;
|
||||
}).join("\n");
|
||||
return { ...bundle, files: { ...bundle.files, [bundle.entrypoint]: content } };
|
||||
}
|
||||
const rows = [];
|
||||
for (const machine of MACHINE_DEFINITIONS) {
|
||||
const original = await loadSimulationCase({ path: machine.entrypoint, fetchImpl });
|
||||
const modified = edit(original, { "AXIS_X.MAX_VELOCITY": 4, "AXIS_X.MAX_ACCELERATION": 8, "AXIS_X.MAX_LIMIT": 15, "JOINT_0.MAX_LIMIT": 17, "TRAJ.MAX_LINEAR_VELOCITY": 13, "EMCMOT.SERVO_PERIOD": 2000000, "TASK.CYCLE_TIME": .02, "DISPLAY.MAX_FEED_OVERRIDE": 1.3 });
|
||||
const config = toWorkerConfiguration(modified);
|
||||
const zeroTolerance = { ...modified, files: { ...modified.files, [modified.entrypoint]: `${modified.files[modified.entrypoint]}\n[WEB_RUNTIME]\nSPINDLE_TOLERANCE = 0\n` } };
|
||||
assert.equal(readTrtConfiguration(zeroTolerance).kinematics.io.spindle_tolerance, 0);
|
||||
const blendingCase = { ...modified, files: { ...modified.files, [modified.entrypoint]: `${modified.files[modified.entrypoint]}\n[TRAJ]\nARC_BLEND_ENABLE = 1\nARC_BLEND_OPTIMIZATION_DEPTH = 25\nARC_BLEND_GAP_CYCLES = 3\nARC_BLEND_RAMP_FREQ = 50\nARC_BLEND_TANGENT_KINK_RATIO = 0.2\n` } };
|
||||
const configuredBlend = readTrtConfiguration(blendingCase).kinematics.blend;
|
||||
assert.equal(configuredBlend.optimization_depth, 25);
|
||||
assert.equal(readTrtConfiguration(edit(original, { "TRAJ.MAX_LINEAR_ACCELERATION": 8 })).kinematics.max_acceleration, 8);
|
||||
for (const declaration of ["SPINDLE_COUNT = 2.5", "TOOL_CAPACITY = 1002", "ENCODER_COUNT = 9"]) {
|
||||
const invalid = { ...modified, files: { ...modified.files, [modified.entrypoint]: `${modified.files[modified.entrypoint]}\n[WEB_RUNTIME]\n${declaration}\n` } };
|
||||
assert.throws(() => readTrtConfiguration(invalid), /trt_configuration_io_capacity/);
|
||||
}
|
||||
assert.deepEqual(config.home_limits.x, [-200, 17]);
|
||||
assert.deepEqual(config.kinematics.axis_limits.x, [-200, 15]);
|
||||
assert.equal(config.kinematics.axis_velocity_limits.x, 4);
|
||||
for (const values of [{ "AXIS_X.MAX_VELOCITY": "NaN" }, { "AXIS_X.MAX_ACCELERATION": 0 }, { "JOINT_0.MAX_LIMIT": -300 }, { "KINS.JOINTS": 4 }, { "TASK.CYCLE_TIME": .0015 }, { "DISPLAY.MAX_FEED_OVERRIDE": .5 }]) assert.throws(() => readTrtConfiguration(edit(original, values)), /trt_configuration/);
|
||||
const core = await createModule({ wasmBinary });
|
||||
const session = new SimSession(core, { sessionId: `configuration-${machine.id}` }); let seq = 0;
|
||||
const send = (kind, payload = {}, expected = "ok") => {
|
||||
const result = session.accept({ schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId, kind, seq: ++seq, operation_id: `cfg-${seq}`, payload });
|
||||
if (expected === "error") assert.equal(result.kind, "ERROR");
|
||||
else assert.notEqual(result.kind, "ERROR", `${kind}: ${JSON.stringify(result.payload)}`);
|
||||
return result;
|
||||
};
|
||||
send("HELLO"); send("SESSION_ATTACH");
|
||||
send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(original) });
|
||||
// Existing adapter values are untrusted duplicates: the files must win.
|
||||
config.home_limits.x = [-1, 1]; config.kinematics.max_linear_velocity = 99;
|
||||
send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: config });
|
||||
assert.equal(session.wasmMotion.velocityLimit, 13);
|
||||
assert.equal(session.wasmMotion.cycleTime, .002);
|
||||
assert.equal(session.wasmHomeLimit.period, .002);
|
||||
assert.equal(session.xyzacTaskCycleTicks, 10);
|
||||
assert.equal(readTrtConfiguration(modified).kinematics.io.cycle_time, .02);
|
||||
assert.equal(session.wasmMotion.jogVelocity("x"), 4);
|
||||
assert.deepEqual(session.xyzacKinematics.snapshot().limits.x, [-200, 17]);
|
||||
send("POWER", { active: true }); send("HOME", { all: true });
|
||||
for (let i = 0; i < 500 && !session.wasmHomeLimit.read().state.homed; i++) send("SIM_STEP", { cycles: 1 });
|
||||
assert.ok(session.wasmHomeLimit.read().state.homed);
|
||||
send("SET_OVERRIDE", { feed: 1.31 }, "error"); send("SET_OVERRIDE", { feed: 1.3 }); send("SET_OVERRIDE", { feed: 1 });
|
||||
send("JOG_START", { axis: "x", direction: 1, increment: 16 }, "error");
|
||||
send("MDI_EXECUTE", { block: "G21 G90 G1 X10 F1200" });
|
||||
let maxVelocity = 0, maxAcceleration = 0, previousVelocity = 0;
|
||||
const trace = [];
|
||||
for (let i = 0; i < 2000 && session.wasmMotion.read().state.queue_depth > 0; i++) {
|
||||
send("SIM_STEP", { cycles: 1 });
|
||||
const state = session.wasmMotion.read().state;
|
||||
maxVelocity = Math.max(maxVelocity, state.velocity);
|
||||
maxAcceleration = Math.max(maxAcceleration, Math.abs(state.velocity - previousVelocity) / .02);
|
||||
previousVelocity = state.velocity;
|
||||
trace.push({ tick: session.tick, x: state.position.x, velocity: state.velocity });
|
||||
}
|
||||
assert.equal(session.wasmMotion.read().state.queue_depth, 0);
|
||||
assert.ok(maxVelocity > 3.9 && maxVelocity <= 4.000001, `${machine.id} velocity ${maxVelocity}`);
|
||||
assert.ok(maxAcceleration <= 8.000001, `${machine.id} acceleration ${maxAcceleration}`);
|
||||
assert.ok(Math.abs(session.wasmMotion.read().state.position.x - 10) < 1e-8);
|
||||
rows.push({ machine: machine.id, max_velocity: maxVelocity, max_acceleration: maxAcceleration, period: session.wasmMotion.cycleTime, joint_limit: session.xyzacKinematics.snapshot().limits.x, trace });
|
||||
const checkpoint = session.createCheckpoint();
|
||||
send("POWER", { active: false });
|
||||
const newSpeed = toWorkerConfiguration(edit(modified, { "AXIS_X.MAX_VELOCITY": 5 }));
|
||||
send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: newSpeed });
|
||||
const restored = session.restoreCheckpoint(checkpoint);
|
||||
assert.equal(restored.status, "blocked");
|
||||
assert.equal(restored.blocked[0].reason, "trt_recovery_configuration_mismatch");
|
||||
assert.equal(session.wasmMotion.jogVelocity("x"), 5);
|
||||
}
|
||||
const report = { report_id: "trt-configuration-v1", status: "verified", rows };
|
||||
await mkdir(new URL("coverage/configuration/", root), { recursive: true });
|
||||
await writeFile(new URL("coverage/configuration/trt-runtime.report.json", root), JSON.stringify({ ...report, state_hash: fnv1a64(report) }, null, 2) + "\n");
|
||||
console.log(JSON.stringify({ status: report.status, report_id: report.report_id, rows: rows.map(({ trace, ...row }) => row) }));
|
||||
@@ -30,7 +30,7 @@ async function create(id) {
|
||||
assert.equal(send("POWER", "power-on", { active: true }).payload.status, "verified");
|
||||
assert.equal(send("HOME", "home-all", { all: true }).payload.homing.phase, "accepted");
|
||||
assert.equal(send("SIM_STEP", "home-complete", { cycles: 1 }).payload.panel_snapshot.motion.homed, true);
|
||||
return { core, session, send };
|
||||
return { core, session, send, adoptSequence: (value) => { seq = value; } };
|
||||
}
|
||||
|
||||
const continuous = await create("xyzac-recovery-continuous");
|
||||
@@ -42,6 +42,7 @@ continuous.send("PROGRAM_RUN", "program-run");
|
||||
const stepped = continuous.send("SIM_STEP", "motion-step", { cycles: 2 });
|
||||
assert.equal(stepped.payload.status, "verified");
|
||||
const checkpointResponse = continuous.send("CHECKPOINT", "checkpoint");
|
||||
assert.equal(checkpointResponse.kind, "CHECKPOINT_READY", checkpointResponse.payload.message_key);
|
||||
const checkpoint = checkpointResponse.payload.checkpoint;
|
||||
assert.equal(checkpoint.state.kinematics.active_type, 1);
|
||||
assert.equal(checkpoint.state.kinematics.coordinates, "XYZAC");
|
||||
@@ -57,7 +58,10 @@ continuous.send("SIM_STEP", "after-checkpoint", { cycles: 2 });
|
||||
const endpoint = continuous.session.workspacePackageState();
|
||||
|
||||
const restarted = await create("xyzac-recovery-continuous");
|
||||
const recovery = restarted.session.restoreCheckpoint(checkpoint, continuous.session.timeline.export(), (operation) => operation);
|
||||
const rejectedTail = restarted.session.restoreCheckpoint(checkpoint, continuous.session.timeline.export(), (operation) => operation);
|
||||
assert.equal(rejectedTail.status, "blocked");
|
||||
assert.equal(rejectedTail.blocked[0].reason, "trt_checkpoint_tail_requires_explicit_commands");
|
||||
const recovery = restarted.session.restoreCheckpoint(checkpoint);
|
||||
if (recovery.status !== "recovered") console.error(JSON.stringify(recovery));
|
||||
assert.equal(recovery.status, "recovered");
|
||||
const restored = restarted.session.workspacePackageState();
|
||||
@@ -68,11 +72,16 @@ assert.equal(restored.kinematics.state_hash, before.kinematics.state_hash);
|
||||
assert.equal(restored.kinematics.switch_count, switchCountBefore);
|
||||
assert.equal(restored.program.source_hash, before.program.source_hash);
|
||||
assert.equal(restored.motion_authority, "wasm");
|
||||
if (fnv1a64(before) !== fnv1a64(restored)) console.error(JSON.stringify({ differing: Object.fromEntries(Object.keys(before).filter((key) => fnv1a64(before[key]) !== fnv1a64(restored[key])).map((key) => [key, { before: before[key], restored: restored[key] }])) }));
|
||||
assert.equal(fnv1a64(restored), fnv1a64(before), "checkpoint must restore complete runtime state");
|
||||
|
||||
const replay = restarted.session.ledger.reconcile("switch-once");
|
||||
assert.equal(replay.status, "committed");
|
||||
assert.equal(restarted.session.xyzacKinematics.snapshot().switch_count, switchCountBefore);
|
||||
restarted.adoptSequence(checkpoint.event_cursor);
|
||||
const replayedSwitch = restarted.send("MDI_EXECUTE", "switch-once", { block: "M428" });
|
||||
assert.equal(replayedSwitch.replayed, true);
|
||||
assert.equal(restarted.session.xyzacKinematics.snapshot().switch_count, switchCountBefore);
|
||||
assert.deepEqual(replayedSwitch.payload.panel_snapshot.visual.joints, before.motion.joints);
|
||||
|
||||
const corruptedState = structuredClone(checkpoint);
|
||||
corruptedState.state.kinematics.pose.x += 1;
|
||||
@@ -111,8 +120,10 @@ console.log(JSON.stringify({
|
||||
status: "verified",
|
||||
case: CASE,
|
||||
checkpoint: { required_fields: ["context_id", "simulation_tick", "event_cursor", "state_hash", "task", "program", "kinematics", "motion", "wasm_motion", "ledger"], active_type: checkpoint.state.kinematics.active_type, state_hash: checkpoint.state.kinematics.state_hash },
|
||||
continuous_endpoint_hash: fnv1a64(endpoint),
|
||||
continuous_endpoint_hash: fnv1a64(before),
|
||||
restored_endpoint_hash: fnv1a64(restored),
|
||||
checkpoint_tick: checkpoint.simulation_tick,
|
||||
unreplayed_tail_endpoint_hash: fnv1a64(endpoint),
|
||||
endpoint_at_checkpoint_equal: fnv1a64(before) === fnv1a64(restored),
|
||||
no_duplicate_m428: restarted.session.xyzacKinematics.snapshot().switch_count === switchCountBefore,
|
||||
replayed_operation: replay.status === "committed",
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { fnv1a64 } from "./replay.mjs";
|
||||
import { exportNativeCheckpoint, restoreNativeCheckpoint } from "./native-checkpoint.mjs";
|
||||
import { WEB_SETTINGS } from "../workspace/web-settings.mjs";
|
||||
const DEFAULTS = WEB_SETTINGS.runtime.planner;
|
||||
|
||||
const POSE_BYTES = 72;
|
||||
const STATE_BYTES = 112;
|
||||
@@ -15,7 +18,7 @@ function poseArray(value = {}) { return ["x", "y", "z", "a", "b", "c", "u", "v",
|
||||
function toPose(values) { return Object.fromEntries(["x", "y", "z", "a", "b", "c", "u", "v", "w"].map((axis, index) => [axis, Number(values[index] ?? 0)])); }
|
||||
|
||||
export class WasmLinuxcncTp {
|
||||
constructor(core, { cycleTime = 0.001, velocityLimit = 35, accelerationLimit = 300, limits = {} } = {}) {
|
||||
constructor(core, { cycleTime = DEFAULTS.cycle_time, velocityLimit = DEFAULTS.velocity_limit, accelerationLimit = DEFAULTS.acceleration_limit, limits = {}, axisVelocities = {}, axisAccelerations = {}, maxFeedOverride = DEFAULTS.max_feed_override, blend = DEFAULTS.blend } = {}) {
|
||||
this.core = core;
|
||||
this.available = ["sim_linuxcnc_tp_size", "sim_linuxcnc_tp_queue_capacity", "sim_linuxcnc_tp_init", "sim_linuxcnc_tp_read_state", "sim_linuxcnc_tp_step", "sim_linuxcnc_tp_idle", "sim_linuxcnc_tp_add_line", "sim_linuxcnc_tp_set_profile", "sim_linuxcnc_tp_set_feed_override", "sim_linuxcnc_tp_set_velocity_scale", "sim_linuxcnc_tp_set_jog_scale", "sim_linuxcnc_tp_set_axis_limit", "sim_linuxcnc_tp_jog", "sim_linuxcnc_tp_velocity_scale", "sim_linuxcnc_tp_velocity_limit", "sim_linuxcnc_tp_velocity_mm_min", "sim_linuxcnc_tp_jog_scale", "sim_linuxcnc_tp_jog_velocity", "sim_linuxcnc_tp_feed_override", "sim_linuxcnc_tp_set_dynamics", "sim_linuxcnc_tp_set_paused", "sim_linuxcnc_tp_abort", "sim_linuxcnc_tp_set_goal_position", "sim_linuxcnc_tp_queue_depth", "sim_linuxcnc_tp_queue_segment", "sim_linuxcnc_tp_restore_progress"].every((name) => typeof core?.[`_${name}`] === "function" || typeof core?.[name] === "function");
|
||||
this.cycleTime = cycleTime;
|
||||
@@ -37,7 +40,8 @@ export class WasmLinuxcncTp {
|
||||
this.lastState = null;
|
||||
this.queueDepthOverride = null;
|
||||
this.kinematics = null;
|
||||
this.controls = { profile: 0, lookahead: 50, acceleration_limit: accelerationLimit, jerk_limit: 1e9, feed_override: 1, velocity_scale: 1, jog_scale: 1, paused: false };
|
||||
this.controls = { profile: 0, lookahead: blend.optimization_depth, acceleration_limit: accelerationLimit, jerk_limit: DEFAULTS.compatibility_jerk_limit, feed_override: 1, velocity_scale: 1, jog_scale: 1, paused: false };
|
||||
this.maxFeedOverride = maxFeedOverride;
|
||||
if (!this.available || typeof core?._malloc !== "function") return;
|
||||
this.planner = core._malloc(call(core, "sim_linuxcnc_tp_size", "number", [], []));
|
||||
this.posePointer = core._malloc(POSE_BYTES);
|
||||
@@ -55,6 +59,17 @@ export class WasmLinuxcncTp {
|
||||
const index = { x: 0, y: 1, z: 2, a: 3, b: 4, c: 5, u: 6, v: 7, w: 8 }[axis];
|
||||
if (index !== undefined && Array.isArray(limit) && limit.length === 2) this.setAxisLimit(index, Number(limit[0]), Number(limit[1]));
|
||||
}
|
||||
for (const [axis, velocity] of Object.entries(axisVelocities)) {
|
||||
const index = "xyzabcuvw".indexOf(axis);
|
||||
const configured = call(core, "sim_linuxcnc_tp_set_axis_dynamics", "number", ["number", "number", "number", "number"], [this.planner, index, velocity, axisAccelerations[axis]]);
|
||||
if (configured !== 0) throw new Error(`linuxcnc_tp_axis_dynamics_${axis}_${configured}`);
|
||||
}
|
||||
const feedLimit = call(core, "sim_linuxcnc_tp_set_feed_limit", "number", ["number", "number"], [this.planner, maxFeedOverride]);
|
||||
if (feedLimit !== 0) throw new Error(`linuxcnc_tp_feed_limit_${feedLimit}`);
|
||||
const scale = call(core, "sim_linuxcnc_tp_set_scale_minimum", "number", ["number", "number"], [this.planner, WEB_SETTINGS.ui.motion_scale_min]);
|
||||
if (scale !== 0) throw new Error(`linuxcnc_tp_scale_minimum_${scale}`);
|
||||
const blending = call(core, "sim_linuxcnc_tp_set_blend", "number", Array(7).fill("number"), [this.planner, blend.enable, blend.fallback_enable, blend.optimization_depth, blend.gap_cycles, blend.ramp_frequency, blend.tangent_kink_ratio]);
|
||||
if (blending !== 0) throw new Error(`linuxcnc_tp_blend_${blending}`);
|
||||
}
|
||||
|
||||
setPosition(value) {
|
||||
@@ -247,24 +262,19 @@ export class WasmLinuxcncTp {
|
||||
}
|
||||
} finally { this.core._free(pointer); }
|
||||
}
|
||||
const unsigned = { schema: SOURCE_TP_SCHEMA, cycle_time: this.cycleTime, velocity_limit: this.velocityLimit, controls: { ...this.controls }, execution_state: this.executionState, active_tag: this.activeTag, state: current.state, segments };
|
||||
const unsigned = { schema: SOURCE_TP_SCHEMA, cycle_time: this.cycleTime, velocity_limit: this.velocityLimit, controls: { ...this.controls }, execution_state: this.executionState, active_tag: this.activeTag, state: current.state, segments, segment_tags: [...this.segmentTags], last_active_id: this.lastActiveId, queue_depth_override: this.queueDepthOverride, native_state: exportNativeCheckpoint(this.core, "sim_linuxcnc_tp", this.planner) };
|
||||
return { ...unsigned, state_hash: fnv1a64(unsigned) };
|
||||
}
|
||||
restoreState(value) {
|
||||
if (!value || value.schema !== SOURCE_TP_SCHEMA || value.cycle_time !== this.cycleTime || value.velocity_limit !== this.velocityLimit || value.state_hash !== fnv1a64(Object.fromEntries(Object.entries(value).filter(([key]) => key !== "state_hash")))) throw new Error("linuxcnc_tp_restore_schema");
|
||||
this.reset();
|
||||
this.executionState = Number(value.execution_state ?? 3);
|
||||
this.setProfile(value.controls?.profile ?? 0, value.controls?.tolerance ?? 0);
|
||||
this.setFeedOverride(value.controls?.feed_override ?? 1);
|
||||
this.setVelocityScale(value.controls?.velocity_scale ?? 1);
|
||||
this.setJogScale(value.controls?.jog_scale ?? 1);
|
||||
this.setPosition(value.state?.position ?? {});
|
||||
for (const segment of value.segments ?? []) this.enqueue({ ...segment, kinematics_mode: segment.kinematics_mode ?? value.active_tag ?? 0 });
|
||||
if (this.available && value.state) {
|
||||
this.core.HEAPF64.set(poseArray(value.state.position), this.posePointer / 8);
|
||||
const restored = call(this.core, "sim_linuxcnc_tp_restore_progress", "number", ["number", "number", "number", "number"], [this.planner, this.posePointer, Number(value.state.velocity ?? 0), Number(value.state.tick ?? 0)]);
|
||||
if (restored !== 0) throw new Error(`linuxcnc_tp_restore_progress_${restored}`);
|
||||
}
|
||||
if (!value.native_state || !Array.isArray(value.segment_tags)) throw new Error("linuxcnc_tp_native_checkpoint_required");
|
||||
restoreNativeCheckpoint(this.core, "sim_linuxcnc_tp", value.native_state, this.planner);
|
||||
this.executionState = value.execution_state;
|
||||
this.activeTag = value.active_tag;
|
||||
this.controls = { ...value.controls };
|
||||
this.segmentTags = new Map(value.segment_tags);
|
||||
this.lastActiveId = value.last_active_id;
|
||||
this.queueDepthOverride = value.queue_depth_override;
|
||||
return this.read();
|
||||
}
|
||||
destroy() { for (const pointer of [this.planner, this.posePointer, this.centerPointer, this.normalPointer, this.statePointer]) if (pointer && typeof this.core?._free === "function") this.core._free(pointer); this.planner = this.posePointer = this.centerPointer = this.normalPointer = this.statePointer = 0; }
|
||||
|
||||
@@ -6,7 +6,7 @@ export class WasmNativeHoming {
|
||||
constructor(core, configuration, kinematics) {
|
||||
this.core = core;
|
||||
this.kinematics = kinematics;
|
||||
this.jointCount = 5;
|
||||
this.jointCount = configuration.home_limits.joint_count;
|
||||
this.size = core._sim_native_home_size();
|
||||
this.pointer = core._malloc(this.size);
|
||||
this.buffer = core._malloc(12 * 8);
|
||||
@@ -17,7 +17,7 @@ export class WasmNativeHoming {
|
||||
if (typeof value !== "string") throw new Error(`home_ini_missing:${path}`);
|
||||
return value;
|
||||
} } });
|
||||
this.parameters = Array.from({ length: 5 }, (_, j) => {
|
||||
this.parameters = Array.from({ length: this.jointCount }, (_, j) => {
|
||||
const section = parsed.sections[`JOINT_${j}`] ?? {};
|
||||
const n = (key, fallback) => section[key] === undefined ? fallback : Number(section[key]);
|
||||
const flags = [["HOME_IGNORE_LIMITS", 1], ["HOME_USE_INDEX", 2], ["HOME_IS_SHARED", 4], ["LOCKING_INDEXER", 8], ["HOME_INDEX_NO_ENCODER_RESET", 128]].reduce((mask, [key, bit]) => mask | (n(key, 0) ? bit : 0), 0);
|
||||
@@ -25,7 +25,7 @@ export class WasmNativeHoming {
|
||||
if (![0, 1, 2].includes(encoder)) throw new Error("native_home_absolute_encoder_invalid");
|
||||
return [n("HOME_OFFSET", 0), n("HOME", 0), n("HOME_FINAL_VEL", -1), n("HOME_SEARCH_VEL", 0), n("HOME_LATCH_VEL", 0), flags | (encoder ? 16 | 32 | (encoder === 2 ? 64 : 0) : 0), n("HOME_SEQUENCE", 999), n("VOLATILE_HOME", 0), n("MIN_LIMIT", -1e9), n("MAX_LIMIT", 1e9), n("MAX_VELOCITY", 1), n("MAX_ACCELERATION", 1)];
|
||||
});
|
||||
this.period = Number(parsed.sections.EMCMOT?.SERVO_PERIOD ?? 1000000) / 1e9;
|
||||
this.period = configuration.kinematics.servo_cycle_time;
|
||||
this.reset();
|
||||
}
|
||||
invoke(name, ...args) {
|
||||
@@ -41,13 +41,13 @@ export class WasmNativeHoming {
|
||||
step() { this.invoke("step"); return this.read(); }
|
||||
cancel(volatile = false) { this.invoke("cancel", Number(volatile)); return this.read(); }
|
||||
reset() {
|
||||
this.invoke("init", 5, this.period);
|
||||
this.invoke("init", this.jointCount, this.period);
|
||||
this.parameters.forEach((p, j) => { this.core.HEAPF64.set(p, this.buffer / 8); this.invoke("config", j, this.buffer); });
|
||||
return this.read();
|
||||
}
|
||||
check(joints) { if (!this.active) this.sync(joints); return this.read(); }
|
||||
read() {
|
||||
const joint = Array.from({ length: 5 }, (_, j) => ({
|
||||
const joint = Array.from({ length: this.jointCount }, (_, j) => ({
|
||||
joint: j, homing: Boolean(this.get(j, 4)), homed: Boolean(this.get(j, 5)),
|
||||
home_state: this.get(j, 6), home_state_name: `HOME_${STATES[this.get(j, 6)]}`,
|
||||
position: this.get(j, 7), motor_position: this.get(j, 8), motor_offset: this.get(j, 9),
|
||||
@@ -56,7 +56,7 @@ export class WasmNativeHoming {
|
||||
const joints = joint.map((j) => j.position);
|
||||
const result = this.kinematics.forward(joints);
|
||||
return { status: result.status, ...(result.code ? { code: result.code } : {}), state: {
|
||||
schema: "sim_home_limit_state.v1", source: "linuxcnc-homing.c", joint_count: 5,
|
||||
schema: "sim_home_limit_state.v1", source: "linuxcnc-homing.c", joint_count: this.jointCount,
|
||||
homed: joint.every((j) => j.homed), homed_mask: joint.reduce((mask, j) => mask | (j.homed ? 1 << j.joint : 0), 0),
|
||||
homing: this.active, motion_mode: this.mode, joint, joints, pose: result.pose ?? null,
|
||||
home_sequence: this.get(0, 3), tick: this.get(0, 2), last_error: result.result ?? 0, limit_error: result.result === -702 ? -702 : 0, kinematics_error: result.result ?? 0,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fnv1a64 } from "./replay.mjs";
|
||||
import { exportNativeCheckpoint, restoreNativeCheckpoint } from "./native-checkpoint.mjs";
|
||||
|
||||
const SCHEMA = "wasm_program_runtime.v1";
|
||||
const SEGMENT_BYTES = 216;
|
||||
@@ -237,8 +238,15 @@ export class WasmProgramRuntime {
|
||||
seek(line) { return this.invoke("sim_program_seek", [line], "program_seek"); }
|
||||
|
||||
restoreState(value) {
|
||||
if (!value || ![SCHEMA, "sim_program.v1"].includes(value.schema) || !Number.isSafeInteger(value.current_line) || value.current_line < 0 || !this.source) throw new Error("wasm_program_restore_schema");
|
||||
if (!value || ![SCHEMA, "sim_program.v1"].includes(value.schema) || !Number.isSafeInteger(value.current_line) || value.current_line < 0 || !this.source && !value.native_state) throw new Error("wasm_program_restore_schema");
|
||||
if (value.source_hash && value.source_hash !== this.sourceHash) throw new Error("wasm_program_restore_source_hash");
|
||||
if (value.native_state) {
|
||||
if (value.execution_source_hash !== fnv1a64(this.executionSource)) throw new Error("wasm_program_restore_execution_source_hash");
|
||||
restoreNativeCheckpoint(this.core, "sim_program", value.native_state, this.pointer);
|
||||
this.synchronizedPosition = structuredClone(value.synchronized_position);
|
||||
this.rapidVelocity = value.rapid_velocity;
|
||||
return this.snapshot();
|
||||
}
|
||||
const subroutines = [...this.subroutines];
|
||||
const loaded = this.load(this.source, this.path);
|
||||
if (loaded.status !== "verified") throw new Error(loaded.code);
|
||||
@@ -333,6 +341,10 @@ export class WasmProgramRuntime {
|
||||
return { status: "verified", program_status: state.status, line: state.current_line, control_code: state.control_code ?? 0, has_control: state.has_control === 1, program: state, motion: this.readMotion(state) };
|
||||
}
|
||||
|
||||
checkpoint() {
|
||||
return { ...this.snapshot(), subroutine_sources: structuredClone(this.subroutines), synchronized_position: structuredClone(this.synchronizedPosition), execution_source_hash: fnv1a64(this.executionSource), native_state: exportNativeCheckpoint(this.core, "sim_program", this.pointer) };
|
||||
}
|
||||
|
||||
snapshot() {
|
||||
if (!this.available) return { schema: SCHEMA, status: "blocked", code: "wasm_program_unavailable" };
|
||||
const required = call(this.core, "sim_program_canonical", "number", ["number", "number", "number"], [this.pointer, 0, 0]);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fnv1a64 } from "./replay.mjs";
|
||||
import { WEB_SETTINGS } from "../workspace/web-settings.mjs";
|
||||
|
||||
const DEVICE_SCHEMA = "wasm_xyzac_device_io.v1";
|
||||
const DEVICE_ERROR_REASONS = Object.freeze({
|
||||
@@ -42,7 +43,7 @@ function operationNumber(value) {
|
||||
}
|
||||
|
||||
export class WasmXyzacDeviceIo {
|
||||
constructor(core, { encoderCount = 2 } = {}) {
|
||||
constructor(core, { encoderCount = WEB_SETTINGS.runtime.io.encoder_count, cycleTime = WEB_SETTINGS.runtime.io.cycle_time } = {}) {
|
||||
this.core = core;
|
||||
this.available = ["sim_device_io_size", "sim_device_init", "sim_device_canonical", "sim_device_state_hash"].every((name) => typeof core?.[`_${name}`] === "function" || typeof core?.[name] === "function");
|
||||
this.pointer = 0;
|
||||
@@ -50,7 +51,7 @@ export class WasmXyzacDeviceIo {
|
||||
const size = call(core, "sim_device_io_size", "number", [], []);
|
||||
this.pointer = core._malloc(size);
|
||||
if (!this.pointer) throw new Error("wasm_xyzac_device_io_allocation_failed");
|
||||
const result = call(core, "sim_device_init", "number", ["number", "number", "number"], [this.pointer, encoderCount, 0.01]);
|
||||
const result = call(core, "sim_device_init", "number", ["number", "number", "number"], [this.pointer, encoderCount, cycleTime]);
|
||||
if (result !== 0) throw new Error(`wasm_xyzac_device_io_init_${result}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { fnv1a64 } from "./replay.mjs";
|
||||
import { exportNativeCheckpoint, restoreNativeCheckpoint } from "./native-checkpoint.mjs";
|
||||
import { WEB_SETTINGS } from "../workspace/web-settings.mjs";
|
||||
const DEFAULTS = WEB_SETTINGS.runtime.io;
|
||||
|
||||
const IO_SCHEMA = "wasm_xyzac_io.v1";
|
||||
const HAL_SCHEMA = "wasm_xyzac_hal.v1";
|
||||
@@ -44,7 +47,7 @@ function hexToBytes(value) {
|
||||
}
|
||||
|
||||
export class WasmXyzacIo {
|
||||
constructor(core, { spindleCount = 2, toolCapacity = 1001 } = {}) {
|
||||
constructor(core, { spindleCount = DEFAULTS.spindle_count, toolCapacity = DEFAULTS.tool_capacity, cycleTime = DEFAULTS.cycle_time, spindleAcceleration = DEFAULTS.spindle_acceleration, spindleTolerance = DEFAULTS.spindle_tolerance } = {}) {
|
||||
this.core = core;
|
||||
this.available = ["sim_io_size", "sim_io_init", "sim_io_canonical", "sim_io_state_hash"].every((name) => typeof core?.[`_${name}`] === "function" || typeof core?.[name] === "function");
|
||||
this.pointer = 0;
|
||||
@@ -58,7 +61,7 @@ export class WasmXyzacIo {
|
||||
this.pointer = core._malloc(this.ioSize);
|
||||
this.posePointer = core._malloc(POSE_BYTES);
|
||||
if (!this.pointer || !this.posePointer) throw new Error("wasm_xyzac_io_allocation_failed");
|
||||
const result = call(core, "sim_io_init", "number", ["number", "number", "number", "number", "number", "number"], [this.pointer, spindleCount, toolCapacity, 0.01, 600, 5]);
|
||||
const result = call(core, "sim_io_init", "number", ["number", "number", "number", "number", "number", "number"], [this.pointer, spindleCount, toolCapacity, cycleTime, spindleAcceleration, spindleTolerance]);
|
||||
if (result !== 0) throw new Error(`wasm_xyzac_io_init_${result}`);
|
||||
}
|
||||
|
||||
@@ -102,7 +105,7 @@ export class WasmXyzacIo {
|
||||
return this.export();
|
||||
}
|
||||
|
||||
halExport() {
|
||||
halExport({ includeState = false } = {}) {
|
||||
if (!this.available) return { schema: HAL_SCHEMA, source: "wasm_hal", status: "blocked", code: "wasm_hal_unavailable" };
|
||||
const tick = call(this.core, "sim_hal_tick", "number", [], []);
|
||||
const unsigned = {
|
||||
@@ -115,10 +118,16 @@ export class WasmXyzacIo {
|
||||
thread_count: call(this.core, "sim_hal_thread_count", "number", [], []),
|
||||
function_count: call(this.core, "sim_hal_function_count", "number", [], []),
|
||||
component_count: call(this.core, "sim_hal_component_count", "number", [], []),
|
||||
...(includeState ? { native_state: exportNativeCheckpoint(this.core, "sim_hal") } : {}),
|
||||
};
|
||||
return { ...unsigned, state_hash: fnv1a64(unsigned) };
|
||||
}
|
||||
|
||||
restoreHalState(value) {
|
||||
if (!value?.native_state) throw new Error("wasm_hal_native_checkpoint_required");
|
||||
restoreNativeCheckpoint(this.core, "sim_hal", value.native_state);
|
||||
}
|
||||
|
||||
halValue(name, value, type = 4, param = false) {
|
||||
if (!this.available) return { status: "blocked", code: "wasm_hal_unavailable" };
|
||||
if (param || !HAL_WRITE_ALLOWLIST.has(name)) return { status: "error", code: "wasm_hal_write_denied", name, target: param ? "param" : "pin" };
|
||||
|
||||
@@ -16,7 +16,6 @@ const SWITCH = Object.freeze({ switching: 16, active_mode: 20, pending_mode: 24,
|
||||
const TYPE = Object.freeze({ identity: 1, xyzac: 2, xyzbc: 3, userk: 1 });
|
||||
const MODE = Object.freeze({ identity: 0, xyzac: 1, userk: 2 });
|
||||
const AXES = Object.freeze(["x", "y", "z", "a", "b", "c", "u", "v", "w"]);
|
||||
const DEFAULT_LIMITS = Object.freeze({ x: [-200, 200], y: [-100, 100], z: [-120, 120], a: [-100, 50], b: [-100, 50], c: [-36000, 36000] });
|
||||
|
||||
function call(core, name, returnType, argTypes, args) {
|
||||
const direct = core?.[`_${name}`] ?? core?.[name];
|
||||
@@ -44,7 +43,8 @@ function configFor(core, pointer, type, options, coordinates) {
|
||||
view.setUint32(CONFIG.extra_joint_count, 0, true);
|
||||
setCoordinates(core, pointer, coordinates);
|
||||
for (const [index, axis] of coordinates.toLowerCase().split("").entries()) {
|
||||
const limit = options.limits?.[axis] ?? DEFAULT_LIMITS[axis];
|
||||
const limit = options.limits?.[axis];
|
||||
if (!Array.isArray(limit) || limit.length !== 2 || !limit.every(Number.isFinite) || limit[0] >= limit[1]) throw new Error(`wasm_trt_limit_required:${axis}`);
|
||||
core.HEAPU8[pointer + CONFIG.limit_enabled + index] = 1;
|
||||
view.setFloat64(CONFIG.joint_min + index * 8, Number(limit[0]), true);
|
||||
view.setFloat64(CONFIG.joint_max + index * 8, Number(limit[1]), true);
|
||||
@@ -69,11 +69,10 @@ function resultCode(operation, result) {
|
||||
export class WasmXyzacKinematics {
|
||||
constructor(core, options = {}) {
|
||||
this.core = core;
|
||||
this.coordinates = String(options.coordinates ?? "XYZAC").toUpperCase();
|
||||
this.coordinates = String(options.coordinates ?? options.limits?.coordinates ?? "XYZAC").toUpperCase();
|
||||
if (!["XYZAC", "XYZBC"].includes(this.coordinates)) throw new Error("wasm_trt_coordinates_invalid");
|
||||
this.kinematicsType = this.coordinates === "XYZBC" ? "xyzbc-trt-kins" : "xyzac-trt-kins";
|
||||
this.options = { x_rot_point: 0, y_rot_point: 0, z_rot_point: 0, x_offset: 0, y_offset: 20, z_offset: 10, tool_offset: 0, limits: DEFAULT_LIMITS, ...options };
|
||||
if (this.coordinates === "XYZBC" && options.limits === undefined) this.options.limits = { x: [-200, 200], y: [-100, 100], z: [-120, 120], b: [-100, 50], c: [-36000, 36000] };
|
||||
this.options = { x_rot_point: 0, y_rot_point: 0, z_rot_point: 0, x_offset: 0, y_offset: 0, z_offset: 0, tool_offset: 0, ...options };
|
||||
this.available = ["sim_kinematics_init", "sim_kinematics_switch_init", "sim_kinematics_switch_register", "sim_kinematics_switch_select", "sim_kinematics_switch_select_physical", "sim_kinematics_switch_forward", "sim_kinematics_switch_inverse", "sim_kinematics_switch_inverse_mode", "sim_kinematics_switch_active", "sim_kinematics_switch_state_hash"].every((name) => typeof core?.[`_${name}`] === "function" || typeof core?.[name] === "function");
|
||||
this.lastCommand = null;
|
||||
this.modules = new Map();
|
||||
@@ -198,7 +197,7 @@ export class WasmXyzacKinematics {
|
||||
const activeMode = this.activeMode;
|
||||
const nativeType = activeMode === MODE.xyzac ? this.kinematicsType : activeMode === MODE.userk ? "userk-identity-template" : "identity";
|
||||
const view = new DataView(this.core.HEAPU8.buffer, this.switchPointer, SWITCH_SIZE);
|
||||
const limits = Object.fromEntries(this.coordinates.toLowerCase().split("").map((axis) => [axis, [...(this.options.limits?.[axis] ?? DEFAULT_LIMITS[axis])]]));
|
||||
const limits = Object.fromEntries(this.coordinates.toLowerCase().split("").map((axis) => [axis, [...this.options.limits[axis]]]));
|
||||
const unsigned = { schema: "wasm-xyzac-kinematics.v1", active_type: activeMode, native_type: nativeType, coordinates: this.coordinates, joint_count: 5, linear_units: "mm", angular_units: "deg", pose: currentPose, joints: joints.slice(0, 5), offsets: { x_rot_point: this.options.x_rot_point, y_rot_point: this.options.y_rot_point, z_rot_point: this.options.z_rot_point, x_offset: this.options.x_offset, y_offset: this.options.y_offset, z_offset: this.options.z_offset, tool_offset: this.options.tool_offset }, limits, generation: view.getUint32(SWITCH.generation, true), switch_count: view.getUint32(SWITCH.switch_count, true), abort_count: view.getUint32(SWITCH.abort_count, true), last_command: this.lastCommand, capability: { userk: "linked_identity_template", dynamic_modules: "denied" }, source: "linuxcnc-wasm-sim_kinematics_switch" };
|
||||
const hash = call(this.core, "sim_kinematics_switch_state_hash", "number", ["number", "number", "number", "number"], [this.switchPointer, this.jointPointer, 5, this.posePointer]);
|
||||
return { status: "verified", ...unsigned, wasm_state_hash: `fnv1a64:${BigInt.asUintN(64, BigInt(hash >>> 0)).toString(16).padStart(16, "0")}`, state_hash: fnv1a64(unsigned) };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { SimSession, CONTEXT_ID, PROTOCOL_SCHEMA } from "./sim-session.mjs";
|
||||
import { XyzacCommandLedger, XYZAC_COMMAND_LEDGER_SCHEMA } from "./xyzac-command-ledger.mjs";
|
||||
import { XyzacScheduler, XYZAC_SCHEDULER_SCHEMA } from "./xyzac-scheduler.mjs";
|
||||
import { DEFAULT_MACHINE } from "../workspace/machine-definitions.mjs";
|
||||
|
||||
export const XYZAC_SESSION_SCHEMA = "xyzac-session.v1";
|
||||
|
||||
@@ -13,6 +14,7 @@ export class XyzacSession extends SimSession {
|
||||
}
|
||||
|
||||
authority() {
|
||||
const machine = this.machineDefinition ?? DEFAULT_MACHINE;
|
||||
return {
|
||||
schema: XYZAC_SESSION_SCHEMA,
|
||||
authority: "linuxcnc-wasm-worker",
|
||||
@@ -22,7 +24,7 @@ export class XyzacSession extends SimSession {
|
||||
protocol_schema: PROTOCOL_SCHEMA,
|
||||
ledger_schema: XYZAC_COMMAND_LEDGER_SCHEMA,
|
||||
scheduler_schema: XYZAC_SCHEDULER_SCHEMA,
|
||||
machine: { coordinates: "XYZAC", joint_count: 5, kinematics: "xyzac-trt", rotary_units: "degrees" },
|
||||
machine: { coordinates: machine.coordinates, joint_count: machine.joint_count, kinematics: machine.id, rotary_units: "degrees" },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user