Add AC/BC RTCP programs and 3D machine manager

This commit is contained in:
2026-09-20 12:31:12 +08:00
parent 1c1e5f9672
commit 8aab531b02
363 changed files with 53213 additions and 58 deletions

View File

@@ -54,9 +54,18 @@ export class SimScheduler {
}
for (let index = 0; index < cycles; index += 1) {
const nextTick = this.tick + 1;
for (const phase of SCHEDULER_PHASES) {
this.phase_trace = [...SCHEDULER_PHASES];
if (typeof callbacks[phase] === "function") callbacks[phase](nextTick);
try {
for (const phase of SCHEDULER_PHASES) {
this.phase_trace = [...SCHEDULER_PHASES];
if (typeof callbacks[phase] === "function") callbacks[phase](nextTick);
}
} catch (error) {
// Callbacks may already have advanced motion inside this tick. Do
// not replay it or retain the native scheduler's unexecuted future.
this.tick = nextTick;
this.last_cycles = index + 1;
this.restore(this.export());
throw error;
}
this.tick = nextTick;
}

View File

@@ -683,11 +683,19 @@ export class SimSession {
const joints = Array.isArray(current.state.joint_commanded) && current.state.joint_commanded.length === 5
? [...current.state.joint_commanded]
: [...this.xyzacKinematics.snapshot().joints];
const selected = this.xyzacKinematics.select(mode, joints);
// A switchkins button changes the coordinate interpretation of the
// stopped machine. Preserve the physical joint sample and recompute
// only the coordinate pose in the new kinematics. `select()` converts
// the old pose back into the target mode, which changes joints and makes
// an idle M429/M428 button appear to move the machine.
const selected = this.xyzacKinematics.selectPhysical(mode, joints);
if (selected.status !== "verified") return selected;
this.xyzacProgramKinematicsMode = mode;
if (typeof this.wasmMotion.setActiveKinematicsTag === "function") this.wasmMotion.setActiveKinematicsTag(mode);
if (selected.pose && typeof this.wasmMotion.setPosition === "function") this.wasmMotion.setPosition(selected.pose);
if (selected.pose && typeof this.wasmMotion.setPosition === "function") this.wasmMotion.setPosition(selected.pose, mode);
if (selected.pose && this.xyzacProgram) {
const synchronized = this.xyzacProgram.synchronizePosition(selected.pose);
if (synchronized.status !== "verified") return synchronized;
}
return { ...selected, kinematics: this.xyzacKinematics.snapshot(), wasm_motion: this.wasmMotion.read() };
}
@@ -1457,22 +1465,27 @@ export class SimSession {
});
}
if (Number.isSafeInteger(input.simulation_tick) && input.simulation_tick >= this.tick) this.tick = input.simulation_tick;
const pausedStep = input.kind === "SIM_STEP" && this.xyzacProgram && (this.task.state.state === "PAUSED" || this.xyzacProgram.snapshot().status === 3);
const eventTick = input.kind === "SIM_STEP" && Number.isSafeInteger(input.payload?.cycles) && !pausedStep
? this.tick + input.payload.cycles : this.tick;
try {
this.timeline.appendEvent(input.seq, eventTick, input.kind, input.payload ?? {});
} catch (error) {
if (text(input.operation_id)) this.operations.delete(input.operation_id);
return this.error(input, "event_log", String(error?.message ?? error));
}
const coreEventResult = appendCoreEvent(this.core, input.seq, eventTick, input.kind, input.payload ?? {});
if (coreEventResult !== 0) {
if (text(input.operation_id)) this.operations.delete(input.operation_id);
return this.error(input, "event_log", "core_event_append_failed");
// SIM_STEP may stop partway through its requested cycles. Record its
// actual end tick, never a future tick that poisons subsequent commands.
let eventRecorded = false;
const recordEvent = () => {
if (eventRecorded) return;
this.timeline.appendEvent(input.seq, this.tick, input.kind, input.payload ?? {});
eventRecorded = true;
const result = appendCoreEvent(this.core, input.seq, this.tick, input.kind, input.payload ?? {});
if (result !== 0) throw new Error("core_event_append_failed");
};
if (input.kind !== "SIM_STEP") {
try { recordEvent(); }
catch (error) {
if (text(input.operation_id)) this.operations.delete(input.operation_id);
return this.error(input, "event_log", String(error?.message ?? error));
}
}
const finish = (response, status = "verified") => {
try { recordEvent(); }
catch (error) { return this.error(input, "event_log", String(error?.message ?? error)); }
if (text(input.operation_id) && OPERATION_KINDS.has(input.kind)) {
const operation = this.operations.get(input.operation_id);
if (operation) {
@@ -2174,8 +2187,26 @@ export class SimSession {
},
});
} catch (error) {
return finish(this.error(input, "scheduler", String(error?.message ?? error)), "failed");
// The scheduler may have consumed a partial task cycle. Align all
// transport clocks to that attempted tick and leave motion stopped.
this.tick = this.scheduler.tick;
this.task.setTick(this.tick);
call(this.core, "sim_protocol_set_tick", this.tick);
if (this.xyzacProgram) {
this.wasmMotion.abort();
this.pendingXyzacMotion = null;
if (this.xyzacProgram.snapshot().status === 2) this.xyzacProgram.abort();
if (this.task.state.state === "RUNNING") this.task.transition("STOPPED");
}
const reason = String(error?.message ?? error);
const limit = reason === "motion_joint_limit";
return finish(this.envelope("ERROR", input, {
status: limit ? "blocked" : "error", code: limit ? "motion_limit" : "scheduler",
message_key: limit ? "关节到达限位,运动已停止;可反向 Jog" : reason,
reason, motion_stopped: Boolean(this.xyzacProgram), completed_cycles: this.scheduler.last_cycles,
}), "failed");
}
recordEvent();
const program = this.xyzacProgram ? this.xyzacProgram.snapshot() : this.program.loaded ? this.program.snapshot() : null;
const execution_trace = program_steps
.map((step) => ({
@@ -2730,6 +2761,10 @@ export class SimSession {
})();
if (jog.status !== "verified") {
const code = jog.code === "linuxcnc_tp_jog_-1217" ? "motion_limit_range" : jog.code === "linuxcnc_tp_jog_-1215" ? "motion_jog_nonfinite" : jog.code;
if (code === "motion_limit_range") return finish(this.envelope("ERROR", input, {
status: "blocked", code: "motion_limit", message_key: code,
reason: "Jog 超出行程范围;请反向移动或减小增量", command: input.kind,
}), "failed");
return finish(this.error(input, "motion_queue", code), "failed");
}
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, motion: this.wasmMotionProjection(), wasm_motion: this.wasmMotion.read(), motion_authority: "wasm", operation_id: input.operation_id ?? null }), "verified");

