接入 LinuxCNC TP 运行反馈
This commit is contained in:
166
web-rtcp-5axis-sim-plan/app/src/runtime/execution-timing.js
Normal file
166
web-rtcp-5axis-sim-plan/app/src/runtime/execution-timing.js
Normal file
@@ -0,0 +1,166 @@
|
||||
const LINEAR_AXES = ["x", "y", "z", "u", "v", "w"];
|
||||
const ANGULAR_AXES = ["a", "b", "c"];
|
||||
const ALL_AXES = [...LINEAR_AXES, ...ANGULAR_AXES];
|
||||
|
||||
export function buildProgramExecutionTiming({
|
||||
motion = [],
|
||||
profile = null,
|
||||
feedOverride = 100,
|
||||
rapidOverride = 100,
|
||||
defaultFeedRate = 100,
|
||||
} = {}) {
|
||||
const limits = buildVelocityLimits(profile);
|
||||
const segments = [];
|
||||
let previousAxes = null;
|
||||
let elapsedSeconds = 0;
|
||||
let feedRate = Number(defaultFeedRate) > 0 ? Number(defaultFeedRate) : 100;
|
||||
|
||||
for (let index = 0; index < motion.length; index += 1) {
|
||||
const event = motion[index];
|
||||
const axes = normalizeAxes(event.axes, previousAxes);
|
||||
if (Number.isFinite(event.feedRate) && event.feedRate > 0) {
|
||||
feedRate = event.feedRate;
|
||||
}
|
||||
const segment = buildTimingSegment({
|
||||
event,
|
||||
index,
|
||||
axes,
|
||||
previousAxes: previousAxes || axes,
|
||||
limits,
|
||||
feedRate,
|
||||
feedOverride,
|
||||
rapidOverride,
|
||||
elapsedSeconds,
|
||||
});
|
||||
elapsedSeconds += segment.durationSeconds;
|
||||
segments.push({
|
||||
...segment,
|
||||
elapsedSeconds,
|
||||
});
|
||||
previousAxes = axes;
|
||||
}
|
||||
|
||||
const feedSeconds = segments
|
||||
.filter((segment) => segment.motionClass === "feed")
|
||||
.reduce((total, segment) => total + segment.durationSeconds, 0);
|
||||
const rapidSeconds = segments
|
||||
.filter((segment) => segment.motionClass === "rapid")
|
||||
.reduce((total, segment) => total + segment.durationSeconds, 0);
|
||||
|
||||
return {
|
||||
apiName: "web-rtcp-5axis-program-execution-timing",
|
||||
semanticBoundary: "linuxcnc_canonical_motion_timing_estimate_not_planner_queue",
|
||||
sourceBasis: "LinuxCNC canonical motion events plus INI/profile velocity limits and feed overrides",
|
||||
totalSeconds: elapsedSeconds,
|
||||
totalMinutes: elapsedSeconds / 60,
|
||||
feedSeconds,
|
||||
rapidSeconds,
|
||||
motionCount: segments.length,
|
||||
segments,
|
||||
limits,
|
||||
};
|
||||
}
|
||||
|
||||
export function timingAtMotionIndex(timing, motionIndex = 0) {
|
||||
const segment = timing?.segments?.[motionIndex] || null;
|
||||
return {
|
||||
elapsedSeconds: segment?.elapsedSeconds || 0,
|
||||
remainingSeconds: Math.max((timing?.totalSeconds || 0) - (segment?.elapsedSeconds || 0), 0),
|
||||
currentVelocity: segment?.velocityMmPerMin || 0,
|
||||
segmentDurationSeconds: segment?.durationSeconds || 0,
|
||||
segmentDistanceMm: segment?.linearDistanceMm || 0,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTimingSegment({
|
||||
event,
|
||||
index,
|
||||
axes,
|
||||
previousAxes,
|
||||
limits,
|
||||
feedRate,
|
||||
feedOverride,
|
||||
rapidOverride,
|
||||
elapsedSeconds,
|
||||
}) {
|
||||
const deltas = Object.fromEntries(ALL_AXES.map((axis) => [axis, axes[axis] - previousAxes[axis]]));
|
||||
const linearDistanceMm = vectorLength(LINEAR_AXES.map((axis) => deltas[axis]));
|
||||
const angularDistanceDeg = vectorLength(ANGULAR_AXES.map((axis) => deltas[axis]));
|
||||
const motionClass = event.type === "STRAIGHT_TRAVERSE" ? "rapid" : "feed";
|
||||
const requestedLinearVelocity = motionClass === "rapid"
|
||||
? limits.maxLinearVelocityMmPerMin * percent(rapidOverride)
|
||||
: Math.max(feedRate, 0) * percent(feedOverride);
|
||||
const cappedLinearVelocity = Math.min(
|
||||
requestedLinearVelocity || limits.defaultLinearVelocityMmPerMin,
|
||||
limits.maxLinearVelocityMmPerMin,
|
||||
);
|
||||
const linearSeconds = linearDistanceMm > 0
|
||||
? linearDistanceMm / Math.max(cappedLinearVelocity / 60, 0.000001)
|
||||
: 0;
|
||||
const angularVelocityDegPerMin = motionClass === "rapid"
|
||||
? limits.maxAngularVelocityDegPerMin * percent(rapidOverride)
|
||||
: Math.min(Math.max(feedRate, 0) * percent(feedOverride), limits.maxAngularVelocityDegPerMin);
|
||||
const angularSeconds = angularDistanceDeg > 0
|
||||
? angularDistanceDeg / Math.max(angularVelocityDegPerMin / 60, 0.000001)
|
||||
: 0;
|
||||
const durationSeconds = Math.max(linearSeconds, angularSeconds);
|
||||
|
||||
return {
|
||||
index,
|
||||
line: event.line,
|
||||
type: event.type,
|
||||
motionClass,
|
||||
linearDistanceMm,
|
||||
angularDistanceDeg,
|
||||
feedRate,
|
||||
requestedVelocityMmPerMin: requestedLinearVelocity,
|
||||
velocityMmPerMin: cappedLinearVelocity,
|
||||
angularVelocityDegPerMin,
|
||||
durationSeconds,
|
||||
startSeconds: elapsedSeconds,
|
||||
axes,
|
||||
deltas,
|
||||
};
|
||||
}
|
||||
|
||||
function buildVelocityLimits(profile) {
|
||||
const traj = profile?.traj || {};
|
||||
const axisLimits = profile?.axisLimits || {};
|
||||
const maxLinearVelocity = firstFinite(
|
||||
Number(traj.maxLinearVelocity) * 60,
|
||||
...LINEAR_AXES.map((axis) => Number(axisLimits[axis.toUpperCase()]?.maxVelocity) * 60),
|
||||
2100,
|
||||
);
|
||||
const defaultLinearVelocity = firstFinite(Number(traj.defaultLinearVelocity) * 60, maxLinearVelocity, 1200);
|
||||
const maxAngularVelocity = firstFinite(
|
||||
...ANGULAR_AXES.map((axis) => Number(axisLimits[axis.toUpperCase()]?.maxVelocity) * 60),
|
||||
maxLinearVelocity,
|
||||
);
|
||||
return {
|
||||
maxLinearVelocityMmPerMin: maxLinearVelocity,
|
||||
defaultLinearVelocityMmPerMin: defaultLinearVelocity,
|
||||
maxAngularVelocityDegPerMin: maxAngularVelocity,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAxes(axes = {}, fallback = null) {
|
||||
return Object.fromEntries(ALL_AXES.map((axis) => [
|
||||
axis,
|
||||
Number.isFinite(Number(axes[axis]))
|
||||
? Number(axes[axis])
|
||||
: Number(fallback?.[axis] || 0),
|
||||
]));
|
||||
}
|
||||
|
||||
function vectorLength(values) {
|
||||
return Math.sqrt(values.reduce((total, value) => total + value * value, 0));
|
||||
}
|
||||
|
||||
function percent(value) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? Math.max(number, 0) / 100 : 1;
|
||||
}
|
||||
|
||||
function firstFinite(...values) {
|
||||
return values.find((value) => Number.isFinite(value) && value > 0) || 1;
|
||||
}
|
||||
271
web-rtcp-5axis-sim-plan/app/src/runtime/five-axis-session.js
Normal file
271
web-rtcp-5axis-sim-plan/app/src/runtime/five-axis-session.js
Normal file
@@ -0,0 +1,271 @@
|
||||
export const FIVE_AXIS_SESSION_FORMAT = "web-rtcp-5axis-session-snapshot";
|
||||
export const FIVE_AXIS_SESSION_VERSION = 1;
|
||||
export const DEFAULT_SESSION_ID = "gmoccapy-web-session";
|
||||
export const DEFAULT_SESSION_FILENAME = "web-rtcp-5axis-session.json";
|
||||
|
||||
export function createFiveAxisSessionPayload(state) {
|
||||
return validateFiveAxisSessionPayload({
|
||||
apiName: "web-rtcp-5axis-session-payload",
|
||||
payloadVersion: 1,
|
||||
machineProfile: state.machineProfile,
|
||||
sessionName: state.sessionName,
|
||||
sourceMode: state.sourceMode,
|
||||
machine: state.machine,
|
||||
runState: state.runState,
|
||||
activeProgram: state.activeProgram,
|
||||
programSource: state.programSource,
|
||||
programStartLine: state.programStartLine,
|
||||
activeLine: state.activeLine,
|
||||
lineCount: state.lineCount,
|
||||
fileSizeBytes: state.fileSizeBytes,
|
||||
programLines: state.programLines,
|
||||
axisPose: state.axisPose,
|
||||
jointPose: state.jointPose,
|
||||
tcpPose: state.tcpPose,
|
||||
toolAxisVector: state.toolAxisVector,
|
||||
rtcpState: state.rtcpState,
|
||||
kinsType: state.kinsType,
|
||||
feed: state.feed,
|
||||
spindle: state.spindle,
|
||||
coolant: state.coolant,
|
||||
preview: state.preview,
|
||||
toolPreview: state.toolPreview,
|
||||
programExecutionSourceMode: state.programExecutionSourceMode,
|
||||
programExecutionTiming: state.programExecutionTiming,
|
||||
programElapsedSeconds: state.programElapsedSeconds,
|
||||
programRemainingSeconds: state.programRemainingSeconds,
|
||||
programRuntimeFeedback: state.programRuntimeFeedback,
|
||||
programExecution: state.programExecution
|
||||
? {
|
||||
apiName: state.programExecution.apiName,
|
||||
sourceMode: state.programExecution.sourceMode,
|
||||
semanticBoundary: state.programExecution.semanticBoundary,
|
||||
motion: state.programExecution.motion,
|
||||
plannerTiming: state.programExecution.plannerTiming || null,
|
||||
summary: state.programExecution.summary,
|
||||
}
|
||||
: null,
|
||||
kinematicsRuntimeReadiness: state.kinematicsRuntimeReadiness,
|
||||
interpreterRuntimeReadiness: state.interpreterRuntimeReadiness,
|
||||
linuxCncBoundaryReadiness: state.linuxCncBoundaryReadiness,
|
||||
});
|
||||
}
|
||||
|
||||
export function createFiveAxisSessionSnapshot(sessionId, payload, options = {}) {
|
||||
validateSessionId(sessionId);
|
||||
validateFiveAxisSessionPayload(payload);
|
||||
return {
|
||||
format: FIVE_AXIS_SESSION_FORMAT,
|
||||
version: FIVE_AXIS_SESSION_VERSION,
|
||||
sessionId,
|
||||
createdAt: options.createdAt || new Date().toISOString(),
|
||||
metadata: {
|
||||
source: "web-rtcp-5axis-sim-plan",
|
||||
profile: payload.machineProfile,
|
||||
program: payload.activeProgram,
|
||||
...(options.metadata || {}),
|
||||
},
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateFiveAxisSessionSnapshot(snapshot, sessionId) {
|
||||
assertPlainObject(snapshot, "five-axis session snapshot");
|
||||
if (snapshot.format !== FIVE_AXIS_SESSION_FORMAT) {
|
||||
throw new Error(`Unsupported five-axis session snapshot format: ${snapshot.format}`);
|
||||
}
|
||||
if (snapshot.version !== FIVE_AXIS_SESSION_VERSION) {
|
||||
throw new Error(`Unsupported five-axis session snapshot version: ${snapshot.version}`);
|
||||
}
|
||||
if (snapshot.sessionId !== sessionId) {
|
||||
throw new Error(`Five-axis session snapshot id mismatch: ${snapshot.sessionId}`);
|
||||
}
|
||||
assertPlainObject(snapshot.metadata, "five-axis session snapshot metadata");
|
||||
validateFiveAxisSessionPayload(snapshot.payload);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export async function saveFiveAxisSessionSnapshot(sessionId, payload, options = {}) {
|
||||
const snapshot = createFiveAxisSessionSnapshot(sessionId, payload, options);
|
||||
const path = sessionSnapshotPath(sessionId, options.filename);
|
||||
await saveTextFile(path, `${JSON.stringify(snapshot, null, 2)}\n`, options.storage);
|
||||
return { snapshot, path };
|
||||
}
|
||||
|
||||
export async function loadFiveAxisSessionSnapshot(sessionId, options = {}) {
|
||||
const path = sessionSnapshotPath(sessionId, options.filename);
|
||||
const text = await loadTextFile(path, options.storage);
|
||||
let snapshot;
|
||||
try {
|
||||
snapshot = JSON.parse(text);
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid five-axis session snapshot JSON: ${error.message}`);
|
||||
}
|
||||
return { snapshot: validateFiveAxisSessionSnapshot(snapshot, sessionId), path };
|
||||
}
|
||||
|
||||
export function restoreFiveAxisSessionState(snapshot) {
|
||||
const payload = validateFiveAxisSessionPayload(snapshot.payload);
|
||||
return {
|
||||
machineProfile: payload.machineProfile,
|
||||
sessionName: payload.sessionName,
|
||||
machine: payload.machine,
|
||||
runState: payload.runState,
|
||||
activeProgram: payload.activeProgram,
|
||||
programSource: payload.programSource,
|
||||
programStartLine: payload.programStartLine,
|
||||
activeLine: payload.activeLine,
|
||||
lineCount: payload.lineCount,
|
||||
fileSizeBytes: payload.fileSizeBytes,
|
||||
programLines: payload.programLines,
|
||||
axisPose: payload.axisPose,
|
||||
rtcpState: payload.rtcpState,
|
||||
kinsType: payload.kinsType,
|
||||
feed: payload.feed,
|
||||
spindle: payload.spindle,
|
||||
coolant: payload.coolant,
|
||||
preview: payload.preview,
|
||||
toolPreview: payload.toolPreview,
|
||||
programExecutionSourceMode: payload.programExecutionSourceMode,
|
||||
programExecutionTiming: payload.programExecutionTiming,
|
||||
programElapsedSeconds: payload.programElapsedSeconds,
|
||||
programRemainingSeconds: payload.programRemainingSeconds,
|
||||
programRuntimeFeedback: payload.programRuntimeFeedback,
|
||||
programExecution: payload.programExecution,
|
||||
};
|
||||
}
|
||||
|
||||
export function createMemorySessionStorage(seed = {}) {
|
||||
const files = new Map(Object.entries(seed));
|
||||
return {
|
||||
files,
|
||||
async getDirectory() {
|
||||
return createDirectoryHandle(files, []);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function validateFiveAxisSessionPayload(payload) {
|
||||
assertPlainObject(payload, "five-axis session payload");
|
||||
if (payload.apiName !== "web-rtcp-5axis-session-payload") {
|
||||
throw new Error(`Unsupported five-axis session payload API: ${payload.apiName}`);
|
||||
}
|
||||
if (payload.payloadVersion !== 1) {
|
||||
throw new Error(`Unsupported five-axis session payload version: ${payload.payloadVersion}`);
|
||||
}
|
||||
if (!["xyzac-trt", "xyzbc-trt"].includes(payload.machineProfile)) {
|
||||
throw new Error(`Unsupported five-axis machine profile: ${payload.machineProfile}`);
|
||||
}
|
||||
if (!Array.isArray(payload.programLines)) {
|
||||
throw new Error("five-axis session programLines must be an array.");
|
||||
}
|
||||
if (payload.programExecution !== null) {
|
||||
assertPlainObject(payload.programExecution, "five-axis session programExecution");
|
||||
if (!Array.isArray(payload.programExecution.motion)) {
|
||||
throw new Error("five-axis session programExecution.motion must be an array.");
|
||||
}
|
||||
assertPlainObject(payload.programExecution.summary, "five-axis session programExecution.summary");
|
||||
}
|
||||
for (const key of ["machine", "axisPose", "feed", "spindle", "coolant", "preview", "toolPreview"]) {
|
||||
assertPlainObject(payload[key], `five-axis session ${key}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function sessionSnapshotPath(sessionId, filename = DEFAULT_SESSION_FILENAME) {
|
||||
validateSessionId(sessionId);
|
||||
validateFilename(filename);
|
||||
return `web-rtcp-5axis-sim-plan/sessions/${sessionId}/${filename}`;
|
||||
}
|
||||
|
||||
async function saveTextFile(path, text, storage = globalThis.navigator?.storage) {
|
||||
const root = await getStorageRoot(storage);
|
||||
const dir = await ensureParentDir(root, path);
|
||||
const filename = splitPath(path).at(-1);
|
||||
const fileHandle = await dir.getFileHandle(filename, { create: true });
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(text);
|
||||
await writable.close();
|
||||
}
|
||||
|
||||
async function loadTextFile(path, storage = globalThis.navigator?.storage) {
|
||||
const root = await getStorageRoot(storage);
|
||||
const parts = splitPath(path);
|
||||
let current = root;
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
current = await current.getDirectoryHandle(part);
|
||||
}
|
||||
const fileHandle = await current.getFileHandle(parts.at(-1));
|
||||
const file = await fileHandle.getFile();
|
||||
return file.text();
|
||||
}
|
||||
|
||||
async function getStorageRoot(storage) {
|
||||
if (!storage?.getDirectory) {
|
||||
throw new Error("OPFS is not available in this browser.");
|
||||
}
|
||||
return storage.getDirectory();
|
||||
}
|
||||
|
||||
async function ensureParentDir(root, path) {
|
||||
let current = root;
|
||||
for (const part of splitPath(path).slice(0, -1)) {
|
||||
current = await current.getDirectoryHandle(part, { create: true });
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function createDirectoryHandle(files, prefix) {
|
||||
return {
|
||||
async getDirectoryHandle(name) {
|
||||
return createDirectoryHandle(files, [...prefix, name]);
|
||||
},
|
||||
async getFileHandle(name) {
|
||||
const path = [...prefix, name].join("/");
|
||||
return {
|
||||
async createWritable() {
|
||||
let content = "";
|
||||
return {
|
||||
async write(text) {
|
||||
content += String(text);
|
||||
},
|
||||
async close() {
|
||||
files.set(path, content);
|
||||
},
|
||||
};
|
||||
},
|
||||
async getFile() {
|
||||
if (!files.has(path)) throw new Error(`Missing memory session file: ${path}`);
|
||||
return { async text() { return files.get(path); } };
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function splitPath(path) {
|
||||
const value = String(path || "").replaceAll("\\", "/");
|
||||
const parts = value.split("/").filter(Boolean);
|
||||
if (parts.length === 0 || parts.some((part) => part === "." || part === "..")) {
|
||||
throw new Error(`Invalid session path: ${path}`);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
function validateSessionId(sessionId) {
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(String(sessionId || ""))) {
|
||||
throw new Error(`Invalid five-axis session id: ${sessionId}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateFilename(filename) {
|
||||
if (!/^[a-zA-Z0-9._-]+\.json$/.test(String(filename || ""))) {
|
||||
throw new Error(`Invalid five-axis session filename: ${filename}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertPlainObject(value, label) {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error(`${label} must be a plain object.`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
const MACHINE_FILE_FLAGS = [
|
||||
"fiveaxis_ini_open=1",
|
||||
"fiveaxis_remaps_ready=1",
|
||||
"fiveaxis_file_reached_exit=1",
|
||||
];
|
||||
|
||||
export function createFullLinuxCncExecutionBoundary(state = {}) {
|
||||
const adapter = state.linuxCncBoundaryAdapter || {};
|
||||
const frame = state.rtcpFrame || {};
|
||||
const programExecution = state.programExecution || null;
|
||||
const machineFileExecution = state.machineFileExecution || null;
|
||||
const machineFileText = String(machineFileExecution?.resultText || "");
|
||||
|
||||
const kinematicsReady = Boolean(
|
||||
adapter.linuxCncKinematicsReady ||
|
||||
frame.readiness?.linuxCncKinematicsReady,
|
||||
);
|
||||
const interpreterReady = Boolean(
|
||||
adapter.linuxCncInterpreterReady ||
|
||||
state.interpreterRuntimeReadiness?.loaded,
|
||||
);
|
||||
const canonicalProgramReady = Boolean(
|
||||
programExecution?.sourceMode === "linuxcnc-interpreter-wasm" &&
|
||||
programExecution?.summary?.motionEventCount > 0,
|
||||
);
|
||||
const machineFileStagingReady = state.machineFileStaging?.status === "staged"
|
||||
&& state.machineFileStaging?.fileCount > 0;
|
||||
const machineFileRemapReady = Boolean(
|
||||
machineFileExecution?.sourceMode === "linuxcnc-machine-file-remap-wasm" &&
|
||||
machineFileExecution?.summary?.machineFileExecutionReady === true &&
|
||||
MACHINE_FILE_FLAGS.every((flag) => machineFileText.includes(flag)),
|
||||
);
|
||||
const plannerRuntimeReady = Boolean(
|
||||
programExecution?.summary?.plannerRuntimeReady === true &&
|
||||
programExecution?.plannerTiming?.plannerRuntimeReady === true,
|
||||
);
|
||||
const halSwitchkinsEvidenceReady = machineFileText.includes("fiveaxis_hal_switchkins: rc=0 found=1");
|
||||
|
||||
const satisfied = [
|
||||
kinematicsReady ? "linuxcnc-kinematics-wasm" : null,
|
||||
interpreterReady ? "linuxcnc-interpreter-wasm" : null,
|
||||
canonicalProgramReady ? "canonical-motion-events" : null,
|
||||
machineFileStagingReady ? "machine-file-staging" : null,
|
||||
machineFileRemapReady ? "fiveaxis-remap-machine-file-run" : null,
|
||||
plannerRuntimeReady ? "linuxcnc-tp-queue-runtime-timing" : null,
|
||||
halSwitchkinsEvidenceReady ? "switchkins-hal-bridge-evidence" : null,
|
||||
].filter(Boolean);
|
||||
|
||||
const missing = [];
|
||||
if (!kinematicsReady) missing.push("linuxcnc kinematics WASM frame");
|
||||
if (!interpreterReady) missing.push("linuxcnc interpreter WASM runtime");
|
||||
if (!canonicalProgramReady) missing.push("linuxcnc canonical motion execution");
|
||||
if (!machineFileStagingReady) missing.push("LinuxCNC machine-file staging");
|
||||
if (!machineFileRemapReady) missing.push("machine-file backed five-axis remap run");
|
||||
if (!plannerRuntimeReady) missing.push("LinuxCNC trajectory planner queue timing runtime");
|
||||
if (!halSwitchkinsEvidenceReady) missing.push("switchkins HAL bridge evidence");
|
||||
|
||||
const blockers = [
|
||||
"native LinuxCNC task/NML process is not ported",
|
||||
"native realtime HAL thread synchronization is not ported",
|
||||
"external user-M process and full tool DB process are not promoted",
|
||||
];
|
||||
if (!plannerRuntimeReady) {
|
||||
blockers.push("LinuxCNC trajectory planner queue is not promoted as browser runtime");
|
||||
}
|
||||
|
||||
return {
|
||||
apiName: "web-rtcp-5axis-full-linuxcnc-execution-boundary",
|
||||
profileId: state.machineProfile || adapter.profileId || "unknown",
|
||||
phase: machineFileRemapReady
|
||||
? "partial-linuxcnc-remap-boundary"
|
||||
: canonicalProgramReady
|
||||
? "canonical-interpreter-boundary"
|
||||
: "blocked",
|
||||
semanticBoundary: machineFileRemapReady
|
||||
? "linuxcnc_machine_file_remap_ready_planner_task_hal_blocked"
|
||||
: canonicalProgramReady
|
||||
? "linuxcnc_interpreter_canonical_ready_planner_task_hal_blocked"
|
||||
: "linuxcnc_full_execution_boundary_blocked",
|
||||
sourceMode: machineFileRemapReady
|
||||
? "linuxcnc-machine-file-remap-wasm"
|
||||
: programExecution?.sourceMode || state.programExecutionSourceMode || "fixture-line-playback",
|
||||
readyForUiSimulation: kinematicsReady && interpreterReady && canonicalProgramReady,
|
||||
machineFileBackedRemapReady: machineFileRemapReady,
|
||||
remapRuntimeReady: machineFileRemapReady,
|
||||
halSwitchkinsEvidenceReady,
|
||||
plannerRuntimeReady,
|
||||
nativeTaskReady: false,
|
||||
nativeHalSyncReady: false,
|
||||
fullLinuxCncProgramExecutionReady: false,
|
||||
promotionAllowed: false,
|
||||
satisfied,
|
||||
missing,
|
||||
blockers,
|
||||
evidence: {
|
||||
kinematics: kinematicsReady ? frame.semanticBoundary || adapter.semanticBoundary : null,
|
||||
interpreter: interpreterReady ? state.interpreterRuntimeReadiness?.semanticBoundary || adapter.semanticBoundary : null,
|
||||
canonicalMotionEvents: programExecution?.summary?.motionEventCount || 0,
|
||||
canonicalEventCount: programExecution?.summary?.canonicalEventCount || 0,
|
||||
plannerTiming: plannerRuntimeReady ? programExecution.plannerTiming?.semanticBoundary : null,
|
||||
machineFileFlags: MACHINE_FILE_FLAGS.filter((flag) => machineFileText.includes(flag)),
|
||||
machineFileExecutionReady: machineFileExecution?.summary?.machineFileExecutionReady === true,
|
||||
stagedFileCount: state.machineFileStaging?.fileCount || 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { createProfileSourceReferenceSummary } from "../profiles/source-referenc
|
||||
|
||||
export function createLinuxCncBoundaryAdapter({
|
||||
profile = xyzacTrtProfile,
|
||||
panelSchema = xyzacTrtPyvcpPanelSchema,
|
||||
panelSchema = profile.panelSchema || xyzacTrtPyvcpPanelSchema,
|
||||
runtime = null,
|
||||
} = {}) {
|
||||
const sourceSummary = createProfileSourceReferenceSummary(profile.id);
|
||||
@@ -14,6 +14,8 @@ export function createLinuxCncBoundaryAdapter({
|
||||
const runtimeReady = kinematicsRuntimeReady && interpreterRuntimeReady;
|
||||
const linuxCncKinematicsReady = kinematicsRuntimeReady
|
||||
&& runtime.kinematicsWasm.sourceMode === "source-derived-kinematics-wasm";
|
||||
const linuxCncInterpreterReady = interpreterRuntimeReady
|
||||
&& runtime.interpreterWasm.sourceMode === "linuxcnc-interpreter-wasm";
|
||||
|
||||
return {
|
||||
apiName: "web-rtcp-5axis-linuxcnc-boundary-adapter",
|
||||
@@ -24,13 +26,14 @@ export function createLinuxCncBoundaryAdapter({
|
||||
runtimeReady,
|
||||
kinematicsRuntimeReady,
|
||||
interpreterRuntimeReady,
|
||||
linuxCncInterpreterReady,
|
||||
profileSummary: createProfileSummary(profile),
|
||||
linuxCncKinematicsReady,
|
||||
promotionAllowed: linuxCncKinematicsReady,
|
||||
fullLinuxCncProgramExecutionReady: false,
|
||||
semanticBoundary: linuxCncKinematicsReady
|
||||
? interpreterRuntimeReady
|
||||
? "linuxcnc_runtime_supplied_but_interpreter_or_remap_not_promoted"
|
||||
? linuxCncInterpreterReady
|
||||
? "linuxcnc_kinematics_and_interpreter_wasm_connected_remap_planner_not_promoted"
|
||||
: "linuxcnc_kinematics_wasm_runtime_connected"
|
||||
: "adapter_entrypoint_only_runtime_not_connected",
|
||||
adapterPoints: {
|
||||
@@ -74,6 +77,7 @@ export function createLinuxCncBoundaryReadiness(adapter = createLinuxCncBoundary
|
||||
&& !missing.includes("PyVCP/HAL panel schema"),
|
||||
missing,
|
||||
linuxCncKinematicsReady: adapter.linuxCncKinematicsReady,
|
||||
linuxCncInterpreterReady: adapter.linuxCncInterpreterReady,
|
||||
promotionAllowed: adapter.promotionAllowed,
|
||||
fullLinuxCncProgramExecutionReady: adapter.fullLinuxCncProgramExecutionReady,
|
||||
semanticBoundary: adapter.semanticBoundary,
|
||||
|
||||
345
web-rtcp-5axis-sim-plan/app/src/runtime/linuxcnc-ini-runtime.js
Normal file
345
web-rtcp-5axis-sim-plan/app/src/runtime/linuxcnc-ini-runtime.js
Normal file
@@ -0,0 +1,345 @@
|
||||
const AXIS_SECTION_RE = /^AXIS_([A-Z])$/;
|
||||
const JOINT_SECTION_RE = /^JOINT_(\d+)$/;
|
||||
|
||||
export function parseLinuxCncIni(text, { path = "inline.ini", profileId = "unknown" } = {}) {
|
||||
const sections = parseIniSections(text);
|
||||
const kinsText = getFirstValue(sections, "KINS", "KINEMATICS") || "";
|
||||
const coordinates = getFirstValue(sections, "TRAJ", "COORDINATES") || "";
|
||||
const jointCount = numberOrNull(getFirstValue(sections, "KINS", "JOINTS"));
|
||||
const axisLimits = parseAxisLimits(sections);
|
||||
const jointConfig = parseJointConfig(sections, coordinates);
|
||||
const remaps = parseRemaps(sections);
|
||||
const hal = parseHal(sections);
|
||||
const display = parseDisplay(sections);
|
||||
const halui = {
|
||||
mdiCommands: getValues(sections, "HALUI", "MDI_COMMAND"),
|
||||
};
|
||||
const kinematicsModuleId = inferKinematicsModuleId(kinsText);
|
||||
|
||||
return {
|
||||
apiName: "web-rtcp-5axis-linuxcnc-ini-config",
|
||||
profileId,
|
||||
path,
|
||||
machineName: getFirstValue(sections, "EMC", "MACHINE") || null,
|
||||
kinematics: parseKinematics(kinsText),
|
||||
kinematicsModuleId,
|
||||
kinematicsParameters: {
|
||||
sparm: parseKinematicsParameter(kinsText, "sparm"),
|
||||
joints: jointCount,
|
||||
switchkinsTypes: inferSwitchkinsTypes({ coordinates, halui, kinematicsModuleId }),
|
||||
},
|
||||
traj: {
|
||||
coordinates,
|
||||
linearUnits: getFirstValue(sections, "TRAJ", "LINEAR_UNITS") || null,
|
||||
angularUnits: getFirstValue(sections, "TRAJ", "ANGULAR_UNITS") || null,
|
||||
defaultLinearVelocity: numberOrNull(getFirstValue(sections, "TRAJ", "DEFAULT_LINEAR_VELOCITY")),
|
||||
maxLinearVelocity: numberOrNull(getFirstValue(sections, "TRAJ", "MAX_LINEAR_VELOCITY")),
|
||||
defaultLinearAcceleration: numberOrNull(getFirstValue(sections, "TRAJ", "DEFAULT_LINEAR_ACCELERATION")),
|
||||
maxLinearAcceleration: numberOrNull(getFirstValue(sections, "TRAJ", "MAX_LINEAR_ACCELERATION")),
|
||||
},
|
||||
display,
|
||||
rs274ngc: {
|
||||
subroutinePath: getFirstValue(sections, "RS274NGC", "SUBROUTINE_PATH") || null,
|
||||
halPinVars: boolFromIni(getFirstValue(sections, "RS274NGC", "HAL_PIN_VARS")),
|
||||
parameterFile: getFirstValue(sections, "RS274NGC", "PARAMETER_FILE") || null,
|
||||
remaps,
|
||||
},
|
||||
hal,
|
||||
halui,
|
||||
axisLimits,
|
||||
jointConfig,
|
||||
emcio: {
|
||||
toolTable: getFirstValue(sections, "EMCIO", "TOOL_TABLE") || null,
|
||||
},
|
||||
validation: validateIniConfig({ coordinates, jointCount, axisLimits, jointConfig, kinsText }),
|
||||
semanticBoundary: "linuxcnc_ini_file_browser_parser",
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadLinuxCncIniConfig(profile, { baseUrl = import.meta.url, fetchImpl = globalThis.fetch } = {}) {
|
||||
if (!profile?.iniPath) {
|
||||
throw new Error("profile is missing iniPath");
|
||||
}
|
||||
if (typeof fetchImpl !== "function") {
|
||||
throw new Error("fetch is not available for LinuxCNC INI loading");
|
||||
}
|
||||
|
||||
const candidateUrls = [
|
||||
new URL(`../../${profile.iniPath}`, baseUrl),
|
||||
new URL(`../../../../wasm-port/vendor/linuxcnc/${profile.iniPath}`, baseUrl),
|
||||
];
|
||||
const errors = [];
|
||||
let response = null;
|
||||
for (const url of candidateUrls) {
|
||||
try {
|
||||
response = await fetchImpl(url.href);
|
||||
if (response.ok) break;
|
||||
errors.push(`${url.href}: HTTP ${response.status}`);
|
||||
response = null;
|
||||
} catch (error) {
|
||||
errors.push(`${url.href}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
if (!response) {
|
||||
throw new Error(`failed to load LinuxCNC INI ${profile.iniPath}: ${errors.join(" | ")}`);
|
||||
}
|
||||
return parseLinuxCncIni(await response.text(), {
|
||||
path: profile.iniPath,
|
||||
profileId: profile.id,
|
||||
});
|
||||
}
|
||||
|
||||
export function applyIniConfigToProfile(profile, iniConfig) {
|
||||
if (!iniConfig) return profile;
|
||||
return {
|
||||
...profile,
|
||||
machineName: iniConfig.machineName || profile.machineName,
|
||||
kinematics: iniConfig.kinematics.name || profile.kinematics,
|
||||
kinematicsModuleId: iniConfig.kinematicsModuleId || profile.kinematicsModuleId,
|
||||
kinematicsParameters: {
|
||||
...profile.kinematicsParameters,
|
||||
...iniConfig.kinematicsParameters,
|
||||
switchkinsTypes: mergeSwitchkinsTypes(
|
||||
profile.kinematicsParameters?.switchkinsTypes || [],
|
||||
iniConfig.kinematicsParameters.switchkinsTypes,
|
||||
),
|
||||
},
|
||||
display: {
|
||||
...profile.display,
|
||||
...iniConfig.display,
|
||||
},
|
||||
rs274ngc: {
|
||||
...profile.rs274ngc,
|
||||
subroutinePath: iniConfig.rs274ngc.subroutinePath || profile.rs274ngc?.subroutinePath,
|
||||
halPinVars: iniConfig.rs274ngc.halPinVars ?? profile.rs274ngc?.halPinVars,
|
||||
parameterFile: iniConfig.rs274ngc.parameterFile || profile.rs274ngc?.parameterFile,
|
||||
},
|
||||
hal: {
|
||||
...profile.hal,
|
||||
halui: iniConfig.hal.halui || profile.hal?.halui,
|
||||
halFiles: iniConfig.hal.halFiles.length > 0 ? iniConfig.hal.halFiles : profile.hal?.halFiles,
|
||||
postguiHalFiles: iniConfig.hal.postguiHalFiles.length > 0
|
||||
? iniConfig.hal.postguiHalFiles
|
||||
: profile.hal?.postguiHalFiles,
|
||||
halcmd: {
|
||||
...profile.hal?.halcmd,
|
||||
raw: iniConfig.hal.halcmd,
|
||||
initialSets: iniConfig.hal.initialSets.length > 0
|
||||
? iniConfig.hal.initialSets
|
||||
: profile.hal?.halcmd?.initialSets,
|
||||
},
|
||||
},
|
||||
halui: iniConfig.halui.mdiCommands.length > 0 ? iniConfig.halui : profile.halui,
|
||||
traj: {
|
||||
...profile.traj,
|
||||
...dropNullish(iniConfig.traj),
|
||||
},
|
||||
axisLimits: Object.keys(iniConfig.axisLimits).length > 0 ? iniConfig.axisLimits : profile.axisLimits,
|
||||
jointConfig: iniConfig.jointConfig.length > 0 ? iniConfig.jointConfig : profile.jointConfig,
|
||||
linuxCncIniConfig: iniConfig,
|
||||
};
|
||||
}
|
||||
|
||||
function parseIniSections(text) {
|
||||
const sections = new Map();
|
||||
let current = null;
|
||||
for (const rawLine of String(text).split(/\r?\n/)) {
|
||||
const line = stripIniComment(rawLine).trim();
|
||||
if (!line) continue;
|
||||
const sectionMatch = line.match(/^\[([^\]]+)]$/);
|
||||
if (sectionMatch) {
|
||||
current = sectionMatch[1].trim().toUpperCase();
|
||||
if (!sections.has(current)) sections.set(current, new Map());
|
||||
continue;
|
||||
}
|
||||
if (!current) continue;
|
||||
const equals = line.indexOf("=");
|
||||
if (equals < 0) continue;
|
||||
const key = line.slice(0, equals).trim().toUpperCase();
|
||||
const value = line.slice(equals + 1).trim();
|
||||
const section = sections.get(current);
|
||||
if (!section.has(key)) section.set(key, []);
|
||||
section.get(key).push(value);
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
function stripIniComment(line) {
|
||||
let quote = null;
|
||||
for (let index = 0; index < line.length; index += 1) {
|
||||
const char = line[index];
|
||||
if ((char === "\"" || char === "'") && line[index - 1] !== "\\") {
|
||||
quote = quote === char ? null : quote || char;
|
||||
}
|
||||
if (!quote && (char === "#" || char === ";")) {
|
||||
return line.slice(0, index);
|
||||
}
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
function getValues(sections, sectionName, key) {
|
||||
return sections.get(sectionName.toUpperCase())?.get(key.toUpperCase()) || [];
|
||||
}
|
||||
|
||||
function getFirstValue(sections, sectionName, key) {
|
||||
return getValues(sections, sectionName, key)[0] ?? null;
|
||||
}
|
||||
|
||||
function parseAxisLimits(sections) {
|
||||
const result = {};
|
||||
for (const [sectionName, values] of sections) {
|
||||
const match = sectionName.match(AXIS_SECTION_RE);
|
||||
if (!match) continue;
|
||||
result[match[1]] = {
|
||||
min: numberOrNull(first(values, "MIN_LIMIT")),
|
||||
max: numberOrNull(first(values, "MAX_LIMIT")),
|
||||
maxVelocity: numberOrNull(first(values, "MAX_VELOCITY")),
|
||||
maxAcceleration: numberOrNull(first(values, "MAX_ACCELERATION")),
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseJointConfig(sections, coordinates) {
|
||||
const axisOrder = String(coordinates || "").split("");
|
||||
return [...sections.entries()]
|
||||
.map(([sectionName, values]) => {
|
||||
const match = sectionName.match(JOINT_SECTION_RE);
|
||||
if (!match) return null;
|
||||
const id = Number(match[1]);
|
||||
return {
|
||||
id,
|
||||
axis: axisOrder[id] || null,
|
||||
type: first(values, "TYPE") || null,
|
||||
home: numberOrNull(first(values, "HOME")),
|
||||
min: numberOrNull(first(values, "MIN_LIMIT")),
|
||||
max: numberOrNull(first(values, "MAX_LIMIT")),
|
||||
maxVelocity: numberOrNull(first(values, "MAX_VELOCITY")),
|
||||
maxAcceleration: numberOrNull(first(values, "MAX_ACCELERATION")),
|
||||
homeSearchVelocity: numberOrNull(first(values, "HOME_SEARCH_VEL")),
|
||||
homeSequence: numberOrNull(first(values, "HOME_SEQUENCE")),
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => left.id - right.id);
|
||||
}
|
||||
|
||||
function parseRemaps(sections) {
|
||||
return getValues(sections, "RS274NGC", "REMAP").map((value) => {
|
||||
const code = value.match(/\bM\d+\b/i)?.[0]?.toUpperCase() || null;
|
||||
const ngc = value.match(/\bngc=([^\s]+)/i)?.[1] || null;
|
||||
const modalGroup = numberOrNull(value.match(/\bmodalgroup=(\d+)/i)?.[1]);
|
||||
return { raw: value, code, modalGroup, ngc };
|
||||
});
|
||||
}
|
||||
|
||||
function parseHal(sections) {
|
||||
const halcmd = getValues(sections, "HAL", "HALCMD");
|
||||
return {
|
||||
halui: getFirstValue(sections, "HAL", "HALUI") || null,
|
||||
halFiles: getValues(sections, "HAL", "HALFILE"),
|
||||
postguiHalFiles: getValues(sections, "HAL", "POSTGUI_HALFILE"),
|
||||
halcmd,
|
||||
initialSets: halcmd
|
||||
.map((line) => line.match(/^\s*(sets|setp)\s+:?([^\s]+)\s+([-+0-9.eE]+)/i))
|
||||
.filter(Boolean)
|
||||
.map((match) => ({ op: match[1], pin: match[2], value: Number(match[3]) })),
|
||||
};
|
||||
}
|
||||
|
||||
function parseDisplay(sections) {
|
||||
const jogAxes = getFirstValue(sections, "DISPLAY", "JOG_AXES");
|
||||
return dropNullish({
|
||||
geometry: getFirstValue(sections, "DISPLAY", "GEOMETRY"),
|
||||
display: getFirstValue(sections, "DISPLAY", "DISPLAY"),
|
||||
jogAxes: jogAxes ? jogAxes.split("") : null,
|
||||
pyvcp: getFirstValue(sections, "DISPLAY", "PYVCP"),
|
||||
openFile: getFirstValue(sections, "DISPLAY", "OPEN_FILE"),
|
||||
programPrefix: getFirstValue(sections, "DISPLAY", "PROGRAM_PREFIX"),
|
||||
positionOffset: getFirstValue(sections, "DISPLAY", "POSITION_OFFSET"),
|
||||
positionFeedback: getFirstValue(sections, "DISPLAY", "POSITION_FEEDBACK"),
|
||||
maxFeedOverride: numberOrNull(getFirstValue(sections, "DISPLAY", "MAX_FEED_OVERRIDE")),
|
||||
maxLinearVelocity: numberOrNull(getFirstValue(sections, "DISPLAY", "MAX_LINEAR_VELOCITY")),
|
||||
maxAngularVelocity: numberOrNull(getFirstValue(sections, "DISPLAY", "MAX_ANGULAR_VELOCITY")),
|
||||
});
|
||||
}
|
||||
|
||||
function parseKinematics(text) {
|
||||
const [name, ...parameters] = String(text || "").trim().split(/\s+/).filter(Boolean);
|
||||
return {
|
||||
name: name || null,
|
||||
raw: text,
|
||||
parameters,
|
||||
};
|
||||
}
|
||||
|
||||
function parseKinematicsParameter(text, key) {
|
||||
return String(text || "").match(new RegExp(`\\b${key}=([^\\s]+)`, "i"))?.[1] || null;
|
||||
}
|
||||
|
||||
function inferKinematicsModuleId(kinsText) {
|
||||
const name = parseKinematics(kinsText).name || "";
|
||||
if (name.includes("xyzbc")) return "xyzbc-trt";
|
||||
if (name.includes("xyzac")) return "xyzac-trt";
|
||||
return name.replace(/-kins$/, "") || null;
|
||||
}
|
||||
|
||||
function inferSwitchkinsTypes({ coordinates, halui, kinematicsModuleId }) {
|
||||
const mdiCommands = halui.mdiCommands.length > 0 ? halui.mdiCommands : ["M429", "M428", "M430"];
|
||||
const tcpType = coordinates === "XYZBC" ? "tcp-xyzbc" : "tcp-xyzac";
|
||||
const tcpLabel = `${coordinates || kinematicsModuleId || "TCP"} TCP`;
|
||||
return mdiCommands.map((command, index) => ({
|
||||
value: index === 0 ? 0 : index,
|
||||
label: index === 0 ? "identity" : index === 1 ? tcpLabel : "USERK",
|
||||
mdiCommand: command,
|
||||
webKinsType: index === 0 ? "identity" : index === 1 ? tcpType : "userk",
|
||||
}));
|
||||
}
|
||||
|
||||
function validateIniConfig({ coordinates, jointCount, axisLimits, jointConfig, kinsText }) {
|
||||
const missing = [];
|
||||
if (!coordinates) missing.push("TRAJ.COORDINATES");
|
||||
if (!jointCount) missing.push("KINS.JOINTS");
|
||||
if (!kinsText) missing.push("KINS.KINEMATICS");
|
||||
for (const axis of String(coordinates || "").split("")) {
|
||||
if (!axisLimits[axis]) missing.push(`AXIS_${axis}`);
|
||||
}
|
||||
if (jointCount && jointConfig.length !== jointCount) {
|
||||
missing.push(`JOINT_ count ${jointConfig.length}/${jointCount}`);
|
||||
}
|
||||
return {
|
||||
ready: missing.length === 0,
|
||||
missing,
|
||||
axisCount: Object.keys(axisLimits).length,
|
||||
jointCount: jointConfig.length,
|
||||
};
|
||||
}
|
||||
|
||||
function first(values, key) {
|
||||
return values.get(key)?.[0] ?? null;
|
||||
}
|
||||
|
||||
function numberOrNull(value) {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
function boolFromIni(value) {
|
||||
if (value === null || value === undefined) return null;
|
||||
return ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase());
|
||||
}
|
||||
|
||||
function dropNullish(object) {
|
||||
return Object.fromEntries(Object.entries(object).filter(([, value]) => value !== null && value !== undefined));
|
||||
}
|
||||
|
||||
function mergeSwitchkinsTypes(profileTypes, iniTypes) {
|
||||
if (!iniTypes?.length) return profileTypes;
|
||||
return iniTypes.map((iniType) => ({
|
||||
...iniType,
|
||||
...(profileTypes.find((entry) => entry.mdiCommand === iniType.mdiCommand || entry.value === iniType.value) || {}),
|
||||
...iniType,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
const DEFAULT_SDK_MODULE_URL = "../../../../wasm-port/runtime/sdk/src/linuxcnc-interp.js";
|
||||
const DEFAULT_TP_SDK_MODULE_URL = "../../../../wasm-port/runtime/sdk/src/linuxcnc-tp.js";
|
||||
const SOURCE_MODE = "linuxcnc-interpreter-wasm";
|
||||
const SEMANTIC_BOUNDARY = "linuxcnc_interpreter_wasm_canonical_events";
|
||||
const PLANNER_TIMING_BOUNDARY = "linuxcnc_tp_queue_runtime_timing_from_canonical_motion";
|
||||
const SWITCHKINS_REMAP_BOUNDARY = "linuxcnc_switchkins_remap_mcode_preserved_web_runtime_applied";
|
||||
const FIVE_AXIS_REMAP_FLAGS = [
|
||||
"fiveaxis_ini_open=1",
|
||||
"fiveaxis_remaps_ready=1",
|
||||
"fiveaxis_file_reached_exit=1",
|
||||
];
|
||||
|
||||
const AXES = ["x", "y", "z", "a", "b", "c", "u", "v", "w"];
|
||||
const SWITCHKINS_M_CODES = new Map([
|
||||
[428, { switchkinsType: 1, requestedKinsType: "tcp" }],
|
||||
[429, { switchkinsType: 0, requestedKinsType: "identity" }],
|
||||
[430, { switchkinsType: 2, requestedKinsType: "userk" }],
|
||||
]);
|
||||
const PLANE_AXIS_MAP = {
|
||||
170: ["x", "y", "z"],
|
||||
180: ["x", "z", "y"],
|
||||
190: ["y", "z", "x"],
|
||||
};
|
||||
|
||||
export async function createLinuxCncInterpreterRuntime({
|
||||
moduleOptions = null,
|
||||
tpModuleOptions = null,
|
||||
wasmRoot = null,
|
||||
sdkModuleUrl = DEFAULT_SDK_MODULE_URL,
|
||||
tpSdkModuleUrl = DEFAULT_TP_SDK_MODULE_URL,
|
||||
} = {}) {
|
||||
const { createLinuxCncInterpSdk } = await import(sdkModuleUrl);
|
||||
const resolvedModuleOptions = moduleOptions || await createDefaultModuleOptions({ wasmRoot });
|
||||
const sdk = await createLinuxCncInterpSdk(resolvedModuleOptions);
|
||||
const tpRuntime = await createOptionalTpRuntime({ tpModuleOptions, wasmRoot, tpSdkModuleUrl });
|
||||
|
||||
return {
|
||||
apiName: "web-rtcp-5axis-linuxcnc-interpreter-runtime",
|
||||
loaded: true,
|
||||
sourceMode: SOURCE_MODE,
|
||||
semanticBoundary: SEMANTIC_BOUNDARY,
|
||||
executionContext: "direct",
|
||||
sdk,
|
||||
tpRuntime,
|
||||
|
||||
readiness() {
|
||||
return {
|
||||
apiName: "web-rtcp-5axis-linuxcnc-interpreter-runtime-readiness",
|
||||
loaded: true,
|
||||
sourceMode: SOURCE_MODE,
|
||||
semanticBoundary: SEMANTIC_BOUNDARY,
|
||||
executionContext: "direct",
|
||||
runProgramReady: typeof sdk.runProgram === "function",
|
||||
remapRuntimeReady: false,
|
||||
plannerRuntimeReady: tpRuntime?.loaded === true,
|
||||
plannerSemanticBoundary: tpRuntime?.semanticBoundary || null,
|
||||
};
|
||||
},
|
||||
|
||||
runProgram(programText) {
|
||||
const prepared = prepareLinuxCncProgramForRuntime(programText);
|
||||
const resultText = sdk.runProgram(prepared.runtimeProgramText);
|
||||
const motion = parseLinuxCncCanonicalMotion(resultText, programText, prepared.switchkinsEvents);
|
||||
return createProgramExecutionResult({
|
||||
programText,
|
||||
resultText: prependRuntimeEvents(resultText, prepared.switchkinsEvents),
|
||||
motion,
|
||||
switchkinsEvents: prepared.switchkinsEvents,
|
||||
runtimeProgramText: prepared.runtimeProgramText,
|
||||
plannerTiming: runPlannerTiming(tpRuntime, motion),
|
||||
});
|
||||
},
|
||||
|
||||
runMachineFileProgram({ plan, files = null, executionMode = "fiveAxisRemap" } = {}) {
|
||||
if (!plan?.wasmIniPath || !plan?.wasmProgramPath) {
|
||||
throw new Error("runMachineFileProgram requires a machine-file staging plan with INI and program paths");
|
||||
}
|
||||
if (typeof sdk.runSimConfigProgram !== "function") {
|
||||
throw new Error("LinuxCNC interpreter SDK missing runSimConfigProgram");
|
||||
}
|
||||
const stagedFiles = files || plan.files;
|
||||
const resultText = sdk.runSimConfigProgram({
|
||||
files: stagedFiles.map((file) => ({
|
||||
path: file.wasmPath || file.path,
|
||||
text: file.text,
|
||||
executable: file.executable,
|
||||
})),
|
||||
programPath: plan.wasmProgramPath,
|
||||
iniPath: plan.wasmIniPath,
|
||||
executionMode,
|
||||
});
|
||||
const programFile = stagedFiles.find((file) => (
|
||||
(file.wasmPath || file.path) === plan.wasmProgramPath
|
||||
));
|
||||
const programText = programFile?.text || "";
|
||||
const motion = parseLinuxCncCanonicalMotion(resultText, programText);
|
||||
return createProgramExecutionResult({
|
||||
programText,
|
||||
resultText,
|
||||
motion,
|
||||
machineFilePlan: plan,
|
||||
sourceMode: "linuxcnc-machine-file-remap-wasm",
|
||||
semanticBoundary: "linuxcnc_fiveaxis_remap_wasm_machine_file_execution",
|
||||
plannerTiming: runPlannerTiming(tpRuntime, motion),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createLinuxCncInterpreterRuntimeDescriptor(runtime) {
|
||||
if (!runtime?.loaded) return null;
|
||||
return {
|
||||
apiName: runtime.apiName,
|
||||
loaded: runtime.loaded,
|
||||
sourceMode: runtime.sourceMode,
|
||||
semanticBoundary: runtime.semanticBoundary,
|
||||
executionContext: runtime.executionContext || "direct",
|
||||
};
|
||||
}
|
||||
|
||||
function createProgramExecutionResult({
|
||||
programText,
|
||||
resultText,
|
||||
motion,
|
||||
switchkinsEvents = [],
|
||||
runtimeProgramText = programText,
|
||||
machineFilePlan = null,
|
||||
sourceMode = SOURCE_MODE,
|
||||
semanticBoundary = SEMANTIC_BOUNDARY,
|
||||
plannerTiming = null,
|
||||
}) {
|
||||
const canonicalEventCount = String(resultText).split("\n").filter((line) => line.startsWith("canon_event=")).length;
|
||||
const machineFileExecutionReady = Boolean(
|
||||
machineFilePlan && FIVE_AXIS_REMAP_FLAGS.every((flag) => String(resultText).includes(flag)),
|
||||
);
|
||||
const plannerRuntimeReady = plannerTiming?.plannerRuntimeReady === true
|
||||
&& plannerTiming.motionCount === motion.length;
|
||||
return {
|
||||
apiName: "web-rtcp-5axis-linuxcnc-interpreter-program-execution",
|
||||
sourceMode,
|
||||
semanticBoundary,
|
||||
resultText,
|
||||
runtimeProgramText,
|
||||
motion,
|
||||
plannerTiming,
|
||||
switchkinsEvents,
|
||||
switchkinsRemapBoundary: switchkinsEvents.length > 0 ? SWITCHKINS_REMAP_BOUNDARY : null,
|
||||
machineFilePlan: machineFilePlan
|
||||
? {
|
||||
apiName: machineFilePlan.apiName,
|
||||
profileId: machineFilePlan.profileId,
|
||||
wasmIniPath: machineFilePlan.wasmIniPath,
|
||||
wasmProgramPath: machineFilePlan.wasmProgramPath,
|
||||
selectedProgramSourceRel: machineFilePlan.selectedProgramSourceRel || null,
|
||||
selectedProgramFilename: machineFilePlan.selectedProgramFilename || null,
|
||||
fileCount: machineFilePlan.files?.length ?? 0,
|
||||
semanticBoundary: machineFilePlan.semanticBoundary,
|
||||
}
|
||||
: null,
|
||||
summary: {
|
||||
ready: motion.length > 0,
|
||||
programLineCount: programText.split(/\r?\n/).filter((line) => line.trim()).length,
|
||||
canonicalEventCount,
|
||||
motionEventCount: motion.length,
|
||||
motionTypes: [...new Set(motion.map((event) => event.type))],
|
||||
finalAxes: motion.at(-1)?.axes || Object.fromEntries(AXES.map((axis) => [axis, 0])),
|
||||
switchkinsEventCount: switchkinsEvents.length,
|
||||
switchkinsCodes: [...new Set(switchkinsEvents.map((event) => event.code))],
|
||||
switchkinsRemapBoundary: switchkinsEvents.length > 0 ? SWITCHKINS_REMAP_BOUNDARY : null,
|
||||
remapRuntimeReady: machineFileExecutionReady,
|
||||
plannerRuntimeReady,
|
||||
plannerSemanticBoundary: plannerRuntimeReady ? PLANNER_TIMING_BOUNDARY : null,
|
||||
machineFileExecutionReady,
|
||||
fullLinuxCncProgramExecutionReady: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function createOptionalTpRuntime({ tpModuleOptions, wasmRoot, tpSdkModuleUrl }) {
|
||||
try {
|
||||
const { createLinuxCncTpSdk } = await import(tpSdkModuleUrl);
|
||||
const resolvedOptions = tpModuleOptions || await createDefaultTpModuleOptions({ wasmRoot });
|
||||
const sdk = await createLinuxCncTpSdk(resolvedOptions);
|
||||
return {
|
||||
loaded: true,
|
||||
semanticBoundary: PLANNER_TIMING_BOUNDARY,
|
||||
sdk,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
loaded: false,
|
||||
semanticBoundary: null,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function runPlannerTiming(tpRuntime, motion) {
|
||||
if (!tpRuntime?.loaded || typeof tpRuntime.sdk?.runCanonicalMotionTiming !== "function") {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return tpRuntime.sdk.runCanonicalMotionTiming({
|
||||
motion,
|
||||
options: {
|
||||
cycleTime: 0.001,
|
||||
queueSize: 32,
|
||||
maxCycles: 2000000,
|
||||
sampleStride: 10,
|
||||
maxVelocity: 35,
|
||||
maxAcceleration: 500,
|
||||
maxJerk: 1000,
|
||||
tolerance: 0,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
apiName: "web-rtcp-5axis-linuxcnc-tp-program-timing",
|
||||
semanticBoundary: PLANNER_TIMING_BOUNDARY,
|
||||
plannerRuntimeReady: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
motionCount: motion.length,
|
||||
totalSeconds: 0,
|
||||
totalMinutes: 0,
|
||||
feedSeconds: 0,
|
||||
rapidSeconds: 0,
|
||||
segments: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function parseLinuxCncCanonicalMotion(resultText, programText = "", switchkinsEvents = []) {
|
||||
const axes = Object.fromEntries(AXES.map((axis) => [axis, 0]));
|
||||
const sourceLines = programLineMap(programText);
|
||||
const feedRatesByLine = feedRatesBySourceLine(programText);
|
||||
const switchkinsByLine = switchkinsEventsByLine(switchkinsEvents);
|
||||
const motion = [];
|
||||
let activePlane = 170;
|
||||
let activeSwitchkinsEvent = null;
|
||||
let activeFeedRate = null;
|
||||
|
||||
for (const line of String(resultText).split("\n")) {
|
||||
const feedRate = readCanonicalNumber(line, "feed_rate");
|
||||
if (Number.isFinite(feedRate) && feedRate > 0) {
|
||||
activeFeedRate = feedRate;
|
||||
}
|
||||
|
||||
const plane = readCanonicalNumber(line, "plane");
|
||||
if (plane && PLANE_AXIS_MAP[plane]) {
|
||||
activePlane = plane;
|
||||
}
|
||||
|
||||
const event = line.match(/^canon_event=(STRAIGHT_TRAVERSE|STRAIGHT_FEED|ARC_FEED)\b/);
|
||||
if (!event) continue;
|
||||
|
||||
if (event[1] === "ARC_FEED") {
|
||||
const [firstAxis, secondAxis, thirdAxis] = PLANE_AXIS_MAP[activePlane] ?? PLANE_AXIS_MAP[170];
|
||||
const firstEnd = readCanonicalNumber(line, "first_end");
|
||||
const secondEnd = readCanonicalNumber(line, "second_end");
|
||||
const axisEndPoint = readCanonicalNumber(line, "axis_end_point");
|
||||
if (Number.isFinite(firstEnd)) axes[firstAxis] = firstEnd;
|
||||
if (Number.isFinite(secondEnd)) axes[secondAxis] = secondEnd;
|
||||
if (Number.isFinite(axisEndPoint)) axes[thirdAxis] = axisEndPoint;
|
||||
axes.arc = {
|
||||
plane: activePlane,
|
||||
firstAxis,
|
||||
secondAxis,
|
||||
thirdAxis,
|
||||
firstEnd,
|
||||
secondEnd,
|
||||
centerFirst: readCanonicalNumber(line, "first_axis"),
|
||||
centerSecond: readCanonicalNumber(line, "second_axis"),
|
||||
rotation: readCanonicalNumber(line, "rotation"),
|
||||
axisEndPoint,
|
||||
};
|
||||
} else {
|
||||
for (const axis of AXES) {
|
||||
const value = readCanonicalNumber(line, axis);
|
||||
if (value !== null && Number.isFinite(value)) axes[axis] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const sourceLine = readCanonicalNumber(line, "line");
|
||||
if (Number.isFinite(sourceLine)) {
|
||||
const event = latestSwitchkinsEventAtOrBeforeLine(switchkinsByLine, sourceLine);
|
||||
if (event) activeSwitchkinsEvent = event;
|
||||
const sourceFeedRate = latestFeedRateAtOrBeforeLine(feedRatesByLine, sourceLine);
|
||||
if (Number.isFinite(sourceFeedRate) && sourceFeedRate > 0) {
|
||||
activeFeedRate = sourceFeedRate;
|
||||
}
|
||||
}
|
||||
motion.push({
|
||||
type: event[1],
|
||||
line: Number.isFinite(sourceLine) ? sourceLine : null,
|
||||
statement: Number.isFinite(sourceLine) ? (sourceLines.get(sourceLine) ?? "-") : "-",
|
||||
axes: { ...axes },
|
||||
kinsType: activeSwitchkinsEvent?.requestedKinsType || null,
|
||||
switchkinsType: activeSwitchkinsEvent?.switchkinsType ?? null,
|
||||
switchkinsCode: activeSwitchkinsEvent?.code || null,
|
||||
switchkinsRemapBoundary: activeSwitchkinsEvent ? SWITCHKINS_REMAP_BOUNDARY : null,
|
||||
feedRate: activeFeedRate,
|
||||
raw: line,
|
||||
});
|
||||
}
|
||||
|
||||
return motion;
|
||||
}
|
||||
|
||||
function feedRatesBySourceLine(programText) {
|
||||
const rates = [];
|
||||
String(programText).split(/\r?\n/).forEach((line, index) => {
|
||||
const codeOnly = stripComments(line);
|
||||
let feedRate = null;
|
||||
for (const match of codeOnly.matchAll(/\bF\s*([-+]?\d+(?:\.\d+)?)/gi)) {
|
||||
const value = Number(match[1]);
|
||||
if (Number.isFinite(value) && value > 0) feedRate = value;
|
||||
}
|
||||
if (feedRate !== null) {
|
||||
rates.push({ line: index + 1, feedRate });
|
||||
}
|
||||
});
|
||||
return rates;
|
||||
}
|
||||
|
||||
function latestFeedRateAtOrBeforeLine(rates, sourceLine) {
|
||||
let feedRate = null;
|
||||
for (const entry of rates) {
|
||||
if (entry.line <= sourceLine) feedRate = entry.feedRate;
|
||||
}
|
||||
return feedRate;
|
||||
}
|
||||
|
||||
export function prepareLinuxCncProgramForRuntime(programText) {
|
||||
const switchkinsEvents = [];
|
||||
const runtimeLines = String(programText).split(/\r?\n/).map((line, index) => {
|
||||
const lineNumber = index + 1;
|
||||
const events = readSwitchkinsEventsFromLine(line, lineNumber);
|
||||
if (events.length === 0) return line;
|
||||
switchkinsEvents.push(...events);
|
||||
return stripSwitchkinsMcodesFromLine(line, events);
|
||||
});
|
||||
|
||||
return {
|
||||
runtimeProgramText: runtimeLines.join("\n"),
|
||||
switchkinsEvents,
|
||||
};
|
||||
}
|
||||
|
||||
function readSwitchkinsEventsFromLine(line, lineNumber) {
|
||||
const codeOnly = stripComments(String(line));
|
||||
const events = [];
|
||||
for (const match of codeOnly.matchAll(/\bM\s*([0-9]+(?:\.[0-9]+)?)\b/gi)) {
|
||||
const value = Number(match[1]);
|
||||
const mCode = Number.isInteger(value) ? value : null;
|
||||
const switchkins = SWITCHKINS_M_CODES.get(mCode);
|
||||
if (!switchkins) continue;
|
||||
events.push({
|
||||
line: lineNumber,
|
||||
code: `M${mCode}`,
|
||||
switchkinsType: switchkins.switchkinsType,
|
||||
requestedKinsType: switchkins.requestedKinsType,
|
||||
semanticBoundary: SWITCHKINS_REMAP_BOUNDARY,
|
||||
});
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
function stripSwitchkinsMcodesFromLine(line, events) {
|
||||
const eventCodes = new Set(events.map((event) => event.code.slice(1)));
|
||||
let nextLine = String(line).replace(/\bM\s*([0-9]+(?:\.[0-9]+)?)\b/gi, (token, value) => {
|
||||
const numericValue = Number(value);
|
||||
if (Number.isInteger(numericValue) && eventCodes.has(String(numericValue))) {
|
||||
return " ";
|
||||
}
|
||||
return token;
|
||||
});
|
||||
const codeOnly = stripComments(nextLine).replace(/\bN\s*[0-9]+\b/gi, "").trim();
|
||||
if (!codeOnly) {
|
||||
nextLine = `(web runtime switchkins ${events.map((event) => event.code).join(" ")})`;
|
||||
}
|
||||
return nextLine;
|
||||
}
|
||||
|
||||
function stripComments(line) {
|
||||
return String(line)
|
||||
.replace(/\([^)]*\)/g, " ")
|
||||
.replace(/;.*$/g, " ");
|
||||
}
|
||||
|
||||
function switchkinsEventsByLine(events) {
|
||||
return [...events]
|
||||
.filter((event) => Number.isFinite(event.line))
|
||||
.sort((left, right) => left.line - right.line);
|
||||
}
|
||||
|
||||
function latestSwitchkinsEventAtOrBeforeLine(events, lineNumber) {
|
||||
let latest = null;
|
||||
for (const event of events) {
|
||||
if (event.line > lineNumber) break;
|
||||
latest = event;
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
function prependRuntimeEvents(resultText, switchkinsEvents) {
|
||||
if (switchkinsEvents.length === 0) return resultText;
|
||||
const eventLines = switchkinsEvents.map((event) => (
|
||||
`web_runtime_event=SWITCHKINS line=${event.line} code=${event.code} switchkins_type=${event.switchkinsType} requested_kins=${event.requestedKinsType}`
|
||||
));
|
||||
return `${eventLines.join("\n")}\n${resultText}`;
|
||||
}
|
||||
|
||||
function programLineMap(programText) {
|
||||
const lines = new Map();
|
||||
programText.split(/\r?\n/).forEach((line, index) => {
|
||||
lines.set(index + 1, line.trim() || "(blank)");
|
||||
});
|
||||
return lines;
|
||||
}
|
||||
|
||||
function readCanonicalNumber(line, field) {
|
||||
const match = String(line).match(new RegExp(`\\b${field}=([-+0-9.eE]+)`));
|
||||
return match ? Number(match[1]) : null;
|
||||
}
|
||||
|
||||
async function createDefaultModuleOptions({ wasmRoot }) {
|
||||
const quietOptions = { print() {}, printErr() {} };
|
||||
if (!isNodeRuntime()) return quietOptions;
|
||||
|
||||
const [{ readFileSync }, { dirname, resolve }, { fileURLToPath }] = await Promise.all([
|
||||
import("node:fs"),
|
||||
import("node:path"),
|
||||
import("node:url"),
|
||||
]);
|
||||
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
||||
const resolvedWasmRoot = wasmRoot || resolve(moduleDir, "../../../../wasm-port/build/wasm/core");
|
||||
return {
|
||||
...quietOptions,
|
||||
wasmBinary: readFileSync(resolve(resolvedWasmRoot, "linuxcnc_interp.wasm")),
|
||||
};
|
||||
}
|
||||
|
||||
async function createDefaultTpModuleOptions({ wasmRoot }) {
|
||||
const quietOptions = { print() {}, printErr() {} };
|
||||
if (!isNodeRuntime()) return quietOptions;
|
||||
|
||||
const [{ readFileSync }, { dirname, resolve }, { fileURLToPath }] = await Promise.all([
|
||||
import("node:fs"),
|
||||
import("node:path"),
|
||||
import("node:url"),
|
||||
]);
|
||||
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
||||
const resolvedWasmRoot = wasmRoot || resolve(moduleDir, "../../../../wasm-port/build/wasm/tp");
|
||||
return {
|
||||
...quietOptions,
|
||||
wasmBinary: readFileSync(resolve(resolvedWasmRoot, "linuxcnc_tp.wasm")),
|
||||
};
|
||||
}
|
||||
|
||||
function isNodeRuntime() {
|
||||
return typeof process === "object"
|
||||
&& typeof process.versions === "object"
|
||||
&& typeof process.versions.node === "string"
|
||||
&& process.type !== "renderer";
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
const SOURCE_MODE = "linuxcnc-interpreter-wasm";
|
||||
const SEMANTIC_BOUNDARY = "linuxcnc_interpreter_wasm_canonical_events";
|
||||
|
||||
export async function createLinuxCncInterpreterWorkerRuntime({
|
||||
sdkModuleUrl,
|
||||
workerUrl = new URL("./linuxcnc-interpreter-worker.js", import.meta.url).href,
|
||||
} = {}) {
|
||||
if (typeof Worker !== "function") {
|
||||
throw new Error("Web Worker is not available in this runtime");
|
||||
}
|
||||
|
||||
const worker = new Worker(workerUrl, { type: "module" });
|
||||
const request = createWorkerRequest(worker);
|
||||
const readiness = await request("init", { sdkModuleUrl });
|
||||
|
||||
const runtime = {
|
||||
apiName: "web-rtcp-5axis-linuxcnc-interpreter-worker-runtime",
|
||||
loaded: true,
|
||||
sourceMode: SOURCE_MODE,
|
||||
semanticBoundary: SEMANTIC_BOUNDARY,
|
||||
executionContext: "worker",
|
||||
workerUrl,
|
||||
|
||||
readiness() {
|
||||
return {
|
||||
...readiness,
|
||||
apiName: "web-rtcp-5axis-linuxcnc-interpreter-worker-runtime-readiness",
|
||||
executionContext: "worker",
|
||||
workerUrl,
|
||||
};
|
||||
},
|
||||
|
||||
runProgram(programText) {
|
||||
return request("runProgram", { programText });
|
||||
},
|
||||
|
||||
runMachineFileProgram(options = {}) {
|
||||
return request("runMachineFileProgram", options);
|
||||
},
|
||||
|
||||
terminate() {
|
||||
worker.terminate();
|
||||
},
|
||||
};
|
||||
|
||||
return runtime;
|
||||
}
|
||||
|
||||
function createWorkerRequest(worker) {
|
||||
let nextId = 1;
|
||||
const pending = new Map();
|
||||
|
||||
worker.addEventListener("message", (event) => {
|
||||
const { id, ok, value, error } = event.data || {};
|
||||
const request = pending.get(id);
|
||||
if (!request) return;
|
||||
pending.delete(id);
|
||||
if (ok) {
|
||||
request.resolve(value);
|
||||
} else {
|
||||
request.reject(new Error(error || "LinuxCNC interpreter worker request failed"));
|
||||
}
|
||||
});
|
||||
|
||||
worker.addEventListener("error", (event) => {
|
||||
const error = new Error(event.message || "LinuxCNC interpreter worker error");
|
||||
for (const request of pending.values()) {
|
||||
request.reject(error);
|
||||
}
|
||||
pending.clear();
|
||||
});
|
||||
|
||||
return function request(type, payload = {}) {
|
||||
const id = nextId++;
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.set(id, { resolve, reject });
|
||||
worker.postMessage({ id, type, payload });
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { createLinuxCncInterpreterRuntime } from "./linuxcnc-interpreter-runtime.js";
|
||||
|
||||
let runtime = null;
|
||||
|
||||
self.addEventListener("message", async (event) => {
|
||||
const { id, type, payload = {} } = event.data || {};
|
||||
try {
|
||||
if (type === "init") {
|
||||
runtime = await createLinuxCncInterpreterRuntime({
|
||||
moduleOptions: payload.moduleOptions,
|
||||
wasmRoot: payload.wasmRoot,
|
||||
sdkModuleUrl: payload.sdkModuleUrl,
|
||||
});
|
||||
postSuccess(id, runtime.readiness());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!runtime?.loaded) {
|
||||
throw new Error("LinuxCNC interpreter worker runtime is not initialized");
|
||||
}
|
||||
|
||||
if (type === "readiness") {
|
||||
postSuccess(id, runtime.readiness());
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "runProgram") {
|
||||
postSuccess(id, runtime.runProgram(payload.programText || ""));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "runMachineFileProgram") {
|
||||
postSuccess(id, runtime.runMachineFileProgram(payload));
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`unknown LinuxCNC interpreter worker request: ${type}`);
|
||||
} catch (error) {
|
||||
self.postMessage({
|
||||
id,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function postSuccess(id, value) {
|
||||
self.postMessage({ id, ok: true, value });
|
||||
}
|
||||
@@ -1,39 +1,31 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
createLinuxCncKinematicsSdk,
|
||||
linuxCncKinematicsWasmFile,
|
||||
supportedLinuxCncKinematicsModules,
|
||||
} from "../../../../wasm-port/runtime/sdk/src/index.js";
|
||||
|
||||
const DEFAULT_MODULE_ID = "xyzac-trt";
|
||||
const DEFAULT_JOINT_COUNT = 5;
|
||||
const SOURCE_MODE = "source-derived-kinematics-wasm";
|
||||
const SEMANTIC_BOUNDARY = "linuxcnc_kinematics_wasm_c_abi";
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const defaultWasmRoot = resolve(__dirname, "../../../../wasm-port/build/wasm/kinematics");
|
||||
const DEFAULT_SDK_MODULE_URL = "../../../../wasm-port/runtime/sdk/src/linuxcnc-kinematics.js";
|
||||
|
||||
export async function createLinuxCncKinematicsRuntime({
|
||||
moduleId = DEFAULT_MODULE_ID,
|
||||
moduleOptions = null,
|
||||
switchkinsType = 0,
|
||||
jointCount = DEFAULT_JOINT_COUNT,
|
||||
wasmRoot = defaultWasmRoot,
|
||||
wasmRoot = null,
|
||||
sdkModuleUrl = DEFAULT_SDK_MODULE_URL,
|
||||
} = {}) {
|
||||
const {
|
||||
createLinuxCncKinematicsSdk,
|
||||
linuxCncKinematicsWasmFile,
|
||||
supportedLinuxCncKinematicsModules,
|
||||
} = await import(sdkModuleUrl);
|
||||
const wasmFile = linuxCncKinematicsWasmFile(moduleId);
|
||||
if (!wasmFile) {
|
||||
throw new Error(`unsupported LinuxCNC kinematics module: ${moduleId}`);
|
||||
}
|
||||
|
||||
const resolvedModuleOptions = moduleOptions || {
|
||||
wasmBinary: readFileSync(resolve(wasmRoot, wasmFile)),
|
||||
print() {},
|
||||
printErr() {},
|
||||
};
|
||||
const resolvedModuleOptions = moduleOptions || await createDefaultModuleOptions({ wasmRoot, wasmFile });
|
||||
const sdk = await createLinuxCncKinematicsSdk({ moduleId, moduleOptions: resolvedModuleOptions });
|
||||
const switchRc = typeof sdk.switchKinematics === "function"
|
||||
let activeSwitchkinsType = switchkinsType;
|
||||
let activeSwitchRc = typeof sdk.switchKinematics === "function"
|
||||
? sdk.switchKinematics(switchkinsType)
|
||||
: 0;
|
||||
|
||||
@@ -45,8 +37,13 @@ export async function createLinuxCncKinematicsRuntime({
|
||||
loaded: true,
|
||||
sourceMode: SOURCE_MODE,
|
||||
semanticBoundary: SEMANTIC_BOUNDARY,
|
||||
switchkinsType,
|
||||
switchRc,
|
||||
executionContext: "direct",
|
||||
get switchkinsType() {
|
||||
return activeSwitchkinsType;
|
||||
},
|
||||
get switchRc() {
|
||||
return activeSwitchRc;
|
||||
},
|
||||
jointCount,
|
||||
sdk,
|
||||
|
||||
@@ -59,11 +56,21 @@ export async function createLinuxCncKinematicsRuntime({
|
||||
loaded: true,
|
||||
sourceMode: SOURCE_MODE,
|
||||
semanticBoundary: SEMANTIC_BOUNDARY,
|
||||
switchkinsType,
|
||||
switchRc,
|
||||
executionContext: "direct",
|
||||
switchkinsType: activeSwitchkinsType,
|
||||
switchRc: activeSwitchRc,
|
||||
};
|
||||
},
|
||||
|
||||
switchKinematics(nextSwitchkinsType) {
|
||||
const requestedType = Number(nextSwitchkinsType) || 0;
|
||||
activeSwitchRc = typeof sdk.switchKinematics === "function"
|
||||
? sdk.switchKinematics(requestedType)
|
||||
: 0;
|
||||
activeSwitchkinsType = requestedType;
|
||||
return activeSwitchRc;
|
||||
},
|
||||
|
||||
forward(joints, options = {}) {
|
||||
return sdk.forward(joints, options);
|
||||
},
|
||||
@@ -82,7 +89,7 @@ export async function createLinuxCncKinematicsRuntime({
|
||||
);
|
||||
return {
|
||||
moduleId,
|
||||
switchkinsType,
|
||||
switchkinsType: activeSwitchkinsType,
|
||||
forward,
|
||||
inverse,
|
||||
};
|
||||
@@ -90,6 +97,35 @@ export async function createLinuxCncKinematicsRuntime({
|
||||
};
|
||||
}
|
||||
|
||||
async function createDefaultModuleOptions({ wasmRoot, wasmFile }) {
|
||||
const quietOptions = {
|
||||
print() {},
|
||||
printErr() {},
|
||||
};
|
||||
if (!isNodeRuntime()) {
|
||||
return quietOptions;
|
||||
}
|
||||
|
||||
const [{ readFileSync }, { dirname, resolve }, { fileURLToPath }] = await Promise.all([
|
||||
import("node:fs"),
|
||||
import("node:path"),
|
||||
import("node:url"),
|
||||
]);
|
||||
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
||||
const resolvedWasmRoot = wasmRoot || resolve(moduleDir, "../../../../wasm-port/build/wasm/kinematics");
|
||||
return {
|
||||
...quietOptions,
|
||||
wasmBinary: readFileSync(resolve(resolvedWasmRoot, wasmFile)),
|
||||
};
|
||||
}
|
||||
|
||||
function isNodeRuntime() {
|
||||
return typeof process === "object"
|
||||
&& typeof process.versions === "object"
|
||||
&& typeof process.versions.node === "string"
|
||||
&& process.type !== "renderer";
|
||||
}
|
||||
|
||||
export function createLinuxCncKinematicsRuntimeDescriptor(runtime) {
|
||||
if (!runtime?.loaded) return null;
|
||||
return {
|
||||
@@ -100,6 +136,8 @@ export function createLinuxCncKinematicsRuntimeDescriptor(runtime) {
|
||||
loaded: runtime.loaded,
|
||||
sourceMode: runtime.sourceMode,
|
||||
semanticBoundary: runtime.semanticBoundary,
|
||||
executionContext: runtime.executionContext || "direct",
|
||||
workerUrl: runtime.workerUrl || null,
|
||||
switchkinsType: runtime.switchkinsType,
|
||||
switchRc: runtime.switchRc,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
const DEFAULT_MODULE_ID = "xyzac-trt";
|
||||
const DEFAULT_JOINT_COUNT = 5;
|
||||
const SOURCE_MODE = "source-derived-kinematics-wasm";
|
||||
const SEMANTIC_BOUNDARY = "linuxcnc_kinematics_wasm_c_abi";
|
||||
|
||||
export async function createLinuxCncKinematicsWorkerRuntime({
|
||||
moduleId = DEFAULT_MODULE_ID,
|
||||
switchkinsType = 0,
|
||||
jointCount = DEFAULT_JOINT_COUNT,
|
||||
sdkModuleUrl,
|
||||
workerUrl = new URL("./linuxcnc-kinematics-worker.js", import.meta.url).href,
|
||||
} = {}) {
|
||||
if (typeof Worker !== "function") {
|
||||
throw new Error("Web Worker is not available in this runtime");
|
||||
}
|
||||
|
||||
const worker = new Worker(workerUrl, { type: "module" });
|
||||
const request = createWorkerRequest(worker);
|
||||
const readiness = await request("init", {
|
||||
moduleId,
|
||||
switchkinsType,
|
||||
jointCount,
|
||||
sdkModuleUrl,
|
||||
});
|
||||
|
||||
const runtime = {
|
||||
apiName: "web-rtcp-5axis-linuxcnc-kinematics-worker-runtime",
|
||||
moduleId,
|
||||
wasmFile: readiness.wasmFile,
|
||||
supportedModules: readiness.supportedModules,
|
||||
loaded: true,
|
||||
sourceMode: SOURCE_MODE,
|
||||
semanticBoundary: SEMANTIC_BOUNDARY,
|
||||
executionContext: "worker",
|
||||
workerUrl,
|
||||
switchkinsType,
|
||||
switchRc: readiness.switchRc,
|
||||
jointCount,
|
||||
|
||||
readiness() {
|
||||
return {
|
||||
...readiness,
|
||||
apiName: "web-rtcp-5axis-linuxcnc-kinematics-worker-runtime-readiness",
|
||||
executionContext: "worker",
|
||||
workerUrl,
|
||||
};
|
||||
},
|
||||
|
||||
forward(joints, options = {}) {
|
||||
return request("forward", { joints: Array.from(joints, Number), options });
|
||||
},
|
||||
|
||||
inverse(pose, count = jointCount, options = {}) {
|
||||
return request("inverse", { pose, count, options });
|
||||
},
|
||||
|
||||
async switchKinematics(nextSwitchkinsType) {
|
||||
const result = await request("switchKinematics", { switchkinsType: nextSwitchkinsType });
|
||||
runtime.switchkinsType = result.switchkinsType;
|
||||
runtime.switchRc = result.switchRc;
|
||||
return result.switchRc;
|
||||
},
|
||||
|
||||
frameForJoints(joints, options = {}) {
|
||||
return request("frameForJoints", { joints: Array.from(joints, Number), options });
|
||||
},
|
||||
|
||||
terminate() {
|
||||
worker.terminate();
|
||||
},
|
||||
};
|
||||
|
||||
return runtime;
|
||||
}
|
||||
|
||||
function createWorkerRequest(worker) {
|
||||
let nextId = 1;
|
||||
const pending = new Map();
|
||||
|
||||
worker.addEventListener("message", (event) => {
|
||||
const { id, ok, value, error } = event.data || {};
|
||||
const request = pending.get(id);
|
||||
if (!request) return;
|
||||
pending.delete(id);
|
||||
if (ok) {
|
||||
request.resolve(value);
|
||||
} else {
|
||||
request.reject(new Error(error || "LinuxCNC kinematics worker request failed"));
|
||||
}
|
||||
});
|
||||
|
||||
worker.addEventListener("error", (event) => {
|
||||
const error = new Error(event.message || "LinuxCNC kinematics worker error");
|
||||
for (const request of pending.values()) {
|
||||
request.reject(error);
|
||||
}
|
||||
pending.clear();
|
||||
});
|
||||
|
||||
return function request(type, payload = {}) {
|
||||
const id = nextId++;
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.set(id, { resolve, reject });
|
||||
worker.postMessage({ id, type, payload });
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { createLinuxCncKinematicsRuntime } from "./linuxcnc-kinematics-runtime.js";
|
||||
|
||||
let runtime = null;
|
||||
|
||||
self.addEventListener("message", async (event) => {
|
||||
const { id, type, payload = {} } = event.data || {};
|
||||
try {
|
||||
if (type === "init") {
|
||||
runtime = await createLinuxCncKinematicsRuntime({
|
||||
moduleId: payload.moduleId,
|
||||
moduleOptions: payload.moduleOptions,
|
||||
switchkinsType: payload.switchkinsType,
|
||||
jointCount: payload.jointCount,
|
||||
wasmRoot: payload.wasmRoot,
|
||||
sdkModuleUrl: payload.sdkModuleUrl,
|
||||
});
|
||||
postSuccess(id, runtime.readiness());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!runtime?.loaded) {
|
||||
throw new Error("LinuxCNC kinematics worker runtime is not initialized");
|
||||
}
|
||||
|
||||
if (type === "readiness") {
|
||||
postSuccess(id, runtime.readiness());
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "forward") {
|
||||
postSuccess(id, runtime.forward(payload.joints, payload.options || {}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "inverse") {
|
||||
postSuccess(id, runtime.inverse(payload.pose, payload.count, payload.options || {}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "switchKinematics") {
|
||||
const switchRc = runtime.switchKinematics(payload.switchkinsType);
|
||||
postSuccess(id, {
|
||||
switchkinsType: runtime.switchkinsType,
|
||||
switchRc,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "frameForJoints") {
|
||||
postSuccess(id, runtime.frameForJoints(payload.joints, payload.options || {}));
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`unknown LinuxCNC kinematics worker request: ${type}`);
|
||||
} catch (error) {
|
||||
self.postMessage({
|
||||
id,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function postSuccess(id, value) {
|
||||
self.postMessage({ id, ok: true, value });
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
const DEFAULT_SDK_MODULE_URL = "../../../../wasm-port/runtime/sdk/src/sim-config-staging.js";
|
||||
const DEFAULT_MANIFEST_URLS = [
|
||||
new URL("../../../../wasm-port/tools/source-manifest.txt", import.meta.url).href,
|
||||
new URL("../../wasm-port/tools/source-manifest.txt", import.meta.url).href,
|
||||
];
|
||||
const DEFAULT_VENDOR_ROOT_URLS = [
|
||||
new URL("../../../../wasm-port/vendor/linuxcnc/", import.meta.url).href,
|
||||
new URL("../../wasm-port/vendor/linuxcnc/", import.meta.url).href,
|
||||
];
|
||||
const TRT_MACHINE_REL = "axis/vismach/5axis/table-rotary-tilting";
|
||||
const TRT_DEMO_SOURCE_PREFIX = `configs/sim/${TRT_MACHINE_REL}/demos/`;
|
||||
const OPFS_ROOT = "web-rtcp-5axis-sim-plan/machines";
|
||||
|
||||
export async function createMachineFileStagingPlan({
|
||||
profile,
|
||||
iniText = null,
|
||||
manifestText = null,
|
||||
sdkModuleUrl = DEFAULT_SDK_MODULE_URL,
|
||||
manifestUrl = null,
|
||||
wasmDir = null,
|
||||
} = {}) {
|
||||
if (!profile?.iniPath) {
|
||||
throw new Error("machine file staging requires a profile with iniPath");
|
||||
}
|
||||
const { planSimConfigStaging } = await import(sdkModuleUrl);
|
||||
const resolvedManifestText = manifestText ?? await readTextFromCandidateUrls(
|
||||
manifestUrl ? [manifestUrl] : DEFAULT_MANIFEST_URLS,
|
||||
);
|
||||
const resolvedIniText = iniText ?? await readTextFromCandidateUrls(sourceUrlsFor(profile.iniPath));
|
||||
const iniFile = basename(profile.iniPath);
|
||||
const plan = planSimConfigStaging({
|
||||
manifestText: resolvedManifestText,
|
||||
machineRel: TRT_MACHINE_REL,
|
||||
iniFile,
|
||||
iniText: resolvedIniText,
|
||||
wasmDir: wasmDir || `/work/sim/${TRT_MACHINE_REL}/${profile.id}`,
|
||||
});
|
||||
|
||||
const files = addVendoredDemoSources(plan.files, resolvedManifestText, plan.wasmDir);
|
||||
|
||||
return {
|
||||
apiName: "web-rtcp-5axis-machine-file-staging-plan",
|
||||
profileId: profile.id,
|
||||
machineRel: TRT_MACHINE_REL,
|
||||
iniPath: profile.iniPath,
|
||||
wasmDir: plan.wasmDir,
|
||||
wasmIniPath: plan.iniPath,
|
||||
wasmProgramPath: plan.programPath,
|
||||
files: files.map((file) => ({
|
||||
...file,
|
||||
opfsPath: opfsPathFor(profile.id, file.sourceRel),
|
||||
kind: classifySourceRel(file.sourceRel),
|
||||
})),
|
||||
summary: summarizePlan(files),
|
||||
semanticBoundary: "linuxcnc_sim_config_file_staging_plan_only",
|
||||
};
|
||||
}
|
||||
|
||||
export function listLinuxCncGcodeSources(save) {
|
||||
return [...(save?.files || [])]
|
||||
.filter((file) => file.kind === "demo" && isLinuxCncFiveAxisGcodeSourceRel(file.sourceRel))
|
||||
.map((file) => ({
|
||||
sourceRel: file.sourceRel,
|
||||
wasmPath: file.wasmPath,
|
||||
opfsPath: file.opfsPath,
|
||||
filename: basename(file.sourceRel),
|
||||
bytes: file.bytes,
|
||||
label: basename(file.sourceRel).replace(/\.ngc$/i, ""),
|
||||
sourceMode: "linuxcnc-vendored-5axis-gcode",
|
||||
semanticBoundary: "linuxcnc_vendored_5axis_gcode_source_file",
|
||||
}))
|
||||
.sort((left, right) => left.filename.localeCompare(right.filename));
|
||||
}
|
||||
|
||||
export function selectMachineFileProgram(plan, save, sourceRel) {
|
||||
if (!isLinuxCncFiveAxisGcodeSourceRel(sourceRel)) {
|
||||
throw new Error(`5-axis G-code source must come from LinuxCNC source demos: ${sourceRel}`);
|
||||
}
|
||||
const selectedFile = (save?.files || []).find((file) => file.sourceRel === sourceRel);
|
||||
if (!selectedFile) {
|
||||
throw new Error(`LinuxCNC G-code source not staged: ${sourceRel}`);
|
||||
}
|
||||
return {
|
||||
...plan,
|
||||
wasmProgramPath: selectedFile.wasmPath || selectedFile.path,
|
||||
selectedProgramSourceRel: selectedFile.sourceRel,
|
||||
selectedProgramFilename: basename(selectedFile.sourceRel),
|
||||
selectedProgramBytes: selectedFile.bytes,
|
||||
semanticBoundary: "linuxcnc_sim_config_file_staging_plan_with_selected_gcode_source",
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveMachineFileStagingPlan(plan, options = {}) {
|
||||
if (plan?.apiName !== "web-rtcp-5axis-machine-file-staging-plan") {
|
||||
throw new Error("saveMachineFileStagingPlan requires a machine-file staging plan");
|
||||
}
|
||||
const savedFiles = [];
|
||||
for (const file of plan.files) {
|
||||
const text = await readTextFromCandidateUrls(sourceUrlsFor(file.sourceRel));
|
||||
await saveTextFile(file.opfsPath, text, options.storage);
|
||||
savedFiles.push({
|
||||
sourceRel: file.sourceRel,
|
||||
opfsPath: file.opfsPath,
|
||||
wasmPath: file.wasmPath,
|
||||
path: file.wasmPath,
|
||||
text,
|
||||
kind: file.kind,
|
||||
bytes: text.length,
|
||||
executable: Boolean(file.executable),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
apiName: "web-rtcp-5axis-machine-file-staging-save",
|
||||
profileId: plan.profileId,
|
||||
status: "saved",
|
||||
savedAt: new Date().toISOString(),
|
||||
fileCount: savedFiles.length,
|
||||
opfsRoot: `${OPFS_ROOT}/${plan.profileId}`,
|
||||
files: savedFiles,
|
||||
gcodeSources: listLinuxCncGcodeSources({ files: savedFiles }),
|
||||
summary: summarizeSavedFiles(savedFiles),
|
||||
semanticBoundary: "opfs_machine_file_text_staging_only",
|
||||
};
|
||||
}
|
||||
|
||||
export async function stageProfileMachineFiles(profile, options = {}) {
|
||||
const plan = await createMachineFileStagingPlan({
|
||||
profile,
|
||||
iniText: options.iniText,
|
||||
manifestText: options.manifestText,
|
||||
sdkModuleUrl: options.sdkModuleUrl,
|
||||
manifestUrl: options.manifestUrl,
|
||||
wasmDir: options.wasmDir,
|
||||
});
|
||||
const save = await saveMachineFileStagingPlan(plan, { storage: options.storage });
|
||||
return { plan, save };
|
||||
}
|
||||
|
||||
function summarizePlan(files) {
|
||||
const kinds = countKinds(files.map((file) => classifySourceRel(file.sourceRel)));
|
||||
return {
|
||||
fileCount: files.length,
|
||||
requiredFileCount: files.filter((file) => file.sourceRel.endsWith(".ini") || file.sourceRel.includes("/demos/")).length,
|
||||
remapFileCount: kinds.remap || 0,
|
||||
demoFileCount: kinds.demo || 0,
|
||||
toolTableFileCount: kinds.toolTable || 0,
|
||||
halFileCount: kinds.hal || 0,
|
||||
kinds,
|
||||
};
|
||||
}
|
||||
|
||||
function addVendoredDemoSources(files, manifestText, wasmDir) {
|
||||
const bySourceRel = new Map(files.map((file) => [file.sourceRel, file]));
|
||||
for (const sourceRel of String(manifestText).split(/\r?\n/)) {
|
||||
if (!isLinuxCncFiveAxisGcodeSourceRel(sourceRel)) continue;
|
||||
if (bySourceRel.has(sourceRel)) continue;
|
||||
bySourceRel.set(sourceRel, {
|
||||
sourceRel,
|
||||
wasmPath: `${wasmDir}/demos/${basename(sourceRel)}`,
|
||||
executable: false,
|
||||
});
|
||||
}
|
||||
return [...bySourceRel.values()];
|
||||
}
|
||||
|
||||
function isLinuxCncFiveAxisGcodeSourceRel(sourceRel) {
|
||||
const value = String(sourceRel || "");
|
||||
return value.startsWith(TRT_DEMO_SOURCE_PREFIX)
|
||||
&& value.endsWith(".ngc")
|
||||
&& !value.slice(TRT_DEMO_SOURCE_PREFIX.length).includes("/");
|
||||
}
|
||||
|
||||
function summarizeSavedFiles(files) {
|
||||
return {
|
||||
fileCount: files.length,
|
||||
totalBytes: files.reduce((total, file) => total + file.bytes, 0),
|
||||
kinds: countKinds(files.map((file) => file.kind)),
|
||||
opfsPaths: files.map((file) => file.opfsPath),
|
||||
};
|
||||
}
|
||||
|
||||
function countKinds(kinds) {
|
||||
return kinds.reduce((counts, kind) => {
|
||||
counts[kind] = (counts[kind] || 0) + 1;
|
||||
return counts;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function classifySourceRel(sourceRel) {
|
||||
if (sourceRel.endsWith(".ini")) return "ini";
|
||||
if (sourceRel.endsWith(".tbl")) return "toolTable";
|
||||
if (sourceRel.endsWith(".hal")) return "hal";
|
||||
if (sourceRel.includes("/remap_subs/")) return "remap";
|
||||
if (sourceRel.includes("/demos/")) return "demo";
|
||||
if (sourceRel.endsWith(".xml")) return "pyvcp";
|
||||
if (sourceRel.endsWith(".var")) return "parameters";
|
||||
return "asset";
|
||||
}
|
||||
|
||||
function opfsPathFor(profileId, sourceRel) {
|
||||
return `${OPFS_ROOT}/${assertPathSegment(profileId)}/${String(sourceRel).replaceAll("\\", "/")}`;
|
||||
}
|
||||
|
||||
async function readTextFromCandidateUrls(urls) {
|
||||
const errors = [];
|
||||
for (const url of urls) {
|
||||
try {
|
||||
return await readTextFromUrl(url);
|
||||
} catch (error) {
|
||||
errors.push(`${url}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
throw new Error(`failed to read machine staging asset: ${errors.join(" | ")}`);
|
||||
}
|
||||
|
||||
async function readTextFromUrl(url) {
|
||||
if (isNodeRuntime()) {
|
||||
const [{ readFile }, { resolve }, { fileURLToPath }] = await Promise.all([
|
||||
import("node:fs/promises"),
|
||||
import("node:path"),
|
||||
import("node:url"),
|
||||
]);
|
||||
const path = String(url).startsWith("file:")
|
||||
? fileURLToPath(url)
|
||||
: resolve(process.cwd(), url);
|
||||
return readFile(path, "utf8");
|
||||
}
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`failed to fetch machine staging asset ${url}: ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
async function saveTextFile(path, text, storage = globalThis.navigator?.storage) {
|
||||
const root = await getStorageRoot(storage);
|
||||
const dir = await ensureParentDir(root, path);
|
||||
const filename = splitPath(path).at(-1);
|
||||
const fileHandle = await dir.getFileHandle(filename, { create: true });
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(text);
|
||||
await writable.close();
|
||||
}
|
||||
|
||||
async function getStorageRoot(storage) {
|
||||
if (!storage?.getDirectory) {
|
||||
throw new Error("OPFS is not available in this browser.");
|
||||
}
|
||||
return storage.getDirectory();
|
||||
}
|
||||
|
||||
async function ensureParentDir(root, path) {
|
||||
let current = root;
|
||||
for (const part of splitPath(path).slice(0, -1)) {
|
||||
current = await current.getDirectoryHandle(part, { create: true });
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function splitPath(path) {
|
||||
const parts = String(path || "").replaceAll("\\", "/").split("/").filter(Boolean);
|
||||
if (parts.length === 0 || parts.some((part) => part === "." || part === "..")) {
|
||||
throw new Error(`Invalid OPFS path: ${path}`);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
function sourceUrlsFor(sourceRel) {
|
||||
return DEFAULT_VENDOR_ROOT_URLS.map((rootUrl) => new URL(sourceRel, rootUrl).href);
|
||||
}
|
||||
|
||||
function basename(path) {
|
||||
return String(path).split("/").filter(Boolean).at(-1);
|
||||
}
|
||||
|
||||
function assertPathSegment(segment) {
|
||||
if (!/^[a-z0-9._-]+$/i.test(String(segment))) {
|
||||
throw new Error(`Invalid OPFS path segment: ${segment}`);
|
||||
}
|
||||
return segment;
|
||||
}
|
||||
|
||||
function isNodeRuntime() {
|
||||
return typeof process === "object"
|
||||
&& typeof process.versions === "object"
|
||||
&& typeof process.versions.node === "string"
|
||||
&& process.type !== "renderer";
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export function buildRtcpFrame({
|
||||
|
||||
const pose = normalizeAxisPose(axisPose);
|
||||
const toolLength = 84.019;
|
||||
const toolAxisVector = computeToolAxisVector(pose.a, pose.c);
|
||||
const toolAxisVector = computeToolAxisVector(pose, profile);
|
||||
const compensation = rtcpEnabled
|
||||
? {
|
||||
x: -toolAxisVector.x * toolLength,
|
||||
@@ -85,7 +85,7 @@ function buildLinuxCncKinematicsFrame({
|
||||
...wasmPose,
|
||||
});
|
||||
const jointValues = Array.isArray(inverse.joints) ? inverse.joints : [];
|
||||
const toolAxisVector = computeToolAxisVector(pose.a, pose.c);
|
||||
const toolAxisVector = computeToolAxisVector(pose, profile);
|
||||
|
||||
return {
|
||||
apiName: "web-rtcp-5axis-motion-frame",
|
||||
@@ -97,7 +97,7 @@ function buildLinuxCncKinematicsFrame({
|
||||
rtcpEnabled,
|
||||
rtcpState: rtcpEnabled ? "on" : "off",
|
||||
axisPose: pose,
|
||||
jointPose: buildJointPoseFromLinuxCncJoints(jointValues, pose),
|
||||
jointPose: buildJointPoseFromLinuxCncJoints(jointValues, pose, profile),
|
||||
tcpPose: {
|
||||
x: pose.x,
|
||||
y: pose.y,
|
||||
@@ -150,9 +150,9 @@ function buildJointPose(pose) {
|
||||
];
|
||||
}
|
||||
|
||||
function buildJointPoseFromLinuxCncJoints(joints, fallbackPose) {
|
||||
const axes = ["X", "Y", "Z", "A", "C"];
|
||||
const fallbackValues = [fallbackPose.x, fallbackPose.y, fallbackPose.z, fallbackPose.a, fallbackPose.c];
|
||||
function buildJointPoseFromLinuxCncJoints(joints, fallbackPose, profile = xyzacTrtProfile) {
|
||||
const axes = profile?.traj?.coordinates === "XYZBC" ? ["X", "Y", "Z", "B", "C"] : ["X", "Y", "Z", "A", "C"];
|
||||
const fallbackValues = axes.map((axis) => fallbackPose[axis.toLowerCase()] ?? 0);
|
||||
return axes.map((axis, joint) => ({
|
||||
joint,
|
||||
axis,
|
||||
@@ -171,18 +171,29 @@ function normalizeLinuxCncPose(pose = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function computeToolAxisVector(aDegrees, cDegrees) {
|
||||
const a = aDegrees * DEG_TO_RAD;
|
||||
function computeToolAxisVector(pose, profile = xyzacTrtProfile) {
|
||||
const coordinates = profile?.traj?.coordinates || "XYZAC";
|
||||
const tiltDegrees = coordinates.includes("B") ? pose.b : pose.a;
|
||||
const cDegrees = pose.c;
|
||||
const tilt = tiltDegrees * DEG_TO_RAD;
|
||||
const c = cDegrees * DEG_TO_RAD;
|
||||
const sinA = Math.sin(a);
|
||||
const cosA = Math.cos(a);
|
||||
const sinTilt = Math.sin(tilt);
|
||||
const cosTilt = Math.cos(tilt);
|
||||
const sinC = Math.sin(c);
|
||||
const cosC = Math.cos(c);
|
||||
|
||||
if (coordinates.includes("B")) {
|
||||
return normalizeVector({
|
||||
x: sinTilt * cosC,
|
||||
y: sinTilt * sinC,
|
||||
z: cosTilt,
|
||||
});
|
||||
}
|
||||
|
||||
return normalizeVector({
|
||||
x: sinA * sinC,
|
||||
y: -sinA * cosC,
|
||||
z: cosA,
|
||||
x: sinTilt * sinC,
|
||||
y: -sinTilt * cosC,
|
||||
z: cosTilt,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user