关节契约扩为槽位制:以 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>
101 lines
8.6 KiB
JavaScript
101 lines
8.6 KiB
JavaScript
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 };
|