View File

@@ -0,0 +1,78 @@
import assert from 'node:assert/strict';
import { readFile, mkdir, writeFile } from 'node:fs/promises';
import createModule from '../../build-wasm/linuxcnc-sim-core.js';
import { XyzacSession, CONTEXT_ID } from './xyzac-session.mjs';
import { loadSimulationCase, toWorkerConfiguration } from '../workspace/sim-case-loader.mjs';
import { MACHINE_DEFINITIONS } from '../workspace/machine-definitions.mjs';
const root = new URL('../../', import.meta.url);
const output = new URL('coverage/glb-machine/jog-limits/', root);
await mkdir(output, { recursive: true });
const fetchImpl = async (url) => { try { const body = await readFile(new URL(String(url).replace(/^\//, ''), root), 'utf8'); return { ok: true, text: async () => body }; } catch { return { ok: false }; } };
const report = { report_id: 'jog-limit-recovery-v1', status: 'running', machines: [] };
try {
for (const definition of MACHINE_DEFINITIONS) {
const core = await createModule({ wasmBinary: await readFile(new URL('build-wasm/linuxcnc-sim-core.wasm', root)) });
const session = new XyzacSession(core, { sessionId: `jog-limit-${definition.id}`, planner: 'linuxcnc-tp' }); let seq = 0;
const send = (kind, payload = {}, expected = null) => {
const result = session.accept({ schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId, kind, seq: ++seq, operation_id: `limit-${seq}`, payload });
if (expected) assert.equal(result.kind, expected, JSON.stringify(result.payload));
else assert.notEqual(result.kind, 'ERROR', `${kind}: ${result.payload.message_key ?? result.payload.gate_reason}`);
assert.equal(session.timeline.events.cursor, seq, `${kind}: event cursor`);
assert.equal(core._sim_event_cursor(), seq, `${kind}: native event cursor`);
return result;
};
send('HELLO'); send('SESSION_ATTACH');
const bundle = await loadSimulationCase({ path: definition.entrypoint, fetchImpl });
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 });
const tilt = definition.axes[3];
const drain = () => { for (let i = 0; i < 2000 && session.wasmMotion.read().state.queue_depth; i++) send('SIM_STEP', { cycles: 5, response: 'motion_delta' }); assert.equal(session.wasmMotion.read().state.queue_depth, 0); };
send('MDI_EXECUTE', { block: `G21 G90 G0 X0 Y0 Z30 ${tilt.toUpperCase()}49 C20` }); drain();
send('JOG_START', { axis: tilt, direction: 1, continuous: true, increment: null });
drain();
const limit = session.wasmMotion.read().state;
assert.ok(limit.joint_commanded[3] <= 50 + 1e-8);
send('JOG_STOP');
send('JOG_START', { axis: tilt, direction: -1, increment: 1 }); drain(); send('JOG_STOP');
assert.ok(Math.abs(session.wasmMotion.read().state.joint_commanded[3] - 49) < 1e-7);
send('JOG_START', { axis: tilt, direction: 1, increment: 5 }, 'ERROR');
send('JOG_STOP');
send('SNAPSHOT_REQUEST');
const recovered = session.wasmMotion.read().state.joint_commanded;
send('MDI_EXECUTE', { block: `G90 G0 X190 Y0 Z30 ${tilt.toUpperCase()}0 C0` }); drain();
send('KINEMATICS_SELECT', { type: 1 });
send('JOG_START', { axis: 'c', direction: 1, continuous: true, increment: null });
let fault;
for (let i = 0; i < 2000; i++) {
const r = session.accept({ schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId, kind: 'SIM_STEP', seq: ++seq, operation_id: `limit-${seq}`, payload: { cycles: 5, response: 'motion_delta' } });
if (r.kind === 'ERROR') { fault = r; break; }
}
assert.equal(fault?.payload.code, 'motion_limit', JSON.stringify(fault?.payload));
assert.equal(session.timeline.events.cursor, seq);
assert.equal(core._sim_event_cursor(), seq);
assert.equal(session.timeline.events.tick, session.tick);
assert.equal(session.scheduler.tick, session.tick);
const stopped = session.wasmMotion.read().state;
assert.equal(stopped.queue_depth, 0);
assert.equal(stopped.velocity, 0, 'limit stop must publish zero velocity');
assert.ok(stopped.joint_commanded.every((value, i) => { const range = session.xyzacKinematics.options.limits[definition.axes[i]]; return value >= range[0] - 1e-8 && value <= range[1] + 1e-8; }));
send('JOG_STOP'); send('SNAPSHOT_REQUEST');
const checkpoint = session.createCheckpoint();
const restored = session.restoreCheckpoint(checkpoint);
assert.equal(restored.status, 'recovered');
send('JOG_START', { axis: 'c', direction: -1, increment: 1 }); drain(); send('JOG_STOP');
assert.ok(session.wasmMotion.read().state.position.c < stopped.position.c - .99);
// Invalid sampling requests must also consume their event at the actual
// clock, rather than reserving ticks that will never run.
send('SIM_STEP', { cycles: 1000001 }, 'ERROR');
send('SIM_STEP', { cycles: -1 }, 'ERROR');
send('SNAPSHOT_REQUEST');
send('SIM_STEP', { cycles: 1, response: 'motion_delta' });
report.machines.push({ coordinates: definition.coordinates, limit: limit.joint_commanded, recovered, rtcp_limit: stopped.joint_commanded, stopped_tick: fault.simulation_tick, completed_cycles: fault.payload.completed_cycles, seq });
}
report.status = 'verified';
} catch (error) { report.status = 'failed'; report.error = String(error.stack ?? error); throw error; }
finally { await writeFile(new URL('worker.report.json', output), JSON.stringify(report, null, 2) + '\n'); }
console.log(JSON.stringify(report));

View File

@@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
import { fnv1a64 } from "./replay.mjs";
import { CONTEXT_ID, SimSession } from "./sim-session.mjs";
const root = new URL("../../", import.meta.url);
const catalog = JSON.parse(await readFile(new URL("web/react/src/rtcp-program-manifest.json", root), "utf8"));
const fetchImpl = async (url) => {
try { const body = await readFile(new URL(String(url).replace(/^\//, ""), root)); return { ok: true, text: async () => body.toString("utf8") }; }
catch { return { ok: false, text: async () => "" }; }
};
const machines = {
XYZAC: "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini",
XYZBC: "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini",
};
const wasmBinary = await readFile(new URL("build-wasm/linuxcnc-sim-core.wasm", root));
const core = await createModule({ wasmBinary });
const evidence = [];
for (const [coordinates, casePath] of Object.entries(machines)) {
const bundle = await loadSimulationCase({ path: casePath, fetchImpl });
assert.equal(bundle.status, "ready", `${coordinates}:case`);
const session = new SimSession(core, { sessionId: `rtcp-catalog-${coordinates.toLowerCase()}` });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
let seq = 0;
const send = (kind, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: `${kind.toLowerCase()}-${seq}`, payload });
send("HELLO"); send("SESSION_ATTACH");
assert.equal(send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }).payload.configuration.state, "TASK_READY");
const programs = catalog.programs.filter((program) => program.role === "main" && program.coordinates.includes(coordinates));
for (const program of programs) {
const source = await readFile(new URL(program.path, root), "utf8");
const loaded = send("PROGRAM_LOAD", { source, path: program.path });
assert.equal(loaded.kind, "COMMAND_RESULT", `${program.id}:${JSON.stringify(loaded.payload)}`);
assert.equal(loaded.payload.status, "verified", program.id);
assert.equal(loaded.payload.program.path, program.path, program.id);
assert.equal(loaded.payload.program.source_hash, fnv1a64(source), program.id);
assert.equal(loaded.payload.preview.source, "linuxcnc-wasm-sim_program", program.id);
evidence.push({ id: program.id, coordinates, source_hash: loaded.payload.program.source_hash, preview_segments: loaded.payload.preview.segments.length });
}
}
assert.equal(evidence.length, 11);
console.log(JSON.stringify({ schema: "rtcp-program-catalog-load.v1", status: "verified", programs: evidence }));

View File

@@ -20,6 +20,15 @@ assert.equal(fast.tick, 10);
const restored = new SimScheduler(core, { budget: 100 });
assert.equal(restored.restore(fast.export()).state_hash, fast.hash());
const interrupted = new SimScheduler(core, { budget: 100 });
assert.throws(() => interrupted.step(10, { motion: (tick) => { if (tick === 3) throw new Error("injected_motion_fault"); } }), /injected_motion_fault/);
assert.equal(interrupted.tick, 3);
assert.equal(interrupted.last_cycles, 3);
const afterFault = [];
interrupted.step(2, { motion: (tick) => afterFault.push(tick) });
assert.deepEqual(afterFault, [4, 5]);
assert.equal(interrupted.tick, 5);
let lastSeq = 0;
const session = new SimSession({
_sim_protocol_reset() {},

View File

@@ -1,5 +1,6 @@
import assert from "node:assert/strict";
import { readFile, mkdir, writeFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";
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";
@@ -137,6 +138,7 @@ for (const machine of MACHINE_DEFINITIONS) {
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");
const output = process.env.TRT_EVIDENCE_DIR ? pathToFileURL(`${process.env.TRT_EVIDENCE_DIR.replace(/\/$/, "")}/`) : new URL("coverage/recovery/", root);
await mkdir(output, { recursive: true });
await writeFile(new URL("exact-checkpoint.report.json", output), JSON.stringify(report, null, 2) + "\n");
console.log(JSON.stringify(report));

View File

@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import { readFile, 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 { writeTrtGeometry } from '../workspace/trt-geometry.mjs';
import { MACHINE_DEFINITIONS } from '../workspace/machine-definitions.mjs';
const root = new URL('../../', import.meta.url), output = new URL('coverage/glb-machine/switching/', root);
const fetchImpl = async (url) => { try { const body = await readFile(new URL(String(url).replace(/^\//, ''), root), 'utf8'); return { ok: true, text: async () => body }; } catch { return { ok: false }; } };
const report = { report_id: 'trt-stationary-switch-worker-v1', status: 'running', machines: [] };
const closeJoints = (actual, expected, label) => actual.forEach((v, i) => assert.ok(Math.abs(v - expected[i]) < 1e-8, `${label} J${i}: ${v} != ${expected[i]}`));
try {
for (const definition of MACHINE_DEFINITIONS) {
const core = await createModule({ wasmBinary: await readFile(new URL('build-wasm/linuxcnc-sim-core.wasm', root)) });
const session = new SimSession(core, { sessionId: `switch-${definition.id}` }); let seq = 0;
const send = (kind, payload = {}, expected = 'COMMAND_RESULT') => {
const r = session.accept({ schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId, kind, seq: ++seq, operation_id: `switch-${seq}`, payload });
assert.equal(r.kind, expected, `${kind}: ${JSON.stringify(r.payload)}`); return r;
};
send('HELLO', {}, 'CAPABILITIES'); send('SESSION_ATTACH', {}, 'SESSION_READY');
const offsets = definition.coordinates === 'XYZAC' ? { x_rot_point: 24, y_rot_point: -12, z_rot_point: 8, y_offset: 18, z_offset: -45 } : { x_rot_point: -18, y_rot_point: 14, z_rot_point: 12, x_offset: 22, z_offset: -40 };
const bundle = writeTrtGeometry(await loadSimulationCase({ path: definition.entrypoint, fetchImpl }), offsets);
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);
const drain = () => { for (let i = 0; i < 5000 && session.wasmMotion.read().state.queue_depth; i++) send('SIM_STEP', { cycles: 1 }); assert.equal(session.wasmMotion.read().state.queue_depth, 0); };
const rows = [];
for (const [x, y, z, tilt, c] of [[0, 0, 44.5858, 4.8675, 179.88], [190, -95, 110, 40, 175]]) {
send('KINEMATICS_SELECT', { type: 0 });
send('MDI_EXECUTE', { block: `G21 G90 G0 X${x} Y${y} Z${z} ${definition.coordinates[3]}${tilt} C${c}` }); drain();
const baseline = session.wasmMotion.read().state.joint_commanded;
for (const kind of ['KINEMATICS_SELECT', 'MDI_EXECUTE']) for (const type of [1, 0, 1, 1, 0, 2, 1, 0]) {
const before = session.wasmMotion.read().state;
const result = send(kind, kind === 'KINEMATICS_SELECT' ? { type } : { block: `M${type === 1 ? 428 : type === 0 ? 429 : 430}` });
const current = session.wasmMotion.read().state;
closeJoints(current.joint_commanded, baseline, `${kind} mode=${type}`);
closeJoints(result.payload.panel_snapshot.visual.joints, baseline, 'visual');
closeJoints(result.payload.kinematics.joints, baseline, 'kinematics');
assert.equal(current.active_tag, type); assert.equal(current.queue_depth, 0);
assert.deepEqual(session.xyzacProgram.synchronizedPosition, current.position, 'interpreter must share new frame');
for (let i = 0; i < 3; i++) { send('SIM_STEP', { cycles: 1 }); closeJoints(session.wasmMotion.read().state.joint_commanded, baseline, 'idle tick'); }
rows.push({ kind, type, baseline, joints: current.joint_commanded, previous_pose: before.position, pose: current.position });
}
}
send('KINEMATICS_SELECT', { type: 0 });
send('MDI_EXECUTE', { block: `G21 G90 G0 X10 Y5 Z20 ${definition.coordinates[3]}20 C60` }); drain();
send('MDI_EXECUTE', { block: 'G10 L2 P1 X3 Y-2 Z4' });
send('KINEMATICS_SELECT', { type: 1 });
const beforeIncrement = session.wasmMotion.read().state.position;
send('MDI_EXECUTE', { block: 'G91 G1 X1 F600' }); drain();
const afterIncrement = session.wasmMotion.read().state.position;
assert.ok(Math.abs(afterIncrement.x - beforeIncrement.x - 1) < 1e-8);
for (const key of ['y', 'z', 'a', 'b', 'c']) assert.ok(Math.abs(afterIncrement[key] - beforeIncrement[key]) < 1e-8);
send('MDI_EXECUTE', { block: 'G91 G1 X10 F60' });
const activeBefore = session.wasmMotion.read().state;
send('KINEMATICS_SELECT', { type: 0 }, 'ERROR');
assert.equal(session.xyzacKinematics.activeMode, 1); closeJoints(session.wasmMotion.read().state.joint_commanded, activeBefore.joint_commanded, 'blocked switch');
drain();
const beforeInvalid = session.wasmMotion.read().state;
send('KINEMATICS_SELECT', { type: 9 }, 'ERROR'); closeJoints(session.wasmMotion.read().state.joint_commanded, beforeInvalid.joint_commanded, 'invalid switch');
report.machines.push({ coordinates: definition.coordinates, rows, beforeIncrement, afterIncrement, busy_and_invalid_rejected: true });
}
report.status = 'verified';
} catch (error) { report.status = 'failed'; report.error = String(error.stack ?? error); throw error; }
finally { await writeFile(new URL('worker.report.json', output), JSON.stringify(report, null, 2) + '\n'); }
console.log(JSON.stringify({ report_id: report.report_id, status: report.status, switches: report.machines.reduce((n, m) => n + m.rows.length, 0) }));

View File

@@ -50,20 +50,22 @@ for (const [code, expected] of [["M429", 0], ["M428", 1], ["M430", 2]]) {
switchRows.push({ code, tick: result.simulation_tick, active_type: expected, state_hash: result.payload.panel_snapshot.state_hash });
}
// Direct panel selection must preserve the live WASM TP pose at a non-zero
// position for every pair of IDENTITY/TCP:XYZAC/USERK transitions.
// Direct panel selection must preserve physical joints and keep the new
// coordinate pose synchronized with TP for every mode transition.
const move = send("MDI_EXECUTE", "seed-position", { block: "G0 X10 Y5 Z20 A30 C45" });
assert.equal(move.kind, "COMMAND_RESULT");
for (let cycle = 0; cycle < 1000 && session.wasmMotion.read().state.queue_depth > 0; cycle += 1) send("SIM_STEP", `seed-step-${cycle}`, { cycles: 1 });
assert.equal(session.wasmMotion.read().state.queue_depth, 0);
const directSwitches = [];
for (const expected of [0, 1, 2, 0, 1, 2, 0]) {
const physicalJoints = session.wasmMotion.read().state.joint_commanded;
const result = send("KINEMATICS_SELECT", `direct-${expected}-${seq}`, { type: expected });
assert.equal(result.kind, "COMMAND_RESULT");
assert.equal(result.payload.kinematics.active_type, expected);
const pose = result.payload.kinematics.pose;
const tpPose = result.payload.wasm_motion.state.position;
for (const axis of ["x", "y", "z", "a", "b", "c", "u", "v", "w"]) assert.ok(Math.abs(Number(pose[axis]) - Number(tpPose[axis])) <= 1e-9, `${axis}: ${pose[axis]} != ${tpPose[axis]}`);
result.payload.wasm_motion.state.joint_commanded.forEach((value, i) => assert.ok(Math.abs(value - physicalJoints[i]) < 1e-8, `switch must preserve physical joint ${i}`));
directSwitches.push({ active_type: expected, state_hash: result.payload.kinematics.state_hash });
}

View File

@@ -72,11 +72,14 @@ export class WasmLinuxcncTp {
if (blending !== 0) throw new Error(`linuxcnc_tp_blend_${blending}`);
}
setPosition(value) {
setPosition(value, kinematicsMode = this.activeTag) {
if (!this.available) return this.read();
this.core.HEAPF64.set(poseArray(value), this.posePointer / 8);
const result = call(this.core, "sim_linuxcnc_tp_set_position", "number", ["number", "number"], [this.planner, this.posePointer]);
if (result !== 0) throw new Error(`linuxcnc_tp_set_position_${result}`);
// Publish the pose and its coordinate interpretation together. Reading
// between these updates would inverse-solve an old pose in a new mode.
this.activeTag = Number(kinematicsMode);
return this.read();
}
@@ -112,7 +115,20 @@ export class WasmLinuxcncTp {
setAxisLimit(axis, minimum, maximum) { if (!this.available) return this.read(); const result = call(this.core, "sim_linuxcnc_tp_set_axis_limit", "number", ["number", "number", "number", "number"], [this.planner, Number(axis), Number(minimum), Number(maximum)]); if (result !== 0) throw new Error(`linuxcnc_tp_axis_limit_${result}`); return this.read(); }
jog(axis, direction, increment, tag = 0) {
if (!this.available) return { status: "blocked", code: "linuxcnc_tp_unavailable" };
const result = call(this.core, "sim_linuxcnc_tp_jog", "number", ["number", "number", "number", "number", "number"], [this.planner, Number(axis), Number(direction), Number(increment), Number(tag)]);
const coordinate = "xyzabcuvw"[axis];
// Identity and rotary coordinates map directly to physical joints. Apply
// both INI constraints to the native jog target without changing normal
// coordinate limits (RTCP XYZ has a different physical interpretation).
const jointLimit = this.kinematics?.options?.limits?.[coordinate];
const directJoint = this.activeTag !== 1 || axis >= 3;
let savedLimits = null;
if (directJoint && jointLimit) {
savedLimits = [0, 1].map((maximum) => call(this.core, "sim_linuxcnc_tp_axis_limit", "number", ["number", "number", "number"], [this.planner, axis, maximum]));
this.setAxisLimit(axis, Math.max(savedLimits[0], jointLimit[0]), Math.min(savedLimits[1], jointLimit[1]));
}
let result;
try { result = call(this.core, "sim_linuxcnc_tp_jog", "number", ["number", "number", "number", "number", "number"], [this.planner, Number(axis), Number(direction), Number(increment), Number(tag)]); }
finally { if (savedLimits) this.setAxisLimit(axis, ...savedLimits); }
if (result !== 0) return { status: "error", code: `linuxcnc_tp_jog_${result}` };
// The native jog tag is a segment ID, not a kinematics mode. Replace any
// earlier program-line mapping with this jog's current coordinate frame.
@@ -187,9 +203,27 @@ export class WasmLinuxcncTp {
step() {
if (!this.available) return { status: "blocked", code: "linuxcnc_tp_unavailable" };
if (this.controls.paused) return { status: "error", code: "linuxcnc_tp_paused" };
const before = this.read().state;
const result = call(this.core, "sim_linuxcnc_tp_step", "number", ["number"], [this.planner]);
if (result !== 0) return { status: "error", code: `linuxcnc_tp_step_${result}` };
const state = this.read();
let state;
try {
state = this.read();
if (this.kinematics && state.status === "verified") {
const checked = this.kinematics.forward(state.state.joint_commanded);
if (checked.status !== "verified") throw new Error(checked.code);
}
} catch (error) {
// Native IK/FK rejects a physical joint limit. Keep the last valid
// servo position, discard the unsafe remainder, and permit retreat.
if (!String(error?.message).endsWith("_-702")) throw error;
call(this.core, "sim_linuxcnc_tp_abort", "number", ["number"], [this.planner]);
this.segmentTags.clear(); this.lastActiveId = 0; this.queueDepthOverride = null;
this.executionState = 3;
const stopped = this.setPosition(before.position, before.active_tag);
this.kinematics.forward(stopped.state.joint_commanded);
throw new Error("motion_joint_limit");
}
if (state.status === "verified") {
const targetJoints = this.commandedJoints(state.state.position, state.state.active_tag);
state.state.joint_commanded = targetJoints;