接入 LinuxCNC TP 运行反馈

This commit is contained in:
2026-06-21 23:29:56 +08:00
parent 626bcfe8e3
commit 3771b9eafe
44 changed files with 7881 additions and 326 deletions

View 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;
}

View 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.`);
}
}

View File

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

View File

@@ -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,

View 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,
}));
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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