按关节槽位驱动 13 个五轴案例的三维模型

关节契约扩为槽位制:以 LinuxCNC 联合号标识关节,轴字母降为槽位属性,
重复轴(bridge-mill 的两个 Y)、并联杆长(hexapod/pentapod)和被动槽位
(max5 第 6 关节)都能表达。v1 规则保持不变并合成 jointOrder,已保存
GLB 与既有测试不受影响。

13 个结构 GLB 由 scripts/generate_physical_case_glbs.mjs 统一重建,
可动关节均为按槽位命名的 Group 并写 userData.linuxcncJoint,共 71 个
关节,槽位数与运行目录的 joint_count 逐条一致。

新增 machine-visual-bindings.json 与纯函数模块,以 machine id 为键:
坐标字母不能作键,因为三个案例共用 XYZAB、三个共用 XYZABC。主界面切换
案例时自动装载对应模型,失败时保留内置模型并显式提示。

消除前端五关节截断:visual 适配器的 joints 改为数组并新增
jointsAuthoritative;allHomed 按 joint_count 判断;面板按 worker 断言的
machine id 查找机型。

管理器解锁:坐标与案例下拉以"存在完整视觉绑定"为启用条件,13 案例全部
可选;machine-urdf.ts 新增 generic-wasm/1 profile,AC/BC 严格校验路径
不变;新增 machine-kinematics-specs.json 保存逐关节限位,INI 未声明的
限位保持 null 而不编造。

修复三个真实缺陷:构建漏发 woodpecker/gmoccapy/qtdragon 配置目录(
DEFECT-02 根因);案例切换沿用"装载需要已上电"的门控,导致关机状态下
第二次切换被静默拒绝;重复轴机型上 React 列表键冲突。

验收:13 案例逐个切换装载成功、槽位键与绑定定义逐一一致、浏览器错误 0;
typecheck 与生产构建通过;视觉绑定、GLB 命名、关节契约、投影、store、
双画布、运动学规格漂移、11 案例 Worker 闭环、管理器浏览器回归全部通过。
证据在 coverage/glb-machine-manager/physical-drive/。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-30 06:09:12 +08:00
parent af65b69b2c
commit db29ae625f
157 changed files with 11936 additions and 885 deletions

View File

