Preserve RTCP compensation across rotary jog stops

This commit is contained in:
2026-09-18 06:10:53 +08:00
parent e4600b34e3
commit 6a69511276
14 changed files with 4530 additions and 7 deletions

View File

@@ -136,7 +136,7 @@ export function XyzacMachineView({ visual, toolpath, view = "p", zoom = 1, rotat
// workpiece path together with the physical table.
const pathGroup = new THREE.Group(); pathGroup.name = "xyzac-toolpath"; assembly.joints.rotate.add(pathGroup);
const liveGroup = new THREE.Group(); liveGroup.name = "xyzac-live-toolpath"; assembly.joints.rotate.add(liveGroup);
const pathMaterials = [0xdce7e8, 0xff9a55, 0x5c9d9a].map((color) => new THREE.LineBasicMaterial({ color }));
const pathMaterials = [0xffff00, 0xff9a55, 0x5c9d9a].map((color) => new THREE.LineBasicMaterial({ color }));
const clearLines = (group: THREE.Group) => { group.children.forEach((child) => (child as THREE.Line).geometry.dispose()); group.clear(); };
const limits = new THREE.Box3Helper(new THREE.Box3(), 0xdd7566); scene.add(limits);
const offsets = new THREE.AxesHelper(.8); scene.add(offsets);

View File

@@ -0,0 +1,123 @@
import assert from "node:assert/strict";
import { readFile, mkdir, writeFile } from "node:fs/promises";
import * as THREE from "three";
import createModule from "../../../build-wasm/linuxcnc-sim-core.js";
import { loadSimulationCase, toWorkerConfiguration } from "../../workspace/sim-case-loader.mjs";
import { writeTrtGeometry } from "../../workspace/trt-geometry.mjs";
import { MACHINE_DEFINITIONS } from "../../workspace/machine-definitions.mjs";
import { SimSession, CONTEXT_ID } from "../../worker/sim-session.mjs";
import { fnv1a64 } from "../../worker/replay.mjs";
import { buildXyzacMachine, buildXyzbcMachine } from "./xyzac-machine-model.ts";
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));
const close = (actual, expected, message) => assert.ok(Math.abs(actual - expected) < 1e-7, `${message}: ${actual} != ${expected}`);
const rows = [];
for (const machine of MACHINE_DEFINITIONS) {
const raw = await loadSimulationCase({ path: machine.entrypoint, fetchImpl });
const bundle = writeTrtGeometry(raw, { x_rot_point: 9, y_rot_point: -7, z_rot_point: 13 });
const core = await createModule({ wasmBinary });
const session = new SimSession(core, { sessionId: `rtcp-jog-${machine.id}` }); let seq = 0;
const send = (kind, payload = {}, expectedKind = null) => {
const result = session.accept({ schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId, kind, seq: ++seq, operation_id: `jog-${seq}`, payload });
if (expectedKind) assert.equal(result.kind, expectedKind);
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(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.equal(session.wasmHomeLimit.read().state.homed, true);
const drain = () => {
for (let i = 0; i < 5000 && session.wasmMotion.read().state.queue_depth > 0; i++) send("SIM_STEP", { cycles: 1 });
assert.equal(session.wasmMotion.read().state.queue_depth, 0);
};
send("SET_MODE", { mode: "MDI" });
send("MDI_EXECUTE", { block: "G21 G90 G1 X12 Y8 Z6 F600" }); drain();
send("MDI_EXECUTE", { block: "M428" });
send("SET_MODE", { mode: "MANUAL" });
const model = machine.coordinates === "XYZAC" ? buildXyzacMachine() : buildXyzbcMachine();
const start = session.wasmMotion.read().state;
const target = { ...start.position };
const samples = [];
const check = (phase, snapshot = null) => {
const state = session.wasmMotion.read().state;
assert.equal(session.xyzacKinematics.activeMode, 1, phase);
assert.equal(state.active_tag, 1, `${phase}: RTCP motion mode must remain active`);
for (const axis of ["x", "y", "z"]) close(state.position[axis], target[axis], `${phase}: programmed TCP ${axis}`);
const joints = state.joint_commanded;
const offsets = session.xyzacKinematics.snapshot().offsets;
model.update({ joints, offsets }); model.machine.updateMatrixWorld(true);
const point = model.workOrigin.localToWorld(new THREE.Vector3(target.x * .01, target.z * .01, target.y * .01));
const tip = model.toolTip.getWorldPosition(new THREE.Vector3());
const error = tip.distanceTo(point);
assert.ok(error < 1e-7, `${phase}: machine model TCP error ${error}`);
if (snapshot?.visual?.joints) snapshot.visual.joints.forEach((v, i) => close(v, joints[i], `${phase}: visual joint ${i}`));
if (snapshot?.visual?.motion_trace) for (const sample of snapshot.visual.motion_trace) {
model.update({ joints: sample.joints, offsets }); model.machine.updateMatrixWorld(true);
const samplePoint = model.workOrigin.localToWorld(new THREE.Vector3(target.x * .01, target.z * .01, target.y * .01));
close(model.toolTip.getWorldPosition(new THREE.Vector3()).distanceTo(samplePoint), 0, `${phase}: live motion trace TCP`);
}
samples.push({ phase, tick: session.tick, pose: state.position, joints, error });
return state;
};
check("M428");
for (const axis of [machine.axes[3], "c", machine.axes[3], "c"]) {
const before = session.wasmMotion.read().state;
send("JOG_START", { axis, direction: 1, continuous: true, increment: null });
for (let i = 0; i < 15; i++) {
const result = send("SIM_STEP", { cycles: 1 });
check(`${axis}-continuous-${i}`, result.payload.panel_snapshot);
}
const moving = session.wasmMotion.read().state;
assert.ok(moving.position[axis] > before.position[axis]);
assert.ok(moving.joint_commanded.slice(0, 3).some((v, i) => Math.abs(v - before.joint_commanded[i]) > 1e-5), `${axis}: XYZ compensation missing`);
const stopped = send("JOG_STOP");
const after = check(`${axis}-released`, stopped.payload.panel_snapshot);
after.joint_commanded.forEach((v, i) => close(v, moving.joint_commanded[i], `${axis}: no release jump`));
send("SIM_STEP", { cycles: 1 }); check(`${axis}-idle`);
const incrementalStart = session.wasmMotion.read().state.position[axis];
send("JOG_START", { axis, direction: -1, increment: 1 }); drain();
close(check(`${axis}-increment`).position[axis], incrementalStart - 1, `${axis}: incremental endpoint`);
}
const beforeInvalid = session.wasmMotion.read().state;
send("JOG_START", { axis: machine.axes[3], direction: 1, increment: 100000 }, "ERROR");
send("JOG_START", { axis: "c", direction: 1, increment: "invalid" }, "ERROR");
assert.deepEqual(check("invalid-jog-retains-rtcp").joint_commanded, beforeInvalid.joint_commanded);
send("JOG_STOP"); check("repeated-stop");
const beforeAbort = session.wasmMotion.read().state.joint_commanded;
send("PROGRAM_ABORT");
assert.deepEqual(check("abort-retains-rtcp").joint_commanded, beforeAbort);
const poweredOff = send("POWER", { active: false });
assert.deepEqual(check("power-off-retains-rtcp", poweredOff.payload.panel_snapshot).joint_commanded, beforeAbort);
send("POWER", { active: true });
send("KINEMATICS_SELECT", { type: 0 });
const identityBefore = session.wasmMotion.read().state.joint_commanded;
send("JOG_START", { axis: machine.axes[3], direction: 1, increment: 2 }); drain();
const identityAfter = session.wasmMotion.read().state.joint_commanded;
identityAfter.slice(0, 3).forEach((v, i) => close(v, identityBefore[i], "M429 XYZ uncompensated"));
send("JOG_STOP"); assert.equal(session.wasmMotion.read().state.active_tag, 0);
// A completed program line and a later jog may share the native TP ID.
const reusedId = 400000;
const collisionStart = session.wasmMotion.read().state.position;
assert.equal(session.wasmMotion.enqueue({ end: { ...collisionStart, x: collisionStart.x + .1 }, source_line: reusedId, kinematics_mode: 0, motion_type: 1, velocity: 10 }).status, "verified");
drain(); send("KINEMATICS_SELECT", { type: 1 });
assert.equal(session.wasmMotion.jog(5, 1, 2, reusedId).status, "verified");
for (let i = 0; i < 5000 && session.wasmMotion.read().state.queue_depth > 0; i++) {
send("SIM_STEP", { cycles: 1 });
assert.equal(session.wasmMotion.read().state.active_tag, 1, "jog must replace the completed line's mode mapping");
}
assert.equal(session.wasmMotion.read().state.queue_depth, 0);
send("JOG_STOP"); assert.equal(session.wasmMotion.read().state.active_tag, 1);
model.dispose();
rows.push({ machine: machine.id, samples, identity_before: identityBefore, identity_after: identityAfter, reused_segment_id: "verified" });
}
const report = { report_id: "trt-rtcp-jog-v1", status: "verified", rows };
const dir = new URL("coverage/trt-parametric/", root); await mkdir(dir, { recursive: true });
await writeFile(new URL("rtcp-jog.report.json", dir), JSON.stringify({ ...report, state_hash: fnv1a64(report) }, null, 2) + "\n");
console.log(JSON.stringify({ report_id: report.report_id, status: report.status, rows: rows.map((r) => ({ machine: r.machine, samples: r.samples.length, max_tcp_error: Math.max(...r.samples.map((s) => s.error)) })) }));

View File

@@ -31,9 +31,11 @@ assert.equal(blocked.payload.code, "axis_gate");
assert.equal(blocked.payload.reason, "xyzac_machine_off");
assert.equal(blocked.payload.message_key, "xyzac_machine_off");
first.send("POWER", { active: true });
first.send("HOME", { all: true });
first.send("PROGRAM_LOAD", { source: PROGRAM, path: "followups.ngc" });
first.send("PROGRAM_RUN");
assert.equal(first.send("HOME", { all: true }).kind, "COMMAND_RESULT");
for (let tick = 0; tick < 500 && !first.session.wasmHomeLimit.read().state.homed; tick++) first.send("SIM_STEP", { cycles: 1 });
assert.equal(first.session.wasmHomeLimit.read().state.homed, true);
assert.equal(first.send("PROGRAM_LOAD", { source: PROGRAM, path: "followups.ngc" }).kind, "COMMAND_RESULT");
assert.equal(first.send("PROGRAM_RUN").kind, "COMMAND_RESULT");
const compact = first.send("SIM_STEP", { cycles: 1, response: "motion_delta" });
assert.equal(compact.payload.snapshot_mode, "motion_delta");
assert.equal(compact.payload.panel_snapshot, undefined);

View File

@@ -95,7 +95,15 @@ export class WasmLinuxcncTp {
setVelocityScale(value) { if (!this.available) return this.read(); const result = call(this.core, "sim_linuxcnc_tp_set_velocity_scale", "number", ["number", "number"], [this.planner, Number(value)]); if (result !== 0) throw new Error(`linuxcnc_tp_velocity_scale_${result}`); return this.read(); }
setJogScale(value) { if (!this.available) return this.read(); const result = call(this.core, "sim_linuxcnc_tp_set_jog_scale", "number", ["number", "number"], [this.planner, Number(value)]); if (result !== 0) throw new Error(`linuxcnc_tp_jog_scale_${result}`); return this.read(); }
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)]); return result === 0 ? { status: "verified", command: "JOG_START" } : { status: "error", code: `linuxcnc_tp_jog_${result}` }; }
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)]);
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.
if (Number(tag) > 0) this.segmentTags.set(Number(tag), this.activeTag);
return { status: "verified", command: "JOG_START" };
}
jogVelocity(axis) { if (!this.available) return 0; const index = { x: 0, y: 1, z: 2, a: 3, b: 4, c: 5, u: 6, v: 7, w: 8 }[String(axis).toLowerCase()]; return index === undefined ? 0 : Number(call(this.core, "sim_linuxcnc_tp_jog_velocity", "number", ["number", "number"], [this.planner, index])); }
setPaused(value) { this.controls.paused = Boolean(value); if (this.available) call(this.core, "sim_linuxcnc_tp_set_paused", "number", ["number", "number"], [this.planner, this.controls.paused ? 1 : 0]); return this.read(); }
stepLine() {
@@ -214,7 +222,13 @@ export class WasmLinuxcncTp {
}
reset() { if (this.available) call(this.core, "sim_linuxcnc_tp_reset", "number", ["number"], [this.planner]); this.controls.paused = false; this.activeTag = 0; this.segmentTags.clear(); this.lastActiveId = 0; this.queueDepthOverride = null; this.executionState = 3; return this.read(); }
abort() { if (this.available) call(this.core, "sim_linuxcnc_tp_abort", "number", ["number"], [this.planner]); this.activeTag = 0; this.segmentTags.clear(); this.lastActiveId = 0; this.queueDepthOverride = null; this.executionState = 3; return this.read(); }
abort() {
if (this.available) call(this.core, "sim_linuxcnc_tp_abort", "number", ["number"], [this.planner]);
// Stopping motion is not M429: keep the coordinate frame of the stopped
// pose, so its WASM inverse and the next jog retain RTCP compensation.
this.segmentTags.clear(); this.lastActiveId = 0; this.queueDepthOverride = null; this.executionState = 3;
return this.read();
}
exportState() {
const current = this.read();
const segments = [];