按关节槽位驱动 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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
46
web/worker/test_generic_machine_cases.mjs
Normal file
46
web/worker/test_generic_machine_cases.mjs
Normal 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 }));
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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') => {
|
||||
|
||||
100
web/worker/wasm-generic-kinematics.mjs
Normal file
100
web/worker/wasm-generic-kinematics.mjs
Normal 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 };
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user