429 lines
16 KiB
JavaScript
429 lines
16 KiB
JavaScript
const DEFAULT_SDK_MODULE_URLS = [
|
|
new URL("../../../../wasm-port/runtime/sdk/src/linuxcnc-task-hal.js", import.meta.url).href,
|
|
new URL("../../wasm-port/runtime/sdk/src/linuxcnc-task-hal.js", import.meta.url).href,
|
|
];
|
|
|
|
const SEMANTIC_BOUNDARY = "linuxcnc_task_motion_hal_wasm_simulation_runtime";
|
|
|
|
export async function createLinuxCncTaskHalRuntime({
|
|
sdkModuleUrl = null,
|
|
moduleOptions = {},
|
|
} = {}) {
|
|
const errors = [];
|
|
const candidateUrls = sdkModuleUrl ? [sdkModuleUrl] : DEFAULT_SDK_MODULE_URLS;
|
|
for (const url of candidateUrls) {
|
|
try {
|
|
const { createLinuxCncTaskHalSdk } = await import(url);
|
|
const sdk = await createLinuxCncTaskHalSdk(moduleOptions);
|
|
return wrapTaskHalSdk(sdk, {
|
|
sdkModuleUrl: url,
|
|
executionContext: "direct",
|
|
});
|
|
} catch (error) {
|
|
errors.push(`${url}: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
throw new Error(`LinuxCNC task/HAL runtime unavailable: ${errors.join(" | ")}`);
|
|
}
|
|
|
|
export function wrapTaskHalSdk(sdk, {
|
|
sdkModuleUrl = null,
|
|
executionContext = "direct",
|
|
workerUrl = null,
|
|
} = {}) {
|
|
if (!sdk || typeof sdk.readiness !== "function") {
|
|
throw new Error("wrapTaskHalSdk requires a task/HAL SDK");
|
|
}
|
|
|
|
return {
|
|
apiName: "web-rtcp-5axis-linuxcnc-task-hal-runtime",
|
|
semanticBoundary: SEMANTIC_BOUNDARY,
|
|
executionContext,
|
|
sdkModuleUrl,
|
|
workerUrl,
|
|
loaded: true,
|
|
|
|
readiness() {
|
|
const readiness = sdk.readiness();
|
|
const taskRuntimeReady = readiness.taskRuntimeReady === true;
|
|
const motionRuntimeReady = readiness.motionRuntimeReady === true;
|
|
const halRuntimeReady = readiness.halRuntimeReady === true;
|
|
return {
|
|
apiName: "web-rtcp-5axis-linuxcnc-task-hal-runtime-readiness",
|
|
loaded: true,
|
|
semanticBoundary: SEMANTIC_BOUNDARY,
|
|
sdkSemanticBoundary: readiness.semanticBoundary,
|
|
executionContext,
|
|
workerUrl,
|
|
taskRuntimeReady,
|
|
motionRuntimeReady,
|
|
halRuntimeReady,
|
|
halSyncReady: taskRuntimeReady && motionRuntimeReady && halRuntimeReady,
|
|
nativeTaskReady: taskRuntimeReady,
|
|
nativeHalSyncReady: taskRuntimeReady && motionRuntimeReady && halRuntimeReady,
|
|
hardwareDrive: false,
|
|
hostRealtimeKernel: false,
|
|
externalUserMProcessReady: false,
|
|
};
|
|
},
|
|
|
|
initSession(session = {}) {
|
|
return sdk.initSession(session);
|
|
},
|
|
|
|
stageFiles(files = []) {
|
|
let staged = 0;
|
|
for (const file of files) {
|
|
const path = file.wasmPath || file.path;
|
|
if (!path) continue;
|
|
const rc = sdk.stageFile(path, file.text || "");
|
|
if (rc !== 0) {
|
|
throw new Error(`lctask_stage_file failed for ${path} rc=${rc}`);
|
|
}
|
|
staged += 1;
|
|
}
|
|
return staged;
|
|
},
|
|
|
|
openProgram(path) {
|
|
const rc = sdk.openProgram(path);
|
|
if (rc !== 0) {
|
|
throw new Error(`lctask_open_program failed for ${path} rc=${rc}`);
|
|
}
|
|
return rc;
|
|
},
|
|
|
|
loadProgramMotionPlan(plan = {}) {
|
|
if (typeof sdk.loadProgramMotionPlan !== "function") {
|
|
throw new Error("task/HAL SDK missing loadProgramMotionPlan; rebuild wasm-port/tools/build_task_hal_wasm.sh");
|
|
}
|
|
const rc = sdk.loadProgramMotionPlan(plan);
|
|
if (rc !== 0) {
|
|
throw new Error(`lctask_load_program_motion_plan_json failed rc=${rc}`);
|
|
}
|
|
return rc;
|
|
},
|
|
|
|
sendCommand(command) {
|
|
const rc = sdk.sendCommand(command);
|
|
if (rc !== 0) {
|
|
throw new Error(`lctask_send_command_json failed for ${command?.type || "unknown"} rc=${rc}`);
|
|
}
|
|
return rc;
|
|
},
|
|
|
|
runCycles(options = {}) {
|
|
const rc = sdk.runCycles(options);
|
|
if (rc !== 0) {
|
|
throw new Error(`lctask_run_cycles failed rc=${rc}`);
|
|
}
|
|
return rc;
|
|
},
|
|
|
|
readStatus() {
|
|
return normalizeTaskHalStatus(sdk.readStatus());
|
|
},
|
|
|
|
readEvents() {
|
|
return sdk.readEvents();
|
|
},
|
|
|
|
resetSession() {
|
|
return sdk.resetSession();
|
|
},
|
|
};
|
|
}
|
|
|
|
export function buildTaskHalSessionFromMachineFiles({ profile, plan, save, selectedProgramRel = null } = {}) {
|
|
const files = save?.files || [];
|
|
const iniFile = files.find((file) => file.kind === "ini")
|
|
|| files.find((file) => file.sourceRel === profile?.iniPath)
|
|
|| null;
|
|
const selectedFile = selectedProgramRel
|
|
? files.find((file) => file.sourceRel === selectedProgramRel)
|
|
: null;
|
|
const programFile = selectedFile
|
|
|| files.find((file) => file.wasmPath === plan?.wasmProgramPath)
|
|
|| files.find((file) => file.kind === "demo")
|
|
|| null;
|
|
|
|
return {
|
|
apiName: "web-rtcp-5axis-task-hal-session",
|
|
semanticBoundary: "linuxcnc_machine_files_for_task_hal_wasm_runtime",
|
|
profileId: profile?.id || plan?.profileId || save?.profileId || "unknown",
|
|
iniPath: iniFile?.wasmPath || plan?.wasmIniPath || plan?.iniPath || profile?.iniPath || null,
|
|
iniText: iniFile?.text || "",
|
|
programPath: programFile?.wasmPath || plan?.wasmProgramPath || null,
|
|
programSourceRel: programFile?.sourceRel || selectedProgramRel || null,
|
|
halFiles: files.filter((file) => file.kind === "hal").map(sessionFileDescriptor),
|
|
toolTableFiles: files.filter((file) => file.kind === "toolTable").map(sessionFileDescriptor),
|
|
remapFiles: files.filter((file) => file.kind === "remap").map(sessionFileDescriptor),
|
|
files: files.map(sessionFileDescriptor),
|
|
fileCount: files.length,
|
|
};
|
|
}
|
|
|
|
export function buildTaskHalProgramMotionPlan({
|
|
programPath = null,
|
|
motion = [],
|
|
timing = null,
|
|
linearUnits = "mm",
|
|
programLines = [],
|
|
} = {}) {
|
|
const segments = Array.isArray(timing?.segments) ? timing.segments : [];
|
|
let planSegments = segments.map((segment, index) => {
|
|
const event = motion[index] || {};
|
|
const startAxes = normalizePlanAxes(segment.startAxes || motion[index - 1]?.axes || {});
|
|
const endAxes = normalizePlanAxes(segment.endAxes || segment.axes || event.axes || startAxes);
|
|
return {
|
|
line: Number(segment.line ?? event.line ?? index + 1),
|
|
type: segment.type || event.type || "STRAIGHT_FEED",
|
|
motionClass: segment.motionClass || (event.type === "STRAIGHT_TRAVERSE" ? "rapid" : "feed"),
|
|
feedMode: segment.feedMode || event.feedMode || "units-per-minute",
|
|
startSeconds: Number(segment.startSeconds || 0),
|
|
durationSeconds: Math.max(Number(segment.durationSeconds || 0), 0),
|
|
elapsedSeconds: Number(segment.elapsedSeconds || 0),
|
|
feedRate: Number(segment.feedRate || event.feedRate || 0),
|
|
linearUnits: segment.linearUnits || event.linearUnits || linearUnits,
|
|
velocityMmPerMin: Math.max(Number(segment.velocityMmPerMin || 0), 0),
|
|
requestedVelocityMmPerMin: Math.max(Number(segment.requestedVelocityMmPerMin || segment.velocityMmPerMin || 0), 0),
|
|
startAxes,
|
|
endAxes,
|
|
};
|
|
}).filter((segment) => segment.durationSeconds > 0 || segment.line > 0);
|
|
const lineSegments = buildSourceLineMotionSegments({
|
|
programLines,
|
|
seedSegments: planSegments,
|
|
linearUnits,
|
|
});
|
|
if (shouldUseSourceLineSegments(planSegments, lineSegments)) {
|
|
planSegments = lineSegments;
|
|
}
|
|
|
|
return {
|
|
apiName: "web-rtcp-5axis-task-hal-program-motion-plan",
|
|
semanticBoundary: "linuxcnc_canonical_motion_feed_timed_task_hal_plan",
|
|
programPath,
|
|
linearUnits,
|
|
totalSeconds: Number(timing?.totalSeconds || 0),
|
|
segmentCount: planSegments.length,
|
|
segments: planSegments,
|
|
};
|
|
}
|
|
|
|
export function normalizeTaskHalStatus(status = {}) {
|
|
const motion = status.motionStatus?.motion || {};
|
|
const axis = status.motionStatus?.axis || {};
|
|
const halPins = status.halSnapshot?.pins || {};
|
|
const motionProgramLine = Number(motion.programLine || 0);
|
|
const halProgramLine = Number(halPins["motion.program-line"]?.value || 0);
|
|
const motionQueueDepth = Number(
|
|
status.motionStatus?.commandQueueDepth ??
|
|
motion.commandQueueDepth ??
|
|
status.motionStatus?.queueDepth ??
|
|
motion.queueDepth ??
|
|
0,
|
|
);
|
|
const activeLine = motionProgramLine > 0
|
|
? motionProgramLine
|
|
: halProgramLine > 0
|
|
? halProgramLine
|
|
: 1;
|
|
return {
|
|
...status,
|
|
semanticBoundary: SEMANTIC_BOUNDARY,
|
|
summary: {
|
|
taskRuntimeReady: status.taskRuntimeReady === true,
|
|
motionRuntimeReady: status.motionStatus?.motionHalSyncReady === true || status.taskCommandsDriveMotionRuntime === true,
|
|
halRuntimeReady: Boolean(status.halSnapshot?.halRuntimeReady ?? status.halSnapshot?.ready ?? true),
|
|
halSyncReady: status.taskCommandsDriveMotionRuntime === true && Boolean(halPins["motion.program-line"]),
|
|
taskHalComparisonReady: status.taskRuntimeReady === true && status.taskCommandsDriveMotionRuntime === true,
|
|
switchkinsRemapHalSync: Boolean(halPins["motion.switchkins-type"]),
|
|
nativeTaskReady: status.taskRuntimeReady === true,
|
|
nativeHalSyncReady: status.taskCommandsDriveMotionRuntime === true && Boolean(halPins["motion.program-line"]),
|
|
fullLinuxCncProgramExecutionReady: false,
|
|
hardwareDrive: false,
|
|
hostRealtimeKernel: false,
|
|
},
|
|
ui: {
|
|
taskState: String(status.task?.state || "ESTOP").toLowerCase(),
|
|
taskMode: String(status.task?.mode || "MANUAL").toLowerCase(),
|
|
interpState: String(status.task?.interpState || "IDLE").toLowerCase(),
|
|
interpResumeState: String(status.task?.interpResumeState || status.task?.interpState || "IDLE").toLowerCase(),
|
|
execState: String(status.task?.execState || "DONE").toLowerCase(),
|
|
taskPaused: status.task?.taskPaused === true,
|
|
singleStepping: status.task?.singleStepping === true,
|
|
taskCycle: Number(status.task?.taskCycle ?? status.taskCycle ?? 0),
|
|
servoCycle: Number(status.motionStatus?.cycle ?? status.task?.servoCycle ?? 0),
|
|
motionQueueDepth,
|
|
motionPaused: motion.paused === true,
|
|
motionStepping: motion.stepping === true,
|
|
motionId: Number(motion.motionId || 0),
|
|
idForStep: Number(motion.idForStep || 0),
|
|
halChangedPinCount: Array.isArray(status.halSnapshot?.changedPins)
|
|
? status.halSnapshot.changedPins.length
|
|
: Number(status.halSnapshot?.changedPinCount || 0),
|
|
activeLine,
|
|
motionProgramLine,
|
|
halProgramLine,
|
|
activeLineSource: motionProgramLine > 0 ? "motion-status" : halProgramLine > 0 ? "hal-pin" : "fallback",
|
|
activeLineHalSynced: motionProgramLine > 0 && halProgramLine > 0
|
|
? motionProgramLine === halProgramLine
|
|
: false,
|
|
switchkinsType: Number(motion.switchkinsType ?? halPins["motion.switchkins-type"]?.value ?? 0),
|
|
axisPose: {
|
|
x: Number(axis.x ?? halPins["axis.0.pos-cmd"]?.value ?? 0),
|
|
y: Number(axis.y ?? halPins["axis.1.pos-cmd"]?.value ?? 0),
|
|
z: Number(axis.z ?? halPins["axis.2.pos-cmd"]?.value ?? 0),
|
|
a: Number(axis.a ?? halPins["axis.3.pos-cmd"]?.value ?? 0),
|
|
b: Number(axis.b ?? halPins["axis.4.pos-cmd"]?.value ?? 0),
|
|
c: Number(axis.c ?? halPins["axis.5.pos-cmd"]?.value ?? 0),
|
|
},
|
|
axisPoseFrame: isJogMotion(motion) ? "task-local" : "work",
|
|
currentVelocity: Number(motion.currentVel || motion.currentVelocity || 0) * 60,
|
|
},
|
|
};
|
|
}
|
|
|
|
function shouldUseSourceLineSegments(planSegments, lineSegments) {
|
|
if (lineSegments.length < 3) return false;
|
|
const plannedLines = new Set(planSegments.map((segment) => Number(segment.line)).filter(Number.isFinite));
|
|
const lineCount = lineSegments.length;
|
|
return plannedLines.size <= 2 && lineCount > plannedLines.size;
|
|
}
|
|
|
|
function buildSourceLineMotionSegments({
|
|
programLines = [],
|
|
seedSegments = [],
|
|
linearUnits = "mm",
|
|
} = {}) {
|
|
if (!Array.isArray(programLines) || programLines.length === 0) return [];
|
|
const seedByLine = new Map(seedSegments.map((segment) => [Number(segment.line), segment]));
|
|
const axes = normalizePlanAxes(seedSegments[0]?.startAxes || {});
|
|
const segments = [];
|
|
let elapsedSeconds = 0;
|
|
let feedRate = firstPositive(seedSegments.map((segment) => segment.feedRate), 100);
|
|
let rapidVelocity = firstPositive(
|
|
seedSegments.filter((segment) => segment.motionClass === "rapid").map((segment) => segment.velocityMmPerMin),
|
|
2100,
|
|
);
|
|
|
|
for (let index = 0; index < programLines.length; index += 1) {
|
|
const line = index + 1;
|
|
const code = stripSourceLineComments(programLines[index]);
|
|
if (!isExecutableGcodeLine(code)) continue;
|
|
|
|
const seed = seedByLine.get(line) || null;
|
|
const startAxes = normalizePlanAxes(seed?.startAxes || axes);
|
|
const parsed = parseGcodeLineMotion(code, startAxes, feedRate);
|
|
if (parsed.feedRate > 0) feedRate = parsed.feedRate;
|
|
const motionClass = seed?.motionClass || parsed.motionClass;
|
|
const endAxes = normalizePlanAxes(seed?.endAxes || parsed.endAxes);
|
|
const velocityMmPerMin = Number(seed?.velocityMmPerMin) > 0
|
|
? Number(seed.velocityMmPerMin)
|
|
: motionClass === "rapid"
|
|
? rapidVelocity
|
|
: Math.max(feedRate, 1);
|
|
if (motionClass === "rapid" && velocityMmPerMin > 0) {
|
|
rapidVelocity = velocityMmPerMin;
|
|
}
|
|
const durationSeconds = Math.max(
|
|
Number(seed?.durationSeconds || 0),
|
|
estimateLineDurationSeconds(startAxes, endAxes, velocityMmPerMin),
|
|
0.05,
|
|
);
|
|
const segment = {
|
|
line,
|
|
type: seed?.type || parsed.type,
|
|
motionClass,
|
|
feedMode: seed?.feedMode || parsed.feedMode,
|
|
startSeconds: elapsedSeconds,
|
|
durationSeconds,
|
|
elapsedSeconds: elapsedSeconds + durationSeconds,
|
|
feedRate,
|
|
linearUnits: seed?.linearUnits || linearUnits,
|
|
velocityMmPerMin,
|
|
requestedVelocityMmPerMin: Number(seed?.requestedVelocityMmPerMin || velocityMmPerMin),
|
|
startAxes,
|
|
endAxes,
|
|
};
|
|
segments.push(segment);
|
|
Object.assign(axes, endAxes);
|
|
elapsedSeconds += durationSeconds;
|
|
}
|
|
return segments;
|
|
}
|
|
|
|
function parseGcodeLineMotion(code, startAxes, currentFeedRate) {
|
|
const numberPattern = "[-+]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)";
|
|
const gCodes = [...code.matchAll(new RegExp(`\\bG\\s*(${numberPattern})\\b`, "gi"))].map((match) => Number(match[1]));
|
|
const feedMode = gCodes.includes(93) ? "inverse-time" : "units-per-minute";
|
|
const rapid = gCodes.includes(0);
|
|
const feed = gCodes.some((value) => value === 1 || value === 2 || value === 3);
|
|
const endAxes = { ...startAxes };
|
|
for (const axis of ["x", "y", "z", "a", "b", "c", "u", "v", "w"]) {
|
|
const match = code.match(new RegExp(`\\b${axis}\\s*(${numberPattern})`, "i"));
|
|
if (match) endAxes[axis] = Number(match[1]);
|
|
}
|
|
const feedMatch = code.match(new RegExp(`\\bF\\s*(${numberPattern})`, "i"));
|
|
const feedRate = feedMatch && Number(feedMatch[1]) > 0 ? Number(feedMatch[1]) : Number(currentFeedRate || 0);
|
|
return {
|
|
type: rapid ? "STRAIGHT_TRAVERSE" : "STRAIGHT_FEED",
|
|
motionClass: rapid && !feed ? "rapid" : "feed",
|
|
feedMode,
|
|
feedRate,
|
|
endAxes,
|
|
};
|
|
}
|
|
|
|
function stripSourceLineComments(line) {
|
|
return String(line || "")
|
|
.replace(/\([^)]*\)/g, " ")
|
|
.replace(/;.*$/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
function isExecutableGcodeLine(code) {
|
|
if (!code || code === "%") return false;
|
|
return /\b[GMTXYZABCUVWF]\s*[-+]?\d/i.test(code);
|
|
}
|
|
|
|
function estimateLineDurationSeconds(startAxes, endAxes, velocityMmPerMin) {
|
|
const distance = Math.sqrt(["x", "y", "z"].reduce((total, axis) => {
|
|
const delta = Number(endAxes[axis] || 0) - Number(startAxes[axis] || 0);
|
|
return total + delta * delta;
|
|
}, 0));
|
|
if (distance <= 0 || velocityMmPerMin <= 0) return 0;
|
|
return distance / Math.max(velocityMmPerMin / 60, 0.000001);
|
|
}
|
|
|
|
function firstPositive(values, fallback) {
|
|
for (const value of values) {
|
|
const number = Number(value);
|
|
if (Number.isFinite(number) && number > 0) return number;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
function normalizePlanAxes(axes = {}) {
|
|
return Object.fromEntries(["x", "y", "z", "a", "b", "c", "u", "v", "w"].map((axis) => [
|
|
axis,
|
|
Number.isFinite(Number(axes[axis])) ? Number(axes[axis]) : 0,
|
|
]));
|
|
}
|
|
|
|
function isJogMotion(motion = {}) {
|
|
return Number(motion.motionType) === 3 || Number(motion.teleopMode) === 1 || motion.teleopMode === true;
|
|
}
|
|
|
|
function sessionFileDescriptor(file) {
|
|
return {
|
|
sourceRel: file.sourceRel,
|
|
wasmPath: file.wasmPath || file.path,
|
|
path: file.wasmPath || file.path,
|
|
kind: file.kind,
|
|
bytes: Number(file.bytes || String(file.text || "").length),
|
|
text: file.text || "",
|
|
};
|
|
}
|