@@ -361,6 +361,19 @@ export class ProgramRunner {
return this.snapshot();
}
synchronizePosition(position) {
if (!this.loaded || !position || ["x", "y", "z", "a", "b", "c", "u", "v", "w"].some((axis) => !Number.isFinite(Number(position[axis] ?? 0)))) return { status: "error", code: "program_position_invalid" };
const pointer = this.core._malloc(72);
if (!pointer) return { status: "error", code: "program_position_allocation_failed" };
try {
this.core.HEAPF64.set(["x", "y", "z", "a", "b", "c", "u", "v", "w"].map((axis) => Number(position[axis] ?? 0)), pointer / 8);
const result = call(this.core, "sim_interpreter_sync_position", "number", ["number", "number"], [this.interpreter, pointer]);
if (result !== 0) return { status: "error", code: `program_position_sync_${result}` };
this.state = { ...this.state, ...position };
return { status: "verified", state: { ...this.state } };
} finally { this.core._free(pointer); }
}
preview() {
if (!this.loaded) return { status: "error", code: "program_not_loaded" };
const source = this.source;

View File

@@ -29,6 +29,7 @@ import { WasmLinuxcncTp } from "./wasm-linuxcnc-tp.mjs";
import { WasmHomeLimit } from "./wasm-home-limit.mjs";
import { WasmNativeHoming } from "./wasm-native-homing.mjs";
import { WasmXyzacKinematics, XYZAC_MODES } from "./wasm-xyzac-kinematics.mjs";
import { WasmGenericKinematics } from "./wasm-generic-kinematics.mjs";
import { WasmXyzacIo } from "./wasm-xyzac-io.mjs";
import { WasmXyzacDeviceIo } from "./wasm-xyzac-device-io.mjs";
import { WasmProgramRuntime } from "./wasm-program-runtime.mjs";
@@ -140,6 +141,8 @@ export class SimSession {
this.wasmMotion = planner === "linuxcnc-tp" ? new WasmLinuxcncTp(core) : new WasmMotionQueue(core);
this.wasmHomeLimit = new WasmHomeLimit(core);
this.xyzacKinematics = null;
this.genericKinematics = null;
this.genericMotionEnabled = false;
this.xyzacIo = null;
this.xyzacDeviceIo = null;
this.xyzacProgram = null;
@@ -338,7 +341,25 @@ 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 }, queue_depth: wasm.state.queue_depth } : this.motion.export();
return wasm.status === "verified" ? { ...this.motion.export(), axes: { ...wasm.state.position }, joints: [...(wasm.state.joint_commanded ?? this.genericKinematics?.snapshot().joints ?? [])], queue_depth: wasm.state.queue_depth } : this.motion.export();
}
genericWasmProgramLoad(input) {
const source = input.payload?.source ?? input.payload?.text ?? input.payload?.program_source;
const loaded = this.program.load(source, input.payload?.path ?? null);
if (loaded.status !== "verified") return loaded;
this.wasmMotion.reset(); this.wasmMotion.setPaused(false); this.wasmMotion.setKinematics(this.genericKinematics);
const home = this.genericKinematics.home();
if (home.status !== "verified") return home;
const aligned = this.wasmMotion.setPosition(home.pose);
if (aligned.status !== "verified") return aligned;
const synchronized = this.program.synchronizePosition(home.pose);
if (synchronized.status !== "verified") return synchronized;
this.task.setProgram("loaded", 0);
const preview = this.program.preview();
loaded.preview = preview.preview ?? { status: "error", code: preview.code ?? "program_preview_failed" };
this.previewState = loaded.preview;
return loaded;
}
wasmDeviceProjection({ includeBytes = false } = {}) {
@@ -469,12 +490,10 @@ export class SimSession {
// Generic WASM-compatible cases expose their historical XYZ panel shape;
// the selected XYZAC case is the only panel that publishes all nine pose
// coordinates and the five inverse joints.
const position = this.xyzacProgram
? wasmPosition
: { x: wasmPosition.x, y: wasmPosition.y, z: wasmPosition.z };
const inverseJoints = this.xyzacKinematics?.snapshot?.()?.joints;
const position = this.xyzacProgram || this.genericKinematics ? wasmPosition : { x: wasmPosition.x, y: wasmPosition.y, z: wasmPosition.z };
const inverseJoints = this.xyzacKinematics?.snapshot?.()?.joints ?? this.genericKinematics?.snapshot?.()?.joints;
const joints = this.motionAuthority === "wasm" && wasmMotion.status === "verified"
? (Array.isArray(wasmMotion.state.joint_commanded) && wasmMotion.state.joint_commanded.length === 5 ? [...wasmMotion.state.joint_commanded] : inverseJoints?.length === 5 ? [...inverseJoints] : [position.x, position.y, position.z, position[this.machineDefinition?.coordinates === "XYZBC" ? "b" : "a"] ?? 0, position.c ?? 0])
? (Array.isArray(wasmMotion.state.joint_commanded) && wasmMotion.state.joint_commanded.length === this.machineDefinition?.joint_count ? [...wasmMotion.state.joint_commanded] : inverseJoints?.length === this.machineDefinition?.joint_count ? [...inverseJoints] : [position.x, position.y, position.z, position[this.machineDefinition?.coordinates === "XYZBC" ? "b" : "a"] ?? 0, position.c ?? 0])
: authoritative.kinematics.joints;
const preview = this.previewState?.status === "verified" ? this.previewState : null;
// Keep the Vismach model identity in the same authoritative visual
@@ -497,7 +516,7 @@ export class SimSession {
rotary_axis: "Z",
joints: machineDefinition?.coordinates ? ["table-x", "saddle-y", "spindle-z", `tilt-${machineDefinition.coordinates[3].toLowerCase()}`, "rotate-c"] : ["table-x", "saddle-y", "spindle-z", "tilt-a", "rotate-c"],
components: ["base", "column", "head", "spindle", "tool", "table", "saddle", "zslide"],
} : null;
} : this.genericKinematics ? { schema: "linuxcnc-generic-kinematics-model.v1", id: this.machineDefinition?.id ?? this.genericKinematics.type, source: "linuxcnc-wasm-worker", source_path: this.machineDefinition?.source_path ?? "", source_hash: this.machineDefinition?.source_path && this.workspaceFiles?.[this.machineDefinition.source_path] ? fnv1a64(this.workspaceFiles[this.machineDefinition.source_path]) : null, coordinate_system: this.genericKinematics.coordinates, kinematics: this.genericKinematics.type, joint_count: this.genericKinematics.jointCount, components: ["kinematics-driven-joints"] } : null;
const visual = {
schema: "axis-visual.v1",
pose: { ...position },
@@ -1704,7 +1723,8 @@ export class SimSession {
if (!text(rootPath) || !rootPath.startsWith("/") || rootPath.includes("..")) return finish(this.error(input, "workspace_path", "invalid_workspace_path"), "failed");
let startupLoaded = false;
let configuration = input.payload?.configuration ?? null;
if (machineDefinition(configuration?.entrypoint) || machineDefinition(configuration?.kinematics?.type)) {
const requestedMachine = machineDefinition(configuration?.entrypoint) ?? machineDefinition(configuration?.kinematics?.type);
if (requestedMachine?.runtime === "trt") {
try {
configuration = { ...configuration, ...readTrtConfiguration(configuration) };
} catch (error) {
@@ -1762,7 +1782,9 @@ export class SimSession {
}
const configuredMachine = this.machineDefinition ?? machineDefinition(configuration?.kinematics?.type);
const isTrt = configuredMachine?.runtime === "trt" || configuration?.kinematics?.type === "xyzac-trt" || configuration?.kinematics?.type === "xyzbc-trt";
this.motionAuthority = configuration?.entrypoint?.includes("3axis-tutorial") || isTrt ? "wasm" : "js";
const isGenericWasm = configuredMachine?.runtime === "generic-wasm" || configuration?.kinematics?.runtime === "generic-wasm";
this.motionAuthority = configuration?.entrypoint?.includes("3axis-tutorial") || isTrt || isGenericWasm ? "wasm" : "js";
this.genericKinematics?.destroy(); this.genericKinematics = null;
if (isTrt) {
this.xyzacKinematics?.destroy();
this.xyzacKinematics = new WasmXyzacKinematics(this.core, { ...configuration.kinematics.offsets, coordinates: configuredMachine?.coordinates ?? configuration.kinematics.coordinates ?? "XYZAC", limits: configuration.home_limits });
@@ -1796,6 +1818,24 @@ export class SimSession {
this.xyzacAxisAccelerationLimits = configuration.kinematics.axis_acceleration_limits;
this.xyzacProgram.setRapidVelocity(this.xyzacRapidVelocity);
}
if (isGenericWasm) {
this.xyzacProgram?.destroy?.(); this.xyzacProgram = null;
this.xyzacKinematics?.destroy(); this.xyzacKinematics = null;
this.wasmMotion.destroy?.();
this.xyzacMotionCycleTime = Number(configuration.kinematics.servo_cycle_time ?? 0.01);
this.xyzacTaskCycleTicks = 1;
this.wasmMotion = new WasmMotionQueue(this.core, { cycleTime: this.xyzacMotionCycleTime, velocityLimit: Number(configuration.kinematics.max_linear_velocity ?? 1000) });
this.genericKinematics = new WasmGenericKinematics(this.core, {
type: configuration.kinematics.type, coordinates: configuration.kinematics.coordinates,
jointCount: configuration.kinematics.joint_count, kinematicsJointCount: configuration.kinematics.kinematics_joint_count, extraJointCount: configuration.kinematics.extra_joint_count, limits: configuration.home_limits ?? {},
linearUnits: configuration.kinematics.linear_units ?? "mm", angularUnits: configuration.kinematics.angular_units ?? "deg",
parameters: configuration.kinematics.parameters ?? {},
});
this.wasmMotion.setKinematics(this.genericKinematics);
const home = this.genericKinematics.snapshot().pose;
const aligned = this.wasmMotion.setPosition(home);
if (aligned.status !== "verified") throw new Error(aligned.code ?? "generic_wasm_home_position_failed");
}
if (configuration?.home_limits && this.motionAuthority === "wasm") {
this.wasmHomeLimit.destroy();
this.wasmHomeLimit = this.xyzacProgram ? new WasmNativeHoming(this.core, configuration, this.xyzacKinematics) : new WasmHomeLimit(this.core, {
@@ -1825,7 +1865,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)) {
if (configuredKinematics && !(this.motionAuthority === "wasm" && (isTrt || isGenericWasm))) {
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");
}
@@ -2044,6 +2084,24 @@ export class SimSession {
}
return;
}
if (this.genericKinematics) {
if (this.program.status !== "running" || this.wasmMotion.read().state.queue_depth !== 0) return;
const steps = this.program.tick(1);
program_steps.push(...steps);
for (const step of steps) {
if (!step.motion) continue;
step.motion.source_line = Number(step.program?.execution_source_line ?? step.line ?? step.motion.source_line ?? 0);
step.motion.source_file = step.program?.execution_source_file ?? this.program.path;
const linearNoop = step.motion.motion_type === 1 && ["x", "y", "z", "a", "b", "c", "u", "v", "w"].every((axis) => Math.abs(Number(step.motion.start[axis] ?? 0) - Number(step.motion.end[axis] ?? 0)) < 1e-9);
if (!linearNoop) {
step.motion.start = { ...this.wasmMotion.read().state.position };
const queued = this.wasmMotion.enqueue(step.motion);
if (queued.status !== "verified") throw new Error(queued.code ?? "generic_wasm_motion_enqueue_failed");
}
break;
}
return;
}
const steps = this.program.tick(1);
program_steps.push(...steps);
for (const step of steps) {
@@ -2177,6 +2235,9 @@ export class SimSession {
active_progress: traceState.progress,
});
}
} else if (this.genericKinematics && wasmMotion.status === "verified") {
const position = this.motion.applyWasmPosition(wasmMotion.state.position, wasmMotion.state.velocity, nextTick);
if (position.status !== "verified") throw new Error(position.code);
} else if (this.program.loaded) this.authoritative.applyMotion(this.motion);
if (this.motionAuthority === "wasm" && this.wasmHomeLimit.read().state.homed) {
const homeCheck = this.wasmHomeLimit.check(this.xyzacProgram ? this.wasmMotionProjection().joints : this.motion.state.joints);
@@ -2233,7 +2294,7 @@ export class SimSession {
if (program) {
const status = this.xyzacProgram ? (program.status === 4 ? "completed" : program.status === 2 ? "running" : program.status === 3 ? "paused" : "loaded") : (program.program_status === "completed" ? "completed" : program.program_status === "running" ? "running" : program.program_status);
this.task.setProgram(status, this.xyzacProgram ? (program.execution_source_line ?? program.current_line) : program.line);
if (status === "completed" && this.task.state.state === "RUNNING" && (this.motionAuthority !== "wasm" || this.wasmMotion.read().state.queue_depth === 0)) this.task.transition("STOPPED");
if ((status === "completed" || this.program.snapshot().program_status === "completed") && this.task.state.state === "RUNNING" && (this.motionAuthority !== "wasm" || this.wasmMotion.read().state.queue_depth === 0)) { this.task.transition("STOPPED"); if (this.genericKinematics) this.task.setProgram("completed", this.program.currentLine); }
if (this.xyzacProgram && this.task.state.mode === "MDI" && this.task.state.state === "RUNNING" && this.wasmMotion.read().state.queue_depth === 0) this.task.transition("STOPPED");
}
const snapshotState = { tick: this.tick, cycles, motion_authority: this.motionAuthority, task: this.task.export(), scheduler: this.scheduler.export(), authoritative: this.xyzacProgram ? { schema: "wasm_authority_boundary.v1", source: "linuxcnc-wasm-worker" } : this.authoritative.export(), wasm_motion: this.wasmMotion.read(), motion: this.motionProjection(), plc: this.plc.export(), hal_runtime: this.xyzacIo ? this.xyzacIo.halExport() : 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(), callbacks: this.callbacks.export(), xyzac_timing: { servo_cycle_time: this.xyzacMotionCycleTime, task_cycle_ticks: this.xyzacTaskCycleTicks } };
@@ -2314,13 +2375,17 @@ export class SimSession {
return finish(this.envelope(finalStatus === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, preview, program: this.xyzacProgram.snapshot(), task: this.task.export(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null,
}), finalStatus);
}
if (this.genericKinematics) {
const result = this.genericWasmProgramLoad(input);
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, task: this.task.export(), motion_authority: "wasm", wasm_motion: this.wasmMotion.read(), motion: this.motionProjection(), command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
}
restoreMode();
const result = this.program.load(source, input.payload?.path ?? null);
const result = this.genericKinematics ? this.genericWasmProgramLoad(input) : this.program.load(source, input.payload?.path ?? null);
if (result.status === "verified") {
this.authoritative.reset();
this.wasmMotion.reset();
this.wasmMotion.setPaused(false);
this.motion.applyWasmPosition(this.wasmMotion.read().state.position, 0, this.tick);
if (!this.genericKinematics) {
this.authoritative.reset(); this.wasmMotion.reset(); this.wasmMotion.setPaused(false);
this.motion.applyWasmPosition(this.wasmMotion.read().state.position, 0, this.tick);
}
this.task.setProgram("loaded", 0);
const preview = this.program.preview();
result.preview = preview.preview ?? { status: "error", code: preview.code ?? "program_preview_failed" };
@@ -2344,6 +2409,16 @@ export class SimSession {
if (result.status === "verified") { this.wasmMotion.setPaused(false); this.task.setMode("AUTO"); this.wasmHomeLimit.setMode("COORD"); if (this.task.state.state === "STOPPED") this.task.recover(); this.task.transition("RUNNING"); const program = this.xyzacProgram.snapshot(); this.task.setProgram("running", program.execution_source_line ?? program.current_line); }
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, reopened_taskfile: ["completed", "aborted"].includes(previousProgramStatus), start_line: 0, program: this.xyzacProgram.snapshot(), task: this.task.export(), wasm_motion: this.wasmMotion.read(), motion: this.wasmMotionProjection(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
}
if (this.genericKinematics) {
const previousProgramStatus = this.program.status;
const result = this.program.start();
if (result.status === "verified") {
this.wasmMotion.setPaused(false); this.task.setMode("AUTO");
if (["STOPPED", "READY"].includes(this.task.state.state)) { if (this.task.state.state === "STOPPED") this.task.recover(); this.task.transition("RUNNING"); }
this.task.setProgram("running", this.program.currentLine);
}
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, reopened_taskfile: ["completed", "aborted"].includes(previousProgramStatus), task: this.task.export(), wasm_motion: this.wasmMotion.read(), motion: this.motionProjection(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
}
const result = this.program.start();
if (result.status === "verified") {
this.task.setMode("AUTO");
@@ -2405,14 +2480,16 @@ export class SimSession {
if (result.status === "verified") {
if (action === "pause") {
if (this.motionAuthority === "wasm") this.wasmMotion.setPaused(true);
this.task.transition("PAUSED");
if (this.task.state.state === "RUNNING") this.task.transition("PAUSED");
if (this.genericKinematics) this.task.setProgram("paused", this.program.currentLine);
} else if (action === "resume") {
if (this.motionAuthority === "wasm") this.wasmMotion.setPaused(false);
this.task.transition("RUNNING");
if (this.task.state.state === "PAUSED") this.task.transition("RUNNING");
if (this.genericKinematics) this.task.setProgram("running", this.program.currentLine);
} else {
if (this.motionAuthority === "wasm") this.wasmMotion.abort();
else this.authoritative.abort();
this.task.transition("STOPPED");
if (["RUNNING", "PAUSED"].includes(this.task.state.state)) this.task.transition("STOPPED");
this.task.setProgram("aborted", this.program.currentLine);
}
}

View File

@@ -0,0 +1,46 @@
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 { MACHINE_DEFINITIONS } from "../workspace/machine-definitions.mjs";
import { CONTEXT_ID, SimSession } from "./sim-session.mjs";
const root = "/home/mes006/linuxcncWebWASM";
const fetchImpl = async (url) => {
const relative = String(url).replace(/^\.?\//, "");
for (const path of [`${root}/${relative}`, `${root}/web/react/public/${relative}`]) {
try { const body = await readFile(path); return { ok: true, text: async () => body.toString("utf8") }; } catch { /* try next root */ }
}
return { ok: false, text: async () => "" };
};
const wasmBinary = await readFile(`${root}/build-wasm/linuxcnc-sim-core.wasm`);
const rows = [];
for (const machine of MACHINE_DEFINITIONS.filter((entry) => entry.runtime === "generic-wasm")) {
const core = await createModule({ wasmBinary });
const session = new SimSession(core, { sessionId: machine.id, planner: "queue" });
let seq = 0;
const send = (kind, payload = {}) => session.accept({ schema_version: 1, context_id: CONTEXT_ID, session_id: machine.id, kind, seq: ++seq, operation_id: `${machine.id}-${seq}`, payload });
send("HELLO"); send("SESSION_ATTACH");
const bundle = await loadSimulationCase({ path: machine.entrypoint, fetchImpl });
assert.equal(bundle.status, "ready", machine.id);
const configuration = toWorkerConfiguration(bundle);
const workspace = send("LOAD_WORKSPACE", { root_path: "/workspace", configuration });
assert.equal(workspace.kind, "COMMAND_RESULT", machine.id);
const loaded = send("PROGRAM_LOAD", { source: bundle.files[machine.program], path: machine.program });
assert.equal(loaded.kind, "COMMAND_RESULT", `${machine.id}: ${loaded.payload?.code ?? "load"}`);
assert.equal(send("PROGRAM_RUN").kind, "COMMAND_RESULT", machine.id);
let last = null;
for (let tick = 0; tick < 12000; tick += 1) {
last = send("SIM_STEP", { cycles: 1 });
assert.notEqual(last.kind, "ERROR", `${machine.id}: ${last.payload?.code ?? "step"}`);
if (last.payload?.program?.program_status === "completed" && Number(last.payload?.wasm_motion?.state?.queue_depth ?? 0) === 0) break;
}
assert.equal(last?.payload?.program?.program_status, "completed", machine.id);
const joints = last.payload.wasm_motion.state.joint_commanded;
assert.equal(joints.length, machine.joint_count, machine.id);
assert.ok(joints.every(Number.isFinite), machine.id);
rows.push({ id: machine.id, kinematics: machine.kinematics, coordinates: machine.coordinates, program: machine.program, joints });
}
console.log(JSON.stringify({ schema: "generic-wasm-machine-cases.v1", status: "verified", case_count: rows.length, rows }));

View File

@@ -11,7 +11,7 @@ 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) {
for (const definition of MACHINE_DEFINITIONS.filter((entry) => entry.runtime === "trt")) {
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) => {

View File

@@ -35,7 +35,7 @@ async function open(machine) {
return { session, core, send, adoptSequence: (value) => { seq = value; } };
}
const rows = [];
for (const machine of MACHINE_DEFINITIONS) {
for (const machine of MACHINE_DEFINITIONS.filter((entry) => entry.runtime === "trt")) {
const original = await open(machine);
const idle = original.send("CHECKPOINT").payload.checkpoint;
const idleRestored = await open(machine);

View File

@@ -24,7 +24,7 @@ function edit(bundle, values) {
return { ...bundle, files: { ...bundle.files, [bundle.entrypoint]: content } };
}
const rows = [];
for (const machine of MACHINE_DEFINITIONS) {
for (const machine of MACHINE_DEFINITIONS.filter((entry) => entry.runtime === "trt")) {
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);

View File

@@ -10,7 +10,7 @@ const fetchImpl = async (url) => { try { const body = await readFile(new URL(Str
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) {
for (const definition of MACHINE_DEFINITIONS.filter((entry) => entry.runtime === "trt")) {
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') => {

View File

@@ -0,0 +1,100 @@
import { fnv1a64 } from "./replay.mjs";
const CONFIG_SIZE = 1608;
const KINEMATICS_SIZE = 1760;
const POSE_SIZE = 72;
const JOINT_BYTES = 9 * 8;
const CONFIG = Object.freeze({ type: 0, mode: 4, joint_count: 8, kinematics_joint_count: 12, extra_joint_count: 16, coordinates: 20, limit_enabled: 1448, joint_min: 1464, joint_max: 1536 });
const POSE_AXES = Object.freeze(["x", "y", "z", "a", "b", "c", "u", "v", "w"]);
const TYPES = Object.freeze({ trivkins: 1, "xyzab-tdr": 1, xyzac: 2, xyzbc: 3, xyzbcw: 4, corexy: 5, rose: 6, maxkins: 7, rotatekins: 8, "5axiskins": 9, scara: 10, pumakins: 11, "scorbot-kins": 12, genserkins: 13, lineardeltakins: 14, rotarydeltakins: 15, tripodkins: 16, genhexkins: 17, pentakins: 18 });
function call(core, name, returnType, argTypes, args) {
const fn = core?.[`_${name}`] ?? core?.[name];
if (typeof fn === "function") return fn(...args);
if (typeof core?.ccall === "function") return core.ccall(name, returnType, argTypes, args);
throw new Error(`missing core export: ${name}`);
}
export class WasmGenericKinematics {
constructor(core, { type, coordinates, jointCount, kinematicsJointCount = jointCount, extraJointCount = jointCount - kinematicsJointCount, limits = {}, linearUnits = "mm", angularUnits = "deg", parameters = {} }) {
this.core = core;
this.type = String(type ?? "").toLowerCase();
this.coordinates = String(coordinates ?? "").replace(/\s+/g, "").toUpperCase();
this.jointCount = Number(jointCount);
this.kinematicsJointCount = Number(kinematicsJointCount);
this.extraJointCount = Number(extraJointCount);
this.limits = structuredClone(limits);
this.linearUnits = linearUnits;
this.angularUnits = angularUnits;
this.parameters = structuredClone(parameters ?? {});
const typeId = TYPES[this.type];
if (!typeId || !this.coordinates || !Number.isInteger(this.jointCount) || this.jointCount < 1 || this.jointCount > 9 || !Number.isInteger(this.kinematicsJointCount) || this.kinematicsJointCount < 1 || this.kinematicsJointCount > this.jointCount || this.extraJointCount !== this.jointCount - this.kinematicsJointCount) throw new Error("wasm_generic_kinematics_profile_invalid");
if (["sim_kinematics_init", "sim_kinematics_inverse", "sim_kinematics_forward", "sim_kinematics_home"].some((name) => typeof core?.[`_${name}`] !== "function" && typeof core?.[name] !== "function") || typeof core?._malloc !== "function") throw new Error("wasm_generic_kinematics_unavailable");
this.config = core._malloc(CONFIG_SIZE); this.kinematics = core._malloc(KINEMATICS_SIZE); this.pose = core._malloc(POSE_SIZE); this.joints = core._malloc(JOINT_BYTES); this.info = core._malloc(32);
this.pointers = [this.config, this.kinematics, this.pose, this.joints, this.info];
if (this.pointers.some((pointer) => !pointer)) throw new Error("wasm_generic_kinematics_allocation_failed");
core.HEAPU8.fill(0, this.config, this.config + CONFIG_SIZE); core.HEAPU8.fill(0, this.kinematics, this.kinematics + KINEMATICS_SIZE); core.HEAPU8.fill(0, this.pose, this.pose + POSE_SIZE); core.HEAPU8.fill(0, this.joints, this.joints + JOINT_BYTES); core.HEAPU8.fill(0, this.info, this.info + 32);
const view = new DataView(core.HEAPU8.buffer, this.config, CONFIG_SIZE);
view.setInt32(CONFIG.type, typeId, true); view.setInt32(CONFIG.mode, 0, true); view.setUint32(CONFIG.joint_count, this.jointCount, true); view.setUint32(CONFIG.kinematics_joint_count, this.kinematicsJointCount, true); view.setUint32(CONFIG.extra_joint_count, this.extraJointCount, true);
for (const [index, axis] of [...this.coordinates].entries()) {
core.HEAPU8[this.config + CONFIG.coordinates + index] = axis.charCodeAt(0);
const range = limits[axis.toLowerCase()];
if (!range) continue;
if (range) {
if (!Array.isArray(range) || range.length !== 2 || !range.every(Number.isFinite) || range[0] >= range[1]) throw new Error(`wasm_generic_kinematics_limit_invalid:${axis}`);
core.HEAPU8[this.config + CONFIG.limit_enabled + index] = 1;
view.setFloat64(CONFIG.joint_min + index * 8, range[0], true); view.setFloat64(CONFIG.joint_max + index * 8, range[1], true);
}
}
const scalarOffsets = { x_rot_point: 40, y_rot_point: 48, z_rot_point: 56, x_offset: 64, y_offset: 72, z_offset: 80, tool_offset: 88, pivot_length: 96, puma_a2: 104, puma_a3: 112, puma_d3: 120, puma_d4: 128, puma_d6: 136 };
for (const [key, offset] of Object.entries(scalarOffsets)) if (Number.isFinite(Number(this.parameters[key]))) view.setFloat64(offset, Number(this.parameters[key]), true);
if (Array.isArray(this.parameters.dh_a) && Array.isArray(this.parameters.dh_alpha) && Array.isArray(this.parameters.dh_d)) {
if (![this.parameters.dh_a, this.parameters.dh_alpha, this.parameters.dh_d].every((array) => array.length === 6 && array.every(Number.isFinite))) throw new Error("wasm_generic_kinematics_dh_invalid");
view.setUint32(192, 6, true);
for (let index = 0; index < 6; index += 1) {
view.setFloat64(200 + index * 8, this.parameters.dh_a[index], true);
view.setFloat64(272 + index * 8, this.parameters.dh_alpha[index], true);
view.setFloat64(344 + index * 8, this.parameters.dh_d[index], true);
}
}
const initialized = call(core, "sim_kinematics_init", "number", ["number", "number"], [this.kinematics, this.config]);
if (initialized !== 0) throw new Error(`wasm_generic_kinematics_init_${initialized}`);
this.lastJoints = new Array(this.jointCount).fill(0); this.lastPose = Object.fromEntries(POSE_AXES.map((axis) => [axis, 0]));
const home = this.home();
if (home.status !== "verified") throw new Error(`wasm_generic_kinematics_home_${home.code}`);
}
inverse(pose) {
if (!pose || POSE_AXES.some((axis) => !Number.isFinite(Number(pose[axis] ?? 0)))) return { status: "error", code: "wasm_generic_kinematics_pose_nonfinite" };
this.core.HEAPF64.set(POSE_AXES.map((axis) => Number(pose[axis] ?? 0)), this.pose / 8);
const result = call(this.core, "sim_kinematics_inverse", "number", ["number", "number", "number", "number"], [this.kinematics, this.pose, this.joints, this.kinematicsJointCount]);
if (result !== 0) return { status: "error", code: `wasm_generic_kinematics_inverse_${result}`, result };
this.lastJoints = [...this.core.HEAPF64.subarray(this.joints / 8, this.joints / 8 + this.jointCount)]; this.lastPose = Object.fromEntries(POSE_AXES.map((axis) => [axis, Number(pose[axis] ?? 0)]));
return { status: "verified", joints: [...this.lastJoints] };
}
forward(joints = this.lastJoints) {
if (!Array.isArray(joints) || joints.length < this.jointCount || joints.slice(0, this.jointCount).some((value) => !Number.isFinite(value))) return { status: "error", code: "wasm_generic_kinematics_joints_invalid" };
this.core.HEAPF64.set(joints.slice(0, this.jointCount), this.joints / 8);
const result = call(this.core, "sim_kinematics_forward", "number", ["number", "number", "number", "number"], [this.kinematics, this.joints, this.kinematicsJointCount, this.pose]);
if (result !== 0) return { status: "error", code: `wasm_generic_kinematics_forward_${result}`, result };
this.lastJoints = joints.slice(0, this.jointCount); this.lastPose = Object.fromEntries(POSE_AXES.map((axis, index) => [axis, this.core.HEAPF64[this.pose / 8 + index]]));
return { status: "verified", pose: { ...this.lastPose }, joints: [...this.lastJoints] };
}
home() {
const result = call(this.core, "sim_kinematics_home", "number", ["number", "number", "number", "number"], [this.kinematics, this.joints, this.kinematicsJointCount, this.pose]);
if (result !== 0) return { status: "error", code: `wasm_generic_kinematics_home_${result}`, result };
this.lastJoints = [...this.core.HEAPF64.subarray(this.joints / 8, this.joints / 8 + this.jointCount)]; this.lastPose = Object.fromEntries(POSE_AXES.map((axis, index) => [axis, this.core.HEAPF64[this.pose / 8 + index]]));
return { status: "verified", pose: { ...this.lastPose }, joints: [...this.lastJoints] };
}
snapshot(pose = this.lastPose, joints = this.lastJoints) {
const unsigned = { schema: "wasm-generic-kinematics.v1", type: this.type, coordinates: this.coordinates, joint_count: this.jointCount, linear_units: this.linearUnits, angular_units: this.angularUnits, pose: { ...pose }, joints: [...joints], limits: structuredClone(this.limits), source: "linuxcnc-wasm-sim_kinematics" };
return { status: "verified", ...unsigned, state_hash: fnv1a64(unsigned) };
}
destroy() { for (const pointer of this.pointers ?? []) if (pointer && typeof this.core?._free === "function") this.core._free(pointer); this.pointers = []; }
}
export { TYPES as WASM_KINEMATICS_TYPES };

View File

@@ -74,6 +74,8 @@ export class WasmMotionQueue {
this.cycleTime = cycleTime;
this.velocityLimit = velocityLimit;
this.controls = controls();
this.kinematics = null;
this.kinematicState = null;
if (!this.available) return;
const queueSize = call(core, "sim_motion_size", "number", [], []);
this.queueSize = queueSize;
@@ -110,6 +112,25 @@ export class WasmMotionQueue {
return result === 0 ? { status: "verified", source_line: segment.source_line ?? null } : { status: "error", code: `wasm_motion_enqueue_${result}`, reason: MOTION_ERROR_REASONS[result] ?? `wasm_motion_enqueue_${result}`, message_key: MOTION_ERROR_REASONS[result] ?? `wasm_motion_enqueue_${result}`, source: "linuxcnc-wasm-motion" };
}
setPosition(position) {
if (!this.available || !position || this.read().state.queue_depth !== 0) return { status: "error", code: "wasm_motion_position_requires_idle" };
const pose = ["x", "y", "z", "a", "b", "c", "u", "v", "w"].map((axis) => Number(position[axis] ?? 0));
if (pose.some((value) => !Number.isFinite(value))) return { status: "error", code: "wasm_motion_position_nonfinite" };
const pointer = this.core._malloc(72);
if (!pointer) return { status: "error", code: "wasm_motion_position_allocation_failed" };
try {
this.core.HEAPF64.set(pose, pointer / 8);
const result = call(this.core, "sim_motion_set_position", "number", ["number", "number"], [this.queue, pointer]);
return result === 0 ? this.read() : { status: "error", code: `wasm_motion_position_${result}` };
} finally { this.core._free(pointer); }
}
setKinematics(kinematics) {
this.kinematics = kinematics ?? null;
this.kinematicState = this.kinematics?.snapshot?.() ?? null;
return this.read();
}
reset() {
if (!this.available) return this.read();
const previousFeedOverride = this.controls.feed_override;
@@ -203,7 +224,15 @@ export class WasmMotionQueue {
if (this.controls.paused) return { status: "error", code: "wasm_motion_step_-605" };
const result = call(this.core, "sim_motion_step", "number", ["number"], [this.queue]);
if (result !== 0) return { status: "error", code: `wasm_motion_step_${result}`, reason: MOTION_ERROR_REASONS[result] ?? `wasm_motion_step_${result}`, message_key: MOTION_ERROR_REASONS[result] ?? `wasm_motion_step_${result}`, source: "linuxcnc-wasm-motion" };
return this.read();
const stepped = this.read();
if (stepped.status === "verified" && this.kinematics) {
const inverse = this.kinematics.inverse(stepped.state.position);
if (inverse.status !== "verified") return { status: "error", code: inverse.code ?? "wasm_motion_kinematics_inverse_failed", source: "linuxcnc-wasm-sim_kinematics" };
stepped.state.joint_commanded = [...inverse.joints];
stepped.state.kinematics = this.kinematics.snapshot(stepped.state.position, inverse.joints);
this.kinematicState = stepped.state.kinematics;
}
return stepped;
}
read() {
@@ -212,7 +241,13 @@ export class WasmMotionQueue {
if (result !== 0) return { status: "error", code: `wasm_motion_read_${result}` };
const doubles = this.core.HEAPF64.subarray(this.state / 8, this.state / 8 + 11);
const ints = this.core.HEAPU32.subarray(this.state / 4 + 22, this.state / 4 + 26);
return { status: "verified", state: { position: { x: doubles[0], y: doubles[1], z: doubles[2], a: doubles[3], b: doubles[4], c: doubles[5], u: doubles[6], v: doubles[7], w: doubles[8] }, velocity: this.controls.paused ? 0 : doubles[9], progress: doubles[10], tick: ints[0], queue_depth: ints[1], active_id: ints[2], active_tag: ints[3], motion_type: this.core.HEAP32[this.state / 4 + 26], status: this.core.HEAP32[this.state / 4 + 27], source: "wasm_motion_queue" } };
const position = { x: doubles[0], y: doubles[1], z: doubles[2], a: doubles[3], b: doubles[4], c: doubles[5], u: doubles[6], v: doubles[7], w: doubles[8] };
const state = { position, velocity: this.controls.paused ? 0 : doubles[9], progress: doubles[10], tick: ints[0], queue_depth: ints[1], active_id: ints[2], active_tag: ints[3], motion_type: this.core.HEAP32[this.state / 4 + 26], status: this.core.HEAP32[this.state / 4 + 27], source: "wasm_motion_queue" };
if (this.kinematics) {
const kin = this.kinematics.snapshot();
state.joint_commanded = [...kin.joints]; state.kinematics = kin;
}
return { status: "verified", state };
}
exportState() {