按关节槽位驱动 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

@@ -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);
}
}