按关节槽位驱动 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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user