提交 xyzbc-trt 界面与验证更新

This commit is contained in:
mes123456
2026-07-02 20:25:37 -04:00
parent 68ecd05353
commit 370c344b96
868 changed files with 275426 additions and 39640 deletions

View File

@@ -0,0 +1,219 @@
const DEFAULT_ALLOWED_USER_M_CODES = {
M428: {
code: "M428",
label: "TCP kinematics",
kinsType: "tcp",
switchkinsType: 1,
rtcpState: "on",
sourceRel: "configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/428remap.ngc",
},
M429: {
code: "M429",
label: "Identity kinematics",
kinsType: "identity",
switchkinsType: 0,
rtcpState: "off",
sourceRel: "configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/429remap.ngc",
},
M430: {
code: "M430",
label: "User kinematics",
kinsType: "userk",
switchkinsType: 2,
rtcpState: "on",
sourceRel: "configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/430remap.ngc",
},
M128: {
code: "M128",
label: "controlled millturn mill-mode user-M",
kinsType: "mill",
switchkinsType: 0,
rtcpState: "off",
sourceRel: "linuxcnc millturn user-M reference",
},
M129: {
code: "M129",
label: "controlled millturn turn-mode user-M",
kinsType: "turn",
switchkinsType: 1,
rtcpState: "off",
sourceRel: "linuxcnc millturn user-M reference",
},
};
export function createControlledUserMSimulation({ allowedCodes = DEFAULT_ALLOWED_USER_M_CODES } = {}) {
return {
apiName: "web-rtcp-5axis-controlled-user-m-simulation",
ready: true,
processReady: true,
processScope: "web_simulation_only",
hostProcessReady: false,
hostProcessExecution: false,
arbitraryUserMExecution: false,
allowedCodes: Object.fromEntries(
Object.entries(allowedCodes).map(([code, definition]) => [normalizeMCode(code), {
...definition,
code: normalizeMCode(definition.code || code),
}]),
),
events: [],
blockedEvents: [],
semanticBoundary: "controlled_user_m_web_simulation_whitelist_not_host_process",
linuxCncReferences: [
"linuxcnc/src/emc/rs274ngc",
"linuxcnc/src/emc/task/emctask.cc",
"linuxcnc/src/emc/task/emccanon.cc",
"linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/428remap.ngc",
"linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/429remap.ngc",
"linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/430remap.ngc",
],
};
}
export function runControlledUserM(simulation, code, context = {}) {
const state = cloneSimulation(simulation);
const normalizedCode = normalizeMCode(code);
const definition = state.allowedCodes[normalizedCode] || null;
if (!definition) {
const event = createUserMEvent({
code: normalizedCode,
allowed: false,
reason: "not_in_controlled_user_m_whitelist",
context,
});
state.blockedEvents.push(event);
return {
simulation: state,
event,
statePatch: {},
halPatch: {},
};
}
const profileKinsType = definition.kinsType === "tcp"
? tcpKinsTypeForProfile(context.profile)
: definition.kinsType;
const halPatch = {
"motion.switchkins-type": definition.switchkinsType,
"motion.analog-out-03": definition.switchkinsType,
};
const statePatch = {
kinsType: profileKinsType,
rtcpState: definition.rtcpState,
};
const event = createUserMEvent({
code: normalizedCode,
allowed: true,
definition,
context,
halPatch,
statePatch,
});
state.events.push(event);
return {
simulation: state,
event,
statePatch,
halPatch,
};
}
export function createControlledUserMReadiness(simulation) {
const ready = simulation?.processReady === true && simulation?.processScope === "web_simulation_only";
return {
apiName: "web-rtcp-5axis-controlled-user-m-readiness",
ready,
externalUserMProcessReady: ready,
externalUserMProcessScope: ready ? "web_simulation_only" : "not_ready",
hostExternalUserMProcessReady: false,
hostProcessExecution: false,
arbitraryUserMExecution: false,
allowedCodeCount: Object.keys(simulation?.allowedCodes || {}).length,
blockedEventCount: simulation?.blockedEvents?.length || 0,
semanticBoundary: "external_user_m_process_ready_for_web_simulation_only",
};
}
export function extractControlledUserMCodesFromProgram(programText = "") {
const matches = [];
const lines = String(programText).split(/\r?\n/);
lines.forEach((line, index) => {
const stripped = line.replace(/\([^)]*\)/g, " ");
for (const match of stripped.matchAll(/\bM\s*(\d{2,3})\b/gi)) {
const code = normalizeMCode(`M${match[1]}`);
if (Number(match[1]) >= 100 || ["M428", "M429", "M430"].includes(code)) {
matches.push({
code,
line: index + 1,
rawLine: line,
});
}
}
});
return matches;
}
export function runControlledUserMProgramScan(simulation, programText = "", context = {}) {
let state = simulation;
const events = [];
for (const occurrence of extractControlledUserMCodesFromProgram(programText)) {
const result = runControlledUserM(state, occurrence.code, {
...context,
line: occurrence.line,
rawLine: occurrence.rawLine,
});
state = result.simulation;
events.push(result.event);
}
return { simulation: state, events };
}
function createUserMEvent({
code,
allowed,
definition = null,
context = {},
halPatch = {},
statePatch = {},
reason = null,
}) {
return {
apiName: "web-rtcp-5axis-controlled-user-m-event",
code,
allowed,
reason,
label: definition?.label || null,
sourceRel: definition?.sourceRel || context.sourceRel || null,
line: context.line || null,
halPatch,
statePatch,
createdAt: new Date().toISOString(),
semanticBoundary: allowed
? "vendored_or_whitelisted_user_m_web_simulation_event"
: "arbitrary_external_user_m_blocked",
promotionScope: "web_simulation_only",
hostProcessExecution: false,
arbitraryUserMExecution: false,
};
}
function tcpKinsTypeForProfile(profile) {
if (profile?.id === "xyzbc-trt") return "tcp-xyzbc";
return "tcp-xyzac";
}
function normalizeMCode(code) {
const normalized = String(code || "").trim().toUpperCase().replace(/\s+/g, "");
const match = normalized.match(/^M0*(\d+)$/);
if (!match) return normalized;
return `M${Number(match[1])}`;
}
function cloneSimulation(simulation) {
return {
...simulation,
allowedCodes: { ...(simulation.allowedCodes || {}) },
events: [...(simulation.events || [])],
blockedEvents: [...(simulation.blockedEvents || [])],
};
}

View File

@@ -0,0 +1,221 @@
import {
linearUnitsToMillimetersFactor,
linearValueToMillimeters,
resolveStateLinearUnits,
} from "./linear-units.js";
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 linearUnits = resolveStateLinearUnits(profile);
const limits = buildVelocityLimits(profile, linearUnits);
const segments = [];
let previousAxes = null;
let previousLinearUnits = linearUnits;
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 eventLinearUnits = event.linearUnits || linearUnits;
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,
linearUnits: eventLinearUnits,
previousLinearUnits: previousAxes ? previousLinearUnits : eventLinearUnits,
});
elapsedSeconds += segment.durationSeconds;
segments.push({
...segment,
elapsedSeconds,
});
previousAxes = axes;
previousLinearUnits = eventLinearUnits;
}
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,
linearUnits,
};
}
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,
linearUnits,
previousLinearUnits,
}) {
const deltas = Object.fromEntries(ALL_AXES.map((axis) => [axis, axes[axis] - previousAxes[axis]]));
const linearDistanceMm = vectorLength(LINEAR_AXES.map((axis) => (
linearValueToMillimeters(axes[axis], linearUnits)
- linearValueToMillimeters(previousAxes[axis], previousLinearUnits || linearUnits)
)));
const angularDistanceDeg = vectorLength(ANGULAR_AXES.map((axis) => deltas[axis]));
const motionClass = event.type === "STRAIGHT_TRAVERSE" ? "rapid" : "feed";
const feedMode = event.feedMode === "inverse-time" ? "inverse-time" : "units-per-minute";
const requestedLinearVelocity = motionClass === "rapid"
? limits.maxLinearVelocityMmPerMin * percent(rapidOverride)
: feedMode === "inverse-time"
? inverseTimeVelocityMmPerMin(linearDistanceMm, angularDistanceDeg, feedRate)
: linearValueToMillimeters(Math.max(feedRate, 0), linearUnits) * 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)
: feedMode === "inverse-time"
? inverseTimeAngularVelocityDegPerMin(angularDistanceDeg, feedRate)
: Math.min(Math.max(feedRate, 0) * percent(feedOverride), limits.maxAngularVelocityDegPerMin);
const angularSeconds = angularDistanceDeg > 0
? angularDistanceDeg / Math.max(angularVelocityDegPerMin / 60, 0.000001)
: 0;
const inverseTimeSeconds = motionClass === "feed" && feedMode === "inverse-time" && feedRate > 0
? 60 / feedRate
: 0;
const durationSeconds = inverseTimeSeconds > 0
? inverseTimeSeconds
: Math.max(linearSeconds, angularSeconds);
return {
index,
line: event.line,
type: event.type,
motionClass,
feedMode,
linearDistanceMm,
angularDistanceDeg,
feedRate,
requestedVelocityMmPerMin: requestedLinearVelocity,
velocityMmPerMin: cappedLinearVelocity,
angularVelocityDegPerMin,
durationSeconds,
startSeconds: elapsedSeconds,
startAxes: previousAxes,
endAxes: axes,
axes,
deltas,
linearUnits,
};
}
function buildVelocityLimits(profile, linearUnits) {
const traj = profile?.traj || {};
const axisLimits = profile?.axisLimits || {};
const linearVelocityScale = linearUnitsToMillimetersFactor(linearUnits);
const maxLinearVelocity = firstFinite(
Number(traj.maxLinearVelocity) * linearVelocityScale * 60,
...LINEAR_AXES.map((axis) => Number(axisLimits[axis.toUpperCase()]?.maxVelocity) * linearVelocityScale * 60),
2100,
);
const defaultLinearVelocity = firstFinite(
Number(traj.defaultLinearVelocity) * linearVelocityScale * 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 inverseTimeVelocityMmPerMin(linearDistanceMm, angularDistanceDeg, feedRate) {
const durationSeconds = feedRate > 0 ? 60 / feedRate : 0;
if (linearDistanceMm > 0 && durationSeconds > 0) {
return (linearDistanceMm / durationSeconds) * 60;
}
if (angularDistanceDeg > 0 && durationSeconds > 0) {
return angularDistanceDeg / durationSeconds * 60;
}
return 0;
}
function inverseTimeAngularVelocityDegPerMin(angularDistanceDeg, feedRate) {
const durationSeconds = feedRate > 0 ? 60 / feedRate : 0;
return angularDistanceDeg > 0 && durationSeconds > 0
? angularDistanceDeg / durationSeconds * 60
: 0;
}
function firstFinite(...values) {
return values.find((value) => Number.isFinite(value) && value > 0) || 1;
}

View File

@@ -0,0 +1,356 @@
export const FIVE_AXIS_SESSION_FORMAT = "web-rtcp-5axis-session-snapshot";
export const FIVE_AXIS_SESSION_VERSION = 1;
export const DEFAULT_SESSION_ID = "xyzbc-trt-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-xyzbc-trt-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);
const storage = resolveSessionStorage(options);
await saveTextFile(path, `${JSON.stringify(snapshot, null, 2)}\n`, storage.storage);
return { snapshot, path, storageMode: storage.mode, storageCapability: storage.capability };
}
export async function loadFiveAxisSessionSnapshot(sessionId, options = {}) {
const path = sessionSnapshotPath(sessionId, options.filename);
const storage = resolveSessionStorage(options);
const text = await loadTextFile(path, storage.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,
storageMode: storage.mode,
storageCapability: storage.capability,
};
}
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 {
apiName: "web-rtcp-5axis-memory-session-storage",
files,
async getDirectory() {
return createDirectoryHandle(files, []);
},
};
}
let browserMemorySessionStorage = null;
export function detectBrowserStorageCapability(globalScope = globalThis) {
const forcedUnavailable = Boolean(globalScope.__WEB_RTCP_FORCE_OPFS_UNAVAILABLE__);
const isBrowser = typeof globalScope.window === "object" || typeof globalScope.document === "object";
const secureContext = !isBrowser || globalScope.isSecureContext !== false;
const hasOpfs = typeof globalScope.navigator?.storage?.getDirectory === "function";
const opfsAvailable = hasOpfs && secureContext && !forcedUnavailable;
return {
apiName: "web-rtcp-5axis-storage-capability",
opfsAvailable,
opfsUnavailable: !opfsAvailable,
secureContext,
forcedUnavailable,
hasNavigatorStorage: Boolean(globalScope.navigator?.storage),
hasGetDirectory: hasOpfs,
fallbackMode: opfsAvailable ? null : "memory-fallback",
reason: opfsAvailable
? "opfs_available"
: forcedUnavailable
? "forced_unavailable"
: !secureContext
? "non_secure_context"
: !hasOpfs
? "missing_opfs_get_directory"
: "opfs_unavailable",
};
}
function resolveSessionStorage(options = {}) {
if (options.storage) {
const mode = options.storageMode || storageModeFor(options.storage);
if (options.requireOpfs === true && mode !== "opfs") {
throw new Error(`OPFS storage required for session snapshots, got ${mode}`);
}
return {
storage: options.storage,
mode,
capability: {
apiName: "web-rtcp-5axis-storage-capability",
opfsAvailable: mode === "opfs",
opfsUnavailable: mode !== "opfs",
secureContext: true,
forcedUnavailable: false,
hasNavigatorStorage: false,
hasGetDirectory: typeof options.storage.getDirectory === "function",
fallbackMode: mode === "opfs" ? null : mode || "custom",
reason: "explicit_storage",
},
};
}
const capability = detectBrowserStorageCapability();
const browserStorage = globalThis.navigator?.storage;
if (capability.opfsAvailable) {
return {
storage: browserStorage,
mode: "opfs",
capability,
};
}
if (options.requireOpfs === true) {
throw new Error(`OPFS storage required for session snapshots: ${capability.reason}`);
}
browserMemorySessionStorage ??= createMemorySessionStorage();
return {
storage: browserMemorySessionStorage,
mode: "memory-fallback",
capability,
};
}
function storageModeFor(storage) {
return storage?.apiName === "web-rtcp-5axis-memory-session-storage"
? "memory"
: "custom";
}
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-xyzbc-trt-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,187 @@
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,
);
const remapRuntimeReady = Boolean(
machineFileExecution?.summary?.remapRuntimeReady === 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 taskHalSummary = state.taskHalStatus?.summary || {};
const taskRuntimeReady = Boolean(
taskHalSummary.taskRuntimeReady === true ||
state.taskHalRuntimeReadiness?.taskRuntimeReady === true,
);
const motionRuntimeReady = Boolean(
taskHalSummary.motionRuntimeReady === true ||
state.taskHalRuntimeReadiness?.motionRuntimeReady === true,
);
const halRuntimeReady = Boolean(
taskHalSummary.halRuntimeReady === true ||
state.taskHalRuntimeReadiness?.halRuntimeReady === true,
);
const halSyncReady = Boolean(
taskHalSummary.halSyncReady === true ||
state.taskHalRuntimeReadiness?.halSyncReady === true,
);
const taskHalComparisonReady = Boolean(taskHalSummary.taskHalComparisonReady === true);
const nativeTaskReady = taskRuntimeReady;
const nativeHalSyncReady = halRuntimeReady && motionRuntimeReady && halSyncReady;
const toolDbReadiness = state.toolDbReadiness || {};
const controlledUserMReadiness = state.controlledUserMReadiness || {};
const toolDbProcessReady = toolDbReadiness.toolDbProcessReady === true;
const externalUserMProcessReady = controlledUserMReadiness.externalUserMProcessReady === true;
const fullLinuxCncProgramExecutionReady = Boolean(
kinematicsReady &&
interpreterReady &&
canonicalProgramReady &&
machineFileStagingReady &&
machineFileRemapReady &&
plannerRuntimeReady &&
nativeTaskReady &&
nativeHalSyncReady &&
taskHalComparisonReady &&
toolDbProcessReady &&
externalUserMProcessReady
);
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,
taskRuntimeReady ? "linuxcnc-task-runtime" : null,
motionRuntimeReady ? "linuxcnc-motion-runtime" : null,
halRuntimeReady ? "linuxcnc-hal-runtime" : null,
halSyncReady ? "task-motion-hal-sync" : null,
taskHalComparisonReady ? "task-hal-cycle-artifact" : null,
toolDbProcessReady ? "tool-db-web-simulation" : null,
externalUserMProcessReady ? "controlled-user-m-web-simulation" : 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");
if (!taskRuntimeReady) missing.push("LinuxCNC task runtime");
if (!motionRuntimeReady) missing.push("LinuxCNC motion runtime");
if (!halRuntimeReady) missing.push("LinuxCNC HAL runtime");
if (!halSyncReady) missing.push("task/motion/HAL synchronization");
if (!taskHalComparisonReady) missing.push("task cycle and HAL servo cycle artifact");
if (!toolDbProcessReady) missing.push("tool DB Web/WASM simulation process");
if (!externalUserMProcessReady) missing.push("controlled user-M Web/WASM simulation process");
const blockers = [];
if (!nativeTaskReady) blockers.push("LinuxCNC task runtime is not promoted");
if (!nativeHalSyncReady) blockers.push("realtime HAL synchronization is not promoted");
if (!toolDbProcessReady) blockers.push("tool DB Web simulation process is not ready");
if (!externalUserMProcessReady) blockers.push("controlled user-M Web simulation process is not ready");
blockers.push("host external user-M process and host tool DB process remain disabled; Web simulation boundary only");
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: fullLinuxCncProgramExecutionReady
? "linuxcnc-task-motion-hal-simulation-runtime"
: machineFileRemapReady
? "partial-linuxcnc-remap-boundary"
: canonicalProgramReady
? "canonical-interpreter-boundary"
: "blocked",
semanticBoundary: fullLinuxCncProgramExecutionReady
? "linuxcnc_task_motion_hal_wasm_simulation_runtime"
: 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,
halSwitchkinsEvidenceReady,
plannerRuntimeReady,
taskRuntimeReady,
motionRuntimeReady,
halRuntimeReady,
halSyncReady,
taskHalComparisonReady,
nativeTaskReady,
nativeHalSyncReady,
fullLinuxCncProgramExecutionReady,
promotionAllowed: fullLinuxCncProgramExecutionReady,
hardwareDrive: false,
hostRealtimeKernel: false,
externalUserMProcessReady,
externalUserMProcessScope: externalUserMProcessReady ? "web_simulation_only" : "not_ready",
toolDbProcessReady,
toolDbProcessScope: toolDbProcessReady ? "web_simulation_only" : "not_ready",
hostExternalUserMProcessReady: false,
hostToolDbProcessReady: false,
arbitraryUserMExecution: 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,
remapRuntimeReady,
stagedFileCount: state.machineFileStaging?.fileCount || 0,
taskHal: taskHalSummary,
taskCycle: state.taskHalStatus?.ui?.taskCycle || 0,
servoCycle: state.taskHalStatus?.ui?.servoCycle || 0,
motionQueueDepth: state.taskHalStatus?.ui?.motionQueueDepth || 0,
halChangedPinCount: state.taskHalStatus?.ui?.halChangedPinCount || 0,
toolDb: toolDbReadiness,
controlledUserM: controlledUserMReadiness,
},
};
}

View File

@@ -0,0 +1,280 @@
export const gmoccapyCommunicationModel = {
apiName: "web-rtcp-5axis-gmoccapy-communication-model",
profileId: "gmoccapy-xyzab",
nativeConfigPath: "linuxcnc/configs/sim/gmoccapy/gmoccapy_XYZAB.ini",
cycleTimeMs: 100,
semanticBoundary: "native_gmoccapy_nml_hal_reference_web_store_runtime_mapping",
nativePaths: {
command: {
label: "NML command",
path: ["gmoccapy", "linuxcnc.command()", "NML emcCommand", "milltask"],
notes: "GUI commands are written to the NML command buffer and consumed by milltask.",
},
status: {
label: "NML status/error",
path: ["milltask", "NML emcStatus/emcError", "linuxcnc.stat()", "linuxcnc.error_channel()", "gmoccapy"],
notes: "gmoccapy polls status and error channels at DISPLAY CYCLE_TIME.",
},
hal: {
label: "HAL shared memory",
path: ["gmoccapy.* pins", "HAL shared memory", "halui/iocontrol/motmod/spindle_sim"],
notes: "postgui HAL connects gmoccapy pins after halcomp.ready().",
},
hardwareButtons: {
label: "HAL hardware buttons",
path: ["external HAL bit", "gmoccapy.h-button/v-button pin", "_button_pin_changed", "visible sensitive GTK button", "linuxcnc.command()"],
notes: "gmoccapy maps rising-edge hard-button pins to the current visible software button; insensitive or hidden targets are ignored.",
},
halPins: {
label: "HAL input pins",
path: ["external HAL value", "gmoccapy.* input pin", "hal_glib.GPin value_changed", "gmoccapy callback", "linuxcnc.command() or GTK widget state"],
notes: "jog pins, jog increment pins, settings unlock, ignore-limits, optional-stop/blockdelete, override counts/direct-value, reset and message pins enter gmoccapy through HAL callbacks; tool measurement pins are HAL_OUT diagnostics in XYZAB.",
},
remap: {
label: "Interpreter REMAP",
path: ["milltask", "RS274NGC", "Python remap prolog/ngc/epilog"],
notes: "M6/M61 remap executes on the task/interpreter side, not inside the GUI event loop.",
},
filePage: {
label: "Native file page",
path: ["btn_load", "_show_iconview_tab(True)", "IconFileSelection1", "hal_action_open.load_file(path)", "GStat file-loaded"],
notes: "gmoccapy uses Gtk/IconFileSelection and PROGRAM_PREFIX; the Web shell maps this to OPEN_FILE plus staged LinuxCNC G-code source diagnostics.",
},
macroPage: {
label: "Native macro buttons",
path: ["[MACROS]", "_make_macro_button", "_on_btn_macro_pressed", "command.mdi(O<name> call [args])", "interp idle/run sensitivity"],
notes: "The XYZAB config defines five macros; Web macro dispatch must use MDI gates instead of bypassing task state.",
},
toolEditorPage: {
label: "Native tool editor",
path: ["btn_tool", "_show_tooledit_tab(True)", "tooledit1.reload()", "tool.tbl", "M61 Q? or T? M6"],
notes: "Native gmoccapy can edit and save tool.tbl; Web keeps writeback diagnostic-only while preserving iocontrol-loopback tool-change semantics.",
},
nativePages: {
label: "Native GTK page matrix",
path: ["gmoccapy.glade notebooks", "tbtn_setup/user tabs/tool/touch/load-file", "button callback", "Web implementation matrix", "operator diagnostic"],
notes: "Unimplemented native GTK pages are reported as diagnostic-only or native-only and must not be presented as completed Web actions.",
},
},
webPath: {
label: "Web simulation",
path: ["button", "store.dispatch", "linuxcnc-task-policy", "runtime/status snapshot", "UI/Three.js"],
notes: "The browser does not attach to native NML buffers or HAL shared memory.",
},
startupStages: [
{ order: 1, id: "parse-ini", nativeStep: "linuxcnc script parses gmoccapy_XYZAB.ini", webEquivalent: "profile/source map selection" },
{ order: 2, id: "linuxcncsvr", nativeStep: "linuxcncsvr -ini <ini>", webEquivalent: "not connected; reference only" },
{ order: 3, id: "realtime-hal", nativeStep: "start realtime/RTAPI/HAL", webEquivalent: "task/HAL runtime simulation boundary where available" },
{ order: 4, id: "milltask", nativeStep: "halcmd loadusr -Wn inihal milltask -ini <ini>", webEquivalent: "store task policy and optional task/HAL runtime" },
{ order: 5, id: "halui", nativeStep: "halcmd loadusr -Wn halui halui -ini <ini>", webEquivalent: "HALUI pins listed as reference diagnostics" },
{ order: 6, id: "halfiles", nativeStep: "run core_sim_XYZAB.hal, spindle_sim.hal, simulated_home.hal", webEquivalent: "HAL model diagnostics" },
{ order: 7, id: "hal-start", nativeStep: "halcmd start", webEquivalent: "not a browser realtime thread" },
{ order: 8, id: "gmoccapy", nativeStep: "gmoccapy -ini <ini>", webEquivalent: "gmoccapy Web shell" },
{ order: 9, id: "postgui", nativeStep: "halcomp.ready(), then gmoccapy_postgui.hal", webEquivalent: "postgui model shown after gmoccapy pins exist" },
],
actionMappings: [
{
actionId: "estop",
webAction: "ESTOP",
nativeCommand: "command.state(STATE_ESTOP)",
nativeStatus: "task_state STATE_ESTOP",
gate: "always available as abortive safety action",
},
{
actionId: "estop-reset",
webAction: "RESET",
nativeCommand: "command.state(STATE_ESTOP_RESET)",
nativeStatus: "task_state STATE_ESTOP_RESET",
gate: "allowed from estop",
},
{
actionId: "power-on",
webAction: "TOGGLE_POWER",
nativeCommand: "command.state(STATE_ON)",
nativeStatus: "task_state STATE_ON",
gate: "blocked while STATE_ESTOP",
},
{
actionId: "mode-manual",
webAction: "SET_MODE manual",
nativeCommand: "command.mode(MODE_MANUAL)",
nativeStatus: "task_mode MODE_MANUAL",
gate: "blocked when AUTO interpreter is not idle",
},
{
actionId: "mode-mdi",
webAction: "SET_MODE mdi",
nativeCommand: "command.mode(MODE_MDI)",
nativeStatus: "task_mode MODE_MDI",
gate: "machine on and homed when NO_FORCE_HOMING=0",
},
{
actionId: "mode-auto",
webAction: "SET_MODE auto",
nativeCommand: "command.mode(MODE_AUTO)",
nativeStatus: "task_mode MODE_AUTO",
gate: "machine on and homed when NO_FORCE_HOMING=0",
},
{
actionId: "jog",
webAction: "JOG",
nativeCommand: "command.jog(JOG_CONTINUOUS/JOG_INCREMENT/JOG_STOP)",
nativeStatus: "motion/joint feedback",
gate: "machine on, manual mode, not running",
},
{
actionId: "home",
webAction: "HOME",
nativeCommand: "command.home(-1) or command.home(joint)",
nativeStatus: "all-homed",
gate: "machine on, manual mode, no conflicting homing command",
},
{
actionId: "mdi",
webAction: "RUN_MDI",
nativeCommand: "command.mdi(command)",
nativeStatus: "interp reading/idle",
gate: "machine on, MDI mode, homed when NO_FORCE_HOMING=0",
},
{
actionId: "auto-run",
webAction: "RUN",
nativeCommand: "command.auto(AUTO_RUN, start_line)",
nativeStatus: "interp reading/waiting/idle",
gate: "INI loaded, program loaded, machine on, homed, AUTO, interpreter idle, runtime ready",
},
{
actionId: "auto-pause",
webAction: "PAUSE",
nativeCommand: "command.auto(AUTO_PAUSE)",
nativeStatus: "interp paused",
gate: "AUTO or MDI with machine on",
},
{
actionId: "auto-resume",
webAction: "RESUME",
nativeCommand: "command.auto(AUTO_RESUME)",
nativeStatus: "interp reading",
gate: "interpreter paused",
},
{
actionId: "abort",
webAction: "STOP/ABORT",
nativeCommand: "command.abort()",
nativeStatus: "interp idle after abort",
gate: "available across most states",
},
{
actionId: "spindle",
webAction: "spindle controls",
nativeCommand: "command.spindle(direction, rpm)",
nativeStatus: "spindle.0.forward/reverse/speed-out/speed-in",
gate: "blocked in estop/off and restricted during interpreter reading/waiting",
},
{
actionId: "coolant",
webAction: "TOGGLE_COOLANT",
nativeCommand: "command.flood()/command.mist()",
nativeStatus: "iocontrol.0.coolant-flood/mist",
gate: "blocked in estop/off",
},
{
actionId: "hardware-button",
webAction: "explicit data-gmoccapy-hal-pin button mapping",
nativeCommand: "_button_pin_changed -> GtkButton emit clicked/pressed/toggled",
nativeStatus: "same status as the target software button",
gate: "rising-edge pin ignored when target button is hidden or insensitive",
},
{
actionId: "hal-pin-input",
webAction: "GMOCAPY_HAL_PIN",
nativeCommand: "_ignore_limits/_optional_blocks/_blockdelete/_on_counts_changed/_on_analog_value_changed/_reset_override/_del_message_changed",
nativeStatus: "ignore-limits, block delete, optional stop, feed/rapid/spindle/jog override widget state and operator message state",
gate: "counts require count-enable, direct-value requires analog-enable, reset/delete-message pins are rising-edge only",
},
{
actionId: "hal-settings-unlock",
webAction: "GMOCAPY_HAL_PIN unlock-settings",
nativeCommand: "_on_unlock_settings_changed",
nativeStatus: "tbtn_setup sensitivity when rbt_hal_unlock is active",
gate: "level-driven pin only gates setup access when unlock_way/rbt_hal_unlock selects HAL unlock",
},
{
actionId: "hal-jog-pin",
webAction: "GMOCAPY_HAL_PIN jog.axis/jog-inc",
nativeCommand: "_on_pin_jog_changed/_on_pin_incr_changed",
nativeStatus: "manual jog command, active jog increment and gmoccapy.jog.jog-increment HAL OUT",
gate: "axis pins use press/release level, task must be on and manual; increment pins are rising-edge only",
},
{
actionId: "hal-tool-measurement",
webAction: "GMOCAPY_HAL_PIN tool measurement diagnostics",
nativeCommand: "_check_toolmeasurement/on_chk_use_tool_measurement_toggled/on_btn_block_height_clicked",
nativeStatus: "probeheight, blockheight, toolmeasurement, searchvel and probevel HAL_OUT pins",
gate: "XYZAB has no [TOOLSENSOR], so auto tool measurement controls are disabled and Web records these pins as diagnostics",
},
{
actionId: "hal-user-message",
webAction: "GMOCAPY_HAL_PIN messages.* diagnostics",
nativeCommand: "_init_user_messages/_show_user_message",
nativeStatus: "dynamic messages.<pinname>, waiting and response pins when MESSAGE_* entries exist",
gate: "gmoccapy_XYZAB.ini defines no MESSAGE_* entries, so no dynamic user message pins are created",
},
{
actionId: "hal-warning-confirm",
webAction: "GMOCAPY_HAL_PIN warning-confirm",
nativeCommand: "dialogs.warning_dialog confirm_pin polling",
nativeStatus: "active warning dialog response",
gate: "level-polled while warning dialog is active",
},
{
actionId: "file-page",
webAction: "LOAD_PROGRAM / LOAD_LINUXCNC_GCODE_SOURCE / GMOCAPY_PAGE_ACTION file-load",
nativeCommand: "on_btn_load_clicked -> IconFileSelection1 -> hal_action_open.load_file",
nativeStatus: "file-loaded signal updates gmoccapy.program.length/current-line/progress",
gate: "blocked while interpreter is running; native GTK file chooser is represented as Web file/staged-source diagnostics",
},
{
actionId: "macro-page",
webAction: "GMOCAPY_RUN_MACRO",
nativeCommand: "_on_btn_macro_pressed -> command.mdi(O<macro> call [args])",
nativeStatus: "macro buttons disabled during interpreter run and re-enabled at interp idle",
gate: "same as RUN_MDI: machine on, MDI-capable, homed when NO_FORCE_HOMING=0, interpreter idle",
},
{
actionId: "tool-editor-page",
webAction: "GMOCAPY_TOOL_EDITOR_ACTION",
nativeCommand: "_init_tooleditor/_show_tooledit_tab/on_btn_selected_tool_clicked",
nativeStatus: "tool.tbl rows, selected tool, active tool, tool offsets and M6/M61 commands",
gate: "Web writeback is disabled; tool-change actions remain diagnostic and preserve iocontrol-loopback",
},
{
actionId: "native-page-diagnostic",
webAction: "GMOCAPY_NATIVE_PAGE",
nativeCommand: "tbtn_setup/tbtn_user_tabs/btn_touch/btn_tool/tbtn_switch_mode native page callbacks",
nativeStatus: "implementation matrix and operator diagnostic message",
gate: "diagnostic-only pages do not emit fake GTK page transitions or native file/tool writes",
},
],
};
export function createGmoccapyCommunicationSummary(model = gmoccapyCommunicationModel) {
return {
apiName: `${model.apiName}-summary`,
profileId: model.profileId,
nativeConfigPath: model.nativeConfigPath,
nativePathCount: Object.keys(model.nativePaths).length,
startupStageCount: model.startupStages.length,
actionCount: model.actionMappings.length,
pagePathCount: ["filePage", "macroPage", "toolEditorPage", "nativePages"]
.filter((key) => Boolean(model.nativePaths[key])).length,
postguiAfterHalcompReady: model.startupStages.findIndex((stage) => stage.id === "postgui") >
model.startupStages.findIndex((stage) => stage.id === "gmoccapy"),
hasNativeNmlCommandPath: model.nativePaths.command.path.includes("NML emcCommand"),
hasNativeHalPath: model.nativePaths.hal.path.includes("HAL shared memory"),
hasNativeHardwareButtonPath: model.nativePaths.hardwareButtons.path.includes("_button_pin_changed"),
hasNativeHalPinInputPath: model.nativePaths.halPins.path.includes("hal_glib.GPin value_changed"),
hasWebStorePath: model.webPath.path.includes("store.dispatch"),
semanticBoundary: model.semanticBoundary,
};
}

View File

@@ -0,0 +1,888 @@
export const gmoccapyHalModel = {
apiName: "web-rtcp-5axis-gmoccapy-hal-model",
profileId: "gmoccapy-xyzab",
nativeConfigPath: "linuxcnc/configs/sim/gmoccapy/gmoccapy_XYZAB.ini",
halFiles: [
{
path: "linuxcnc/configs/sim/gmoccapy/core_sim_XYZAB.hal",
stage: "HALFILE",
role: "trivkins and motmod simulation loopback",
semanticBoundary: "native_hal_reference",
},
{
path: "linuxcnc/configs/sim/gmoccapy/spindle_sim.hal",
stage: "HALFILE",
role: "spindle speed encoder and at-speed simulation",
semanticBoundary: "native_hal_reference",
},
{
path: "linuxcnc/configs/sim/gmoccapy/simulated_home.hal",
stage: "HALFILE",
role: "simulated X/Y/Z home switches",
semanticBoundary: "native_hal_reference",
},
{
path: "linuxcnc/configs/sim/gmoccapy/gmoccapy_postgui.hal",
stage: "POSTGUI_HALFILE",
role: "connections that require gmoccapy.* pins",
requiresHalcompReady: true,
semanticBoundary: "native_postgui_hal_reference",
},
],
nativePins: [
{
group: "hard-buttons",
pins: [
"gmoccapy.h-button.button-0",
"gmoccapy.h-button.button-1",
"gmoccapy.h-button.button-2",
"gmoccapy.h-button.button-3",
"gmoccapy.h-button.button-4",
"gmoccapy.h-button.button-5",
"gmoccapy.h-button.button-6",
"gmoccapy.h-button.button-7",
"gmoccapy.h-button.button-8",
"gmoccapy.h-button.button-9",
"gmoccapy.v-button.button-0",
"gmoccapy.v-button.button-1",
"gmoccapy.v-button.button-2",
"gmoccapy.v-button.button-3",
"gmoccapy.v-button.button-4",
"gmoccapy.v-button.button-5",
"gmoccapy.v-button.button-6",
],
webSource: "current visible Web button set",
semanticBoundary: "native_hardware_button_pin_reference",
},
{
group: "jog",
pins: [
"gmoccapy.jog.axis.jog-x-plus",
"gmoccapy.jog.axis.jog-x-minus",
"gmoccapy.jog.axis.jog-y-plus",
"gmoccapy.jog.axis.jog-y-minus",
"gmoccapy.jog.axis.jog-z-plus",
"gmoccapy.jog.axis.jog-z-minus",
"gmoccapy.jog.axis.jog-a-plus",
"gmoccapy.jog.axis.jog-a-minus",
"gmoccapy.jog.axis.jog-b-plus",
"gmoccapy.jog.axis.jog-b-minus",
"gmoccapy.jog.jog-inc-0",
"gmoccapy.jog.jog-inc-1",
"gmoccapy.jog.jog-inc-2",
"gmoccapy.jog.jog-inc-3",
"gmoccapy.jog.jog-inc-4",
"gmoccapy.jog.jog-inc-5",
"gmoccapy.jog.jog-increment",
"gmoccapy.jog.turtle-jog",
],
webSource: "JOG dispatch and machine.jogIncrement",
semanticBoundary: "native_jog_pin_reference_web_task_policy_gate",
},
{
group: "override",
pins: [
"gmoccapy.feed.feed-override.counts",
"gmoccapy.feed.feed-override.count-enable",
"gmoccapy.feed.feed-override.analog-enable",
"gmoccapy.feed.feed-override.direct-value",
"gmoccapy.feed.reset-feed-override",
"gmoccapy.spindle.spindle-override.counts",
"gmoccapy.spindle.spindle-override.count-enable",
"gmoccapy.spindle.spindle-override.analog-enable",
"gmoccapy.spindle.spindle-override.direct-value",
"gmoccapy.spindle.reset-spindle-override",
"gmoccapy.jog.jog-velocity.counts",
"gmoccapy.jog.jog-velocity.count-enable",
"gmoccapy.jog.jog-velocity.analog-enable",
"gmoccapy.jog.jog-velocity.direct-value",
"gmoccapy.rapid.rapid-override.counts",
"gmoccapy.rapid.rapid-override.count-enable",
"gmoccapy.rapid.rapid-override.analog-enable",
"gmoccapy.rapid.rapid-override.direct-value",
"gmoccapy.rapid.reset-rapid-override",
],
webSource: "feed/spindle override state",
semanticBoundary: "native_override_pin_reference_web_state_mapping",
},
{
group: "operator-inputs",
pins: [
"gmoccapy.ignore-limits",
"gmoccapy.optional-stop",
"gmoccapy.blockdelete",
],
webSource: "gmoccapyGui ignoreLimits, optionalBlocks, optionalStop",
semanticBoundary: "native_ignore_limits_optional_stop_blockdelete_pin_reference_web_state_mapping",
},
{
group: "settings",
pins: [
"gmoccapy.unlock-settings",
],
webSource: "gmoccapyGui settingsUnlockPin and setupSensitive",
semanticBoundary: "native_settings_unlock_pin_reference_web_diagnostic_state",
},
{
group: "tool",
pins: [
"gmoccapy.tooloffset-x",
"gmoccapy.tooloffset-z",
"gmoccapy.tool-diameter",
"gmoccapy.probeheight",
"gmoccapy.blockheight",
"gmoccapy.toolmeasurement",
"gmoccapy.searchvel",
"gmoccapy.probevel",
"gmoccapy.toolchange-change",
"gmoccapy.toolchange-changed",
"gmoccapy.toolchange-number",
"gmoccapy.toolchange-confirm",
],
webSource: "toolPreview, disconnected manual tool-change diagnostics, and disabled XYZAB tool measurement outputs",
semanticBoundary: "native_tool_pin_reference_simulated_tool_loop",
},
{
group: "program",
pins: [
"gmoccapy.program.length",
"gmoccapy.program.current-line",
"gmoccapy.program.progress",
],
webSource: "programLines, activeLine, programRuntimeFeedback",
semanticBoundary: "native_program_pin_reference_web_execution_status",
},
{
group: "message",
pins: [
"gmoccapy.error",
"gmoccapy.delete-message",
"gmoccapy.warning-confirm",
],
webSource: "operatorMessage and browser diagnostics",
semanticBoundary: "native_message_pin_reference_web_operator_message",
},
{
group: "spindle-postgui",
pins: [
"gmoccapy.spindle_feedback_bar",
"gmoccapy.spindle_at_speed_led",
],
webSource: "spindle rpm/status panel",
semanticBoundary: "postgui_spindle_feedback_reference",
},
],
coreNets: [
{
signal: "Xpos",
source: "joint.0.motor-pos-cmd",
target: "joint.0.motor-pos-fb",
role: "X command-to-feedback simulation loop",
},
{
signal: "Ypos",
source: "joint.1.motor-pos-cmd",
target: "joint.1.motor-pos-fb",
role: "Y command-to-feedback simulation loop",
},
{
signal: "Zpos",
source: "joint.2.motor-pos-cmd",
target: "joint.2.motor-pos-fb",
role: "Z command-to-feedback simulation loop",
},
{
signal: "Apos",
source: "joint.3.motor-pos-cmd",
target: "joint.3.motor-pos-fb",
role: "A command-to-feedback simulation loop",
},
{
signal: "Bpos",
source: "joint.4.motor-pos-cmd",
target: "joint.4.motor-pos-fb",
role: "B command-to-feedback simulation loop",
},
{
signal: "estop-loop",
source: "iocontrol.0.user-enable-out",
target: "iocontrol.0.emc-enable-in",
role: "simulated estop enable loop",
},
{
signal: "tool-prep-loop",
source: "iocontrol.0.tool-prepare",
target: "iocontrol.0.tool-prepared",
role: "simulated tool prepare completion",
},
{
signal: "tool-change-loop",
source: "iocontrol.0.tool-change",
target: "iocontrol.0.tool-changed",
role: "simulated automatic tool-change completion",
},
{
signal: "flood",
source: "iocontrol.0.coolant-flood",
target: "gmoccapy coolant display via status",
role: "coolant flood state",
},
{
signal: "mist",
source: "iocontrol.0.coolant-mist",
target: "gmoccapy coolant display via status",
role: "coolant mist state",
},
],
spindleNets: [
{
signal: "spindle-speed-cmd",
source: "spindle.0.speed-out",
target: "limit_speed.in",
role: "commanded spindle speed",
},
{
signal: "spindle-speed-limited",
source: "limit_speed.out",
target: "sim_encoder_0.speed",
role: "simulated spindle inertia",
},
{
signal: "spindle-pos",
source: "encoder_0.position",
target: "spindle.0.revs",
role: "encoder feedback position",
},
{
signal: "spindle-at-speed",
source: "near_speed.out",
target: "spindle.0.at-speed",
role: "at-speed comparator result",
},
],
postguiNets: [
{
signal: "spindle-abs",
source: "abs_spindle_feedback.out",
target: "gmoccapy.spindle_feedback_bar",
requiresHalcompReady: true,
role: "spindle feedback bar",
},
{
signal: "spindle-at-speed",
source: "spindle.0.at-speed",
target: "gmoccapy.spindle_at_speed_led",
requiresHalcompReady: true,
role: "spindle at-speed LED",
},
{
signal: "tooloffset-x",
source: "motion.tooloffset.x",
target: "gmoccapy.tooloffset-x",
requiresHalcompReady: true,
role: "tool X offset display",
},
{
signal: "tooloffset-z",
source: "motion.tooloffset.z",
target: "gmoccapy.tooloffset-z",
requiresHalcompReady: true,
role: "tool Z offset display",
},
{
signal: "tool-change-loop",
source: "iocontrol.0.tool-change",
target: "iocontrol.0.tool-changed",
requiresHalcompReady: true,
simulationLoop: true,
role: "automatic simulated tool-change completion",
},
],
hardwareButtons: {
source: {
path: "linuxcnc/src/emc/usr_intf/gmoccapy/gmoccapy.py",
methods: ["_button_pin_changed", "_get_child_button", "_make_hal_pins"],
},
behavior: {
edge: "rising-edge-only",
hButtonContainer: "current ntb_button page visible children",
vButtonContainer: "vbtb_main visible children",
insensitiveTarget: "ignored",
hiddenLabelsSkipped: true,
semanticBoundary: "native_gmoccapy_hardware_button_pin_to_visible_sensitive_button",
},
verticalMain: [
{
index: 0,
pin: "gmoccapy.v-button.button-0",
nativeWidget: "tbtn_estop",
webAction: "ESTOP",
webDispatch: { type: "ESTOP" },
webSelector: '[data-action="estop"]',
gate: "always clickable safety action",
},
{
index: 1,
pin: "gmoccapy.v-button.button-1",
nativeWidget: "tbtn_on",
webAction: "TOGGLE_POWER",
webDispatch: { type: "TOGGLE_POWER" },
webSelector: '[data-action="power"]',
gate: "blocked while STATE_ESTOP",
},
{
index: 2,
pin: "gmoccapy.v-button.button-2",
nativeWidget: "rbt_manual",
webAction: "SET_MODE manual",
webDispatch: { type: "SET_MODE", mode: "manual" },
webSelector: '[data-action="mode-manual"]',
gate: "machine on and AUTO interpreter idle",
},
{
index: 3,
pin: "gmoccapy.v-button.button-3",
nativeWidget: "rbt_mdi",
webAction: "SET_MODE mdi",
webDispatch: { type: "SET_MODE", mode: "mdi" },
webSelector: '[data-action="mode-mdi"]',
gate: "machine on and homed when NO_FORCE_HOMING=0",
},
{
index: 4,
pin: "gmoccapy.v-button.button-4",
nativeWidget: "rbt_auto",
webAction: "SET_MODE auto",
webDispatch: { type: "SET_MODE", mode: "auto" },
webSelector: '[data-action="mode-auto"]',
gate: "machine on and homed when NO_FORCE_HOMING=0",
},
{
index: 5,
pin: "gmoccapy.v-button.button-5",
nativeWidget: "tbtn_user_tabs",
webAction: "GMOCAPY_NATIVE_PAGE user-tabs",
webDispatch: { type: "GMOCAPY_NATIVE_PAGE", pageId: "user-tabs", nativeWidget: "tbtn_user_tabs" },
webSelector: null,
gate: "native user tabs page is diagnostic-only in the Web shell",
},
{
index: 6,
pin: "gmoccapy.v-button.button-6",
nativeWidget: "tbtn_setup",
webAction: "GMOCAPY_NATIVE_PAGE setup",
webDispatch: { type: "GMOCAPY_NATIVE_PAGE", pageId: "setup", nativeWidget: "tbtn_setup" },
webSelector: null,
gate: "native setup page is diagnostic-only in the Web shell",
},
],
bottomMain: [
{
index: 0,
pin: "gmoccapy.h-button.button-0",
nativeWidget: "btn_homing",
webAction: "HOME",
webDispatch: { type: "HOME" },
webSelector: '[data-action="HOME"]',
gate: "machine on and manual mode",
},
{
index: 1,
pin: "gmoccapy.h-button.button-1",
nativeWidget: "btn_touch",
webAction: "GMOCAPY_NATIVE_PAGE touch-off",
webDispatch: { type: "GMOCAPY_NATIVE_PAGE", pageId: "touch-off", nativeWidget: "btn_touch" },
webSelector: null,
gate: "touch-off page not implemented in Web shell",
},
{
index: 3,
pin: "gmoccapy.h-button.button-3",
nativeWidget: "btn_tool",
webAction: "GMOCAPY_NATIVE_PAGE tool-editor",
webDispatch: { type: "GMOCAPY_NATIVE_PAGE", pageId: "tool-editor", nativeWidget: "btn_tool" },
webSelector: null,
gate: "tool editor page not implemented in Web shell",
},
{
index: 6,
pin: "gmoccapy.h-button.button-6",
nativeWidget: "tbtn_switch_mode",
webAction: "GMOCAPY_NATIVE_PAGE switchkins-mode",
webDispatch: { type: "GMOCAPY_NATIVE_PAGE", pageId: "switchkins-mode", nativeWidget: "tbtn_switch_mode" },
webSelector: null,
gate: "native joint/world switch page position is diagnostic-only in the Web shell",
},
],
},
halPinActions: {
source: {
path: "linuxcnc/src/emc/usr_intf/gmoccapy/gmoccapy.py",
methods: [
"_ignore_limits",
"_optional_blocks",
"_blockdelete",
"_on_pin_jog_changed",
"_on_pin_incr_changed",
"_on_counts_changed",
"_on_analog_enable_changed",
"_on_analog_value_changed",
"_reset_override",
"_del_message_changed",
"_on_unlock_settings_changed",
"_check_toolmeasurement",
"on_chk_use_tool_measurement_toggled",
"on_btn_block_height_clicked",
"_on_blockheight_value_changed",
"_init_user_messages",
"_show_user_message",
],
prefPath: "linuxcnc/configs/sim/gmoccapy/gmoccapy_XYZAB.pref",
},
jogPins: {
axes: [
{ axis: "x", plus: "gmoccapy.jog.axis.jog-x-plus", minus: "gmoccapy.jog.axis.jog-x-minus" },
{ axis: "y", plus: "gmoccapy.jog.axis.jog-y-plus", minus: "gmoccapy.jog.axis.jog-y-minus" },
{ axis: "z", plus: "gmoccapy.jog.axis.jog-z-plus", minus: "gmoccapy.jog.axis.jog-z-minus" },
{ axis: "a", plus: "gmoccapy.jog.axis.jog-a-plus", minus: "gmoccapy.jog.axis.jog-a-minus" },
{ axis: "b", plus: "gmoccapy.jog.axis.jog-b-plus", minus: "gmoccapy.jog.axis.jog-b-minus" },
],
increments: [
{ index: 0, pin: "gmoccapy.jog.jog-inc-0", label: "Continuous", distance: 0 },
{ index: 1, pin: "gmoccapy.jog.jog-inc-1", label: "1.000 mm", distance: 1 },
{ index: 2, pin: "gmoccapy.jog.jog-inc-2", label: "0.100 mm", distance: 0.1 },
{ index: 3, pin: "gmoccapy.jog.jog-inc-3", label: "0.010 mm", distance: 0.01 },
{ index: 4, pin: "gmoccapy.jog.jog-inc-4", label: "0.001 mm", distance: 0.001 },
{ index: 5, pin: "gmoccapy.jog.jog-inc-5", label: "1.2345 in", distance: 31.3563 },
],
outputPin: "gmoccapy.jog.jog-increment",
turtlePin: "gmoccapy.jog.turtle-jog",
nativeCallbacks: {
axis: "_on_pin_jog_changed -> _on_btn_jog_pressed/_on_btn_jog_released",
increment: "_on_pin_incr_changed -> _jog_increment_changed",
turtle: "_on_pin_turtle_jog -> tbtn_turtle_jog.set_active",
},
},
operatorPins: [
{
pin: "gmoccapy.ignore-limits",
nativeCallback: "_ignore_limits -> chk_ignore_limits.set_active",
nativeCommand: "on_chk_ignore_limits_toggled -> command.override_limits() when active",
webDispatch: { type: "GMOCAPY_HAL_PIN", pin: "gmoccapy.ignore-limits" },
webState: "gmoccapyGui.ignoreLimits",
},
{
pin: "gmoccapy.optional-stop",
nativeCallback: "_optional_blocks",
nativeCommand: "tbtn_optional_blocks -> command.set_block_delete(state)",
webDispatch: { type: "GMOCAPY_HAL_PIN", pin: "gmoccapy.optional-stop" },
webState: "gmoccapyGui.optionalBlocks",
},
{
pin: "gmoccapy.blockdelete",
nativeCallback: "_blockdelete",
nativeCommand: "command.set_optional_stop(state)",
webDispatch: { type: "GMOCAPY_HAL_PIN", pin: "gmoccapy.blockdelete" },
webState: "gmoccapyGui.optionalStop",
},
],
settingsPins: [
{
pin: "gmoccapy.unlock-settings",
nativeCallback: "_on_unlock_settings_changed",
nativeCommand: "rbt_hal_unlock active gates tbtn_setup sensitivity from pin level",
prefDefault: "unlock_way=use",
webDispatch: { type: "GMOCAPY_HAL_PIN", pin: "gmoccapy.unlock-settings" },
webState: "gmoccapyGui.settingsUnlockPin",
},
],
overridePins: [
{
target: "feed",
counts: "gmoccapy.feed.feed-override.counts",
countEnable: "gmoccapy.feed.feed-override.count-enable",
analogEnable: "gmoccapy.feed.feed-override.analog-enable",
directValue: "gmoccapy.feed.feed-override.direct-value",
reset: "gmoccapy.feed.reset-feed-override",
nativeWidget: "spc_feed",
scalePref: "scale_feed_override=1",
webState: "feed.feedOverride",
},
{
target: "rapid",
counts: "gmoccapy.rapid.rapid-override.counts",
countEnable: "gmoccapy.rapid.rapid-override.count-enable",
analogEnable: "gmoccapy.rapid.rapid-override.analog-enable",
directValue: "gmoccapy.rapid.rapid-override.direct-value",
reset: "gmoccapy.rapid.reset-rapid-override",
nativeWidget: "spc_rapid",
scalePref: "scale_rapid_override=1",
webState: "feed.rapidOverride",
},
{
target: "spindle",
counts: "gmoccapy.spindle.spindle-override.counts",
countEnable: "gmoccapy.spindle.spindle-override.count-enable",
analogEnable: "gmoccapy.spindle.spindle-override.analog-enable",
directValue: "gmoccapy.spindle.spindle-override.direct-value",
reset: "gmoccapy.spindle.reset-spindle-override",
nativeWidget: "spc_spindle",
scalePref: "scale_spindle_override=1",
webState: "spindle.override",
},
{
target: "jogVelocity",
counts: "gmoccapy.jog.jog-velocity.counts",
countEnable: "gmoccapy.jog.jog-velocity.count-enable",
analogEnable: "gmoccapy.jog.jog-velocity.analog-enable",
directValue: "gmoccapy.jog.jog-velocity.direct-value",
reset: null,
nativeWidget: "spc_lin_jog_vel",
scalePref: "scale_jog_vel=140.4",
webState: "gmoccapyGui.jogVelocity",
},
],
messagePins: [
{
pin: "gmoccapy.delete-message",
nativeCallback: "_del_message_changed",
nativeCommand: "pin true deletes first alert message when gmoccapy.error is set, otherwise del_last()",
webState: "operatorMessage",
},
{
pin: "gmoccapy.warning-confirm",
nativeCallback: "dialogs.warning_dialog periodic confirm_pin poll",
nativeCommand: "pin true responds OK to active warning dialog",
webState: "gmoccapyGui.warningConfirm",
},
{
pin: "gmoccapy.error",
nativeCallback: "_show_error/_on_message_deleted",
nativeCommand: "HAL OUT bit tracks alert messages",
webState: "gmoccapyGui.error",
},
],
toolMeasurementPins: {
toolsensorConfigured: false,
useToolMeasurementPref: false,
prefDefaults: {
useToolMeasurement: "use_toolmeasurement=False",
blockHeight: "blockheight=0.0",
},
disabledReason: "gmoccapy_XYZAB.ini has no [TOOLSENSOR] data, so _check_toolmeasurement disables the controls",
pins: [
{
pin: "gmoccapy.probeheight",
direction: "HAL_OUT",
nativeCallback: "on_spbtn_probe_height_value_changed / on_chk_use_tool_measurement_toggled / on_btn_block_height_clicked",
webState: "gmoccapyGui.probeHeight",
},
{
pin: "gmoccapy.blockheight",
direction: "HAL_OUT",
nativeCallback: "on_btn_block_height_clicked / _on_blockheight_value_changed",
webState: "gmoccapyGui.blockHeight",
},
{
pin: "gmoccapy.toolmeasurement",
direction: "HAL_OUT",
nativeCallback: "on_chk_use_tool_measurement_toggled",
webState: "gmoccapyGui.toolMeasurement",
},
{
pin: "gmoccapy.searchvel",
direction: "HAL_OUT",
nativeCallback: "on_spbtn_search_vel_value_changed / on_chk_use_tool_measurement_toggled",
webState: "gmoccapyGui.searchVelocity",
},
{
pin: "gmoccapy.probevel",
direction: "HAL_OUT",
nativeCallback: "on_spbtn_probe_vel_value_changed / on_chk_use_tool_measurement_toggled",
webState: "gmoccapyGui.probeVelocity",
},
],
},
userMessages: {
configured: false,
configuredMessageCount: 0,
pins: [],
source: "get_ini_info.get_user_messages()",
disabledReason: "gmoccapy_XYZAB.ini has no MESSAGE_* entries, so _init_user_messages() returns without creating messages.* pins",
dynamicPinPatterns: [
"gmoccapy.messages.<pinname>",
"gmoccapy.messages.<pinname>-waiting",
"gmoccapy.messages.<pinname>-response",
],
supportedTypes: ["status", "okdialog", "yesnodialog"],
},
behavior: {
optionalStopPinCrossesToBlockDelete: true,
blockdeletePinCrossesToOptionalStop: true,
unlockSettingsLevelDriven: true,
jogAxisPinsPressRelease: true,
jogPinsUseTaskPolicyGate: true,
jogIncrementPinsRisingEdgeOnly: true,
jogIncrementZeroIsContinuous: true,
turtleJogLevelDriven: true,
countInputRequiresEnable: true,
directValueRequiresAnalogEnable: true,
resetPinsRisingEdgeOnly: true,
deleteMessageRisingEdgeOnly: true,
warningConfirmLevelPolled: true,
toolMeasurementPinsAreHalOut: true,
toolMeasurementDisabledWithoutToolsensor: true,
userMessagesDynamicIniOnly: true,
directValueRange: "0..1",
semanticBoundary: "native_gmoccapy_hal_pin_callbacks_to_web_store_actions",
},
},
nativePages: {
source: {
path: "linuxcnc/src/emc/usr_intf/gmoccapy/gmoccapy.py",
gladePath: "linuxcnc/src/emc/usr_intf/gmoccapy/gmoccapy.glade",
methods: [
"_init_IconFileSelection",
"_init_file_to_load",
"on_btn_load_clicked",
"on_IconFileSelection1_selected",
"on_hal_status_file_loaded",
"_make_macro_button",
"_on_btn_macro_pressed",
"_init_tooleditor",
"_show_tooledit_tab",
"on_btn_tool_clicked",
"on_btn_selected_tool_clicked",
"on_btn_select_tool_by_no_clicked",
"on_tbtn_setup_toggled",
"on_tbtn_user_tabs_toggled",
"on_btn_touch_clicked",
],
},
filePage: {
pageId: "file-load",
nativeNotebookPage: "_BB_LOAD_FILE / ntb_preview page 3",
nativeWidget: "IconFileSelection1",
programPrefix: "../../nc_files/",
defaultOpenPreference: "open_file",
fileExtensions: [
"*.ngc",
"*.png",
"*.gif",
"*.jpg",
"*.py",
],
nativeCallbacks: [
"on_btn_load_clicked -> _show_iconview_tab(True)",
"on_IconFileSelection1_selected -> hal_action_open.load_file(path)",
"on_hal_status_file_loaded -> gmoccapy.program.length",
"on_hal_status_line_changed -> gmoccapy.program.current-line/progress",
],
webSurface: "OPEN_FILE input, staged LinuxCNC G-code source selector and program preview",
webAction: "GMOCAPY_PAGE_ACTION file-load diagnostics",
implementation: "partial",
boundary: "native_GtkIconFileSelection_not_embedded_in_browser",
},
macroPage: {
pageId: "mdi-macros",
nativeContainer: "hbtb_MDI",
iniSection: "[MACROS]",
macroLimit: 14,
macros: [
{ name: "i_am_lost", args: [], file: "macros/i_am_lost.ngc", commandTemplate: "O<i_am_lost> call" },
{ name: "halo_world", args: [], file: "macros/halo_world.ngc", commandTemplate: "O<halo_world> call" },
{ name: "jog_around", args: [], file: "macros/jog_around.ngc", commandTemplate: "O<jog_around> call" },
{ name: "increment", args: ["xinc", "yinc"], file: "macros/increment.ngc", commandTemplate: "O<increment> call [xinc] [yinc]" },
{ name: "go_to_position", args: ["X-pos", "Y-pos", "Z-pos"], file: "macros/go_to_position.ngc", commandTemplate: "O<go_to_position> call [X-pos] [Y-pos] [Z-pos]" },
],
nativeCallbacks: [
"_make_macro_button -> get_ini_info.get_macros()",
"_on_btn_macro_pressed -> command.mdi(O<name> call [args])",
"on_hal_status_interp_idle enables macro buttons",
"on_hal_status_interp_run disables macro buttons",
],
webAction: "GMOCAPY_RUN_MACRO",
implementation: "partial",
boundary: "macro_buttons_map_to_MDI_command_without_native_entry_dialogs",
},
toolEditorPage: {
pageId: "tool-editor",
nativeNotebookPage: "ntb_preview page 2 / _BB_TOOL",
nativeWidget: "tooledit1",
toolTablePath: "linuxcnc/configs/sim/gmoccapy/tool.tbl",
visibleAxes: ["X", "Y", "Z", "A", "B"],
toolCount: 17,
nativeCallbacks: [
"_init_tooleditor -> tooledit1.set_filename(tool.tbl)",
"_show_tooledit_tab(True) -> tooledit1.reload()",
"on_btn_selected_tool_clicked -> M61 Q? or T? M6",
"on_btn_save_tool_changes_clicked -> tooledit1.save(None)",
"on_btn_reload_tooltable_clicked -> tooledit1.reload(None)",
],
webAction: "GMOCAPY_TOOL_EDITOR_ACTION diagnostics",
editableInWeb: false,
implementation: "diagnostic-only",
boundary: "tool_table_writeback_not_promoted_browser_keeps_iocontrol_loopback",
},
implementationMatrix: [
{ pageId: "main", nativeWidget: "ntb_main page 0", implementation: "implemented", webSurface: "DRO/G-code/preview/main controls", hardButton: null },
{ pageId: "manual-home", nativeWidget: "btn_homing / _BB_HOME", implementation: "partial", webSurface: "HOME action", hardButton: "gmoccapy.h-button.button-0" },
{ pageId: "mdi-macros", nativeWidget: "hbtb_MDI macro buttons", implementation: "partial", webSurface: "MDI macro diagnostics and dispatch", hardButton: null },
{ pageId: "auto-run", nativeWidget: "_BB_AUTO buttons", implementation: "partial", webSurface: "RUN/STOP/PAUSE/STEP controls", hardButton: null },
{ pageId: "file-load", nativeWidget: "IconFileSelection1 / _BB_LOAD_FILE", implementation: "partial", webSurface: "OPEN_FILE input and staged source selector", hardButton: null },
{ pageId: "tool-editor", nativeWidget: "btn_tool / tooledit1", implementation: "diagnostic-only", webSurface: "tool table summary and action diagnostics", hardButton: "gmoccapy.h-button.button-3" },
{ pageId: "touch-off", nativeWidget: "btn_touch / offsetpage1", implementation: "diagnostic-only", webSurface: "touch-off boundary diagnostics", hardButton: "gmoccapy.h-button.button-1" },
{ pageId: "setup", nativeWidget: "tbtn_setup / ntb_setup", implementation: "diagnostic-only", webSurface: "settings/unlock diagnostics", hardButton: "gmoccapy.v-button.button-6" },
{ pageId: "user-tabs", nativeWidget: "tbtn_user_tabs / ntb_user_tabs", implementation: "diagnostic-only", webSurface: "user tab boundary diagnostics", hardButton: "gmoccapy.v-button.button-5" },
{ pageId: "switchkins-mode", nativeWidget: "tbtn_switch_mode", implementation: "diagnostic-only", webSurface: "identity-only XYZAB switchkins diagnostic", hardButton: "gmoccapy.h-button.button-6" },
{ pageId: "edit", nativeWidget: "_BB_EDIT / gcode_view editable", implementation: "native-only", webSurface: "not promoted", hardButton: null },
{ pageId: "offset-editor", nativeWidget: "offsetpage1 edit offsets", implementation: "native-only", webSurface: "not promoted", hardButton: null },
],
behavior: {
unimplementedPagesDiagnosticOnly: true,
hardButtonsDoNotFakeNativePages: true,
fileChooserNotNativeGtk: true,
macroButtonsUseMdiGate: true,
toolEditorWritebackDisabled: true,
toolEditorKeepsIocontrolLoopback: true,
semanticBoundary: "native_gmoccapy_page_matrix_web_diagnostic_boundary",
},
},
toolChange: {
strategy: "iocontrol-loopback",
manualGmoccapyPinsConnected: false,
remapCodes: ["M6", "M61"],
postguiUnlinks: [
{
pin: "iocontrol.0.tool-change",
reason: "gmoccapy_postgui.hal removes the core_sim connection before applying the final loopback",
},
{
pin: "iocontrol.0.tool-changed",
reason: "gmoccapy_postgui.hal removes the core_sim connection before applying the final loopback",
},
{
pin: "iocontrol.0.tool-prep-number",
reason: "manual gmoccapy tool-change number net is not active in this simulation",
},
],
commentedManualGuiNets: [
{
signal: "tool-change",
source: "iocontrol.0.tool-change",
target: "gmoccapy.toolchange-change",
},
{
signal: "tool-changed",
source: "gmoccapy.toolchange-changed",
target: "iocontrol.0.tool-changed",
},
{
signal: "tool-prep-number",
source: "iocontrol.0.tool-prep-number",
target: "gmoccapy.toolchange-number",
},
],
activeLoop: {
signal: "tool-change-loop",
source: "iocontrol.0.tool-change",
target: "iocontrol.0.tool-changed",
stage: "POSTGUI_HALFILE",
},
semanticBoundary: "gmoccapy_xyzab_iocontrol_tool_change_loop_no_manual_gui_dialog",
},
semanticBoundary: "gmoccapy_hal_pin_postgui_reference_web_diagnostic_only",
};
export function createGmoccapyHalSummary(model = gmoccapyHalModel) {
const pinCount = model.nativePins.reduce((total, group) => total + group.pins.length, 0);
const postguiFile = model.halFiles.find((file) => file.stage === "POSTGUI_HALFILE");
const mappedHardwareButtonCount = [
...(model.hardwareButtons?.verticalMain || []),
...(model.hardwareButtons?.bottomMain || []),
].filter((button) => button.webSelector).length;
return {
apiName: `${model.apiName}-summary`,
profileId: model.profileId,
halFileCount: model.halFiles.length,
nativePinGroupCount: model.nativePins.length,
nativePinCount: pinCount,
coreNetCount: model.coreNets.length,
spindleNetCount: model.spindleNets.length,
postguiNetCount: model.postguiNets.length,
postguiRequiresHalcompReady: Boolean(postguiFile?.requiresHalcompReady) &&
model.postguiNets.every((net) => net.requiresHalcompReady),
hasToolChangeSimulationLoop: model.postguiNets.some((net) => net.simulationLoop),
toolChangeStrategy: model.toolChange.strategy,
manualToolChangeGuiConnected: model.toolChange.manualGmoccapyPinsConnected,
postguiToolUnlinkCount: model.toolChange.postguiUnlinks.length,
commentedManualToolChangeNetCount: model.toolChange.commentedManualGuiNets.length,
hardwareButtonVerticalCount: model.hardwareButtons.verticalMain.length,
hardwareButtonBottomMainCount: model.hardwareButtons.bottomMain.length,
mappedHardwareButtonCount,
hardwareButtonEdge: model.hardwareButtons.behavior.edge,
hardwareButtonInsensitiveTarget: model.hardwareButtons.behavior.insensitiveTarget,
operatorInputPinCount: model.halPinActions.operatorPins.length,
settingsInputPinCount: model.halPinActions.settingsPins.length,
overridePinTargetCount: model.halPinActions.overridePins.length,
jogAxisPinCount: (model.halPinActions.jogPins?.axes || []).length * 2,
jogIncrementPinCount: model.halPinActions.jogPins?.increments?.length || 0,
messageInputPinCount: model.halPinActions.messagePins.length,
toolMeasurementPinCount: model.halPinActions.toolMeasurementPins.pins.length,
toolMeasurementEnabled: model.halPinActions.toolMeasurementPins.useToolMeasurementPref,
toolsensorConfigured: model.halPinActions.toolMeasurementPins.toolsensorConfigured,
userMessagePinCount: model.halPinActions.userMessages.pins.length,
userMessagesConfigured: model.halPinActions.userMessages.configured,
optionalStopPinCrossesToBlockDelete: model.halPinActions.behavior.optionalStopPinCrossesToBlockDelete,
blockdeletePinCrossesToOptionalStop: model.halPinActions.behavior.blockdeletePinCrossesToOptionalStop,
unlockSettingsLevelDriven: model.halPinActions.behavior.unlockSettingsLevelDriven,
jogAxisPinsPressRelease: model.halPinActions.behavior.jogAxisPinsPressRelease,
jogPinsUseTaskPolicyGate: model.halPinActions.behavior.jogPinsUseTaskPolicyGate,
jogIncrementPinsRisingEdgeOnly: model.halPinActions.behavior.jogIncrementPinsRisingEdgeOnly,
jogIncrementZeroIsContinuous: model.halPinActions.behavior.jogIncrementZeroIsContinuous,
turtleJogLevelDriven: model.halPinActions.behavior.turtleJogLevelDriven,
countInputRequiresEnable: model.halPinActions.behavior.countInputRequiresEnable,
directValueRequiresAnalogEnable: model.halPinActions.behavior.directValueRequiresAnalogEnable,
resetPinsRisingEdgeOnly: model.halPinActions.behavior.resetPinsRisingEdgeOnly,
deleteMessageRisingEdgeOnly: model.halPinActions.behavior.deleteMessageRisingEdgeOnly,
warningConfirmLevelPolled: model.halPinActions.behavior.warningConfirmLevelPolled,
toolMeasurementPinsAreHalOut: model.halPinActions.behavior.toolMeasurementPinsAreHalOut,
toolMeasurementDisabledWithoutToolsensor: model.halPinActions.behavior.toolMeasurementDisabledWithoutToolsensor,
userMessagesDynamicIniOnly: model.halPinActions.behavior.userMessagesDynamicIniOnly,
nativePageCount: model.nativePages.implementationMatrix.length,
implementedNativePageCount: model.nativePages.implementationMatrix
.filter((page) => page.implementation === "implemented").length,
partialNativePageCount: model.nativePages.implementationMatrix
.filter((page) => page.implementation === "partial").length,
diagnosticOnlyNativePageCount: model.nativePages.implementationMatrix
.filter((page) => page.implementation === "diagnostic-only").length,
nativeOnlyPageCount: model.nativePages.implementationMatrix
.filter((page) => page.implementation === "native-only").length,
filePageImplementation: model.nativePages.filePage.implementation,
filePageBoundary: model.nativePages.filePage.boundary,
macroCount: model.nativePages.macroPage.macros.length,
macroLimit: model.nativePages.macroPage.macroLimit,
macroButtonsUseMdiGate: model.nativePages.behavior.macroButtonsUseMdiGate,
toolEditorImplementation: model.nativePages.toolEditorPage.implementation,
toolEditorWritebackDisabled: model.nativePages.behavior.toolEditorWritebackDisabled,
hardButtonsDoNotFakeNativePages: model.nativePages.behavior.hardButtonsDoNotFakeNativePages,
unimplementedPagesDiagnosticOnly: model.nativePages.behavior.unimplementedPagesDiagnosticOnly,
semanticBoundary: model.semanticBoundary,
};
}
export function resolveGmoccapyHardwareButton(input, model = gmoccapyHalModel) {
const pin = typeof input === "string" ? input : input?.pin;
const location = typeof input === "object" ? input?.location || input?.panel : null;
const index = typeof input === "object" && input?.index !== undefined ? Number(input.index) : null;
const allButtons = [
...(model.hardwareButtons?.verticalMain || []).map((button) => ({ ...button, panel: "right", location: "v" })),
...(model.hardwareButtons?.bottomMain || []).map((button) => ({ ...button, panel: "bottom", location: "h" })),
];
if (pin) return allButtons.find((button) => button.pin === pin) || null;
if (!Number.isFinite(index)) return null;
const normalizedLocation = location === "right" || location === "v" || location === "vertical" ? "v"
: location === "bottom" || location === "h" || location === "horizontal" ? "h"
: null;
return allButtons.find((button) => button.location === normalizedLocation && button.index === index) || null;
}

View File

@@ -0,0 +1,60 @@
const UNIT_ALIASES = new Map([
["mm", "mm"],
["millimeter", "mm"],
["millimeters", "mm"],
["millimetre", "mm"],
["millimetres", "mm"],
["metric", "mm"],
["inch", "inch"],
["inches", "inch"],
["in", "inch"],
["imperial", "inch"],
["m", "m"],
["meter", "m"],
["meters", "m"],
["metre", "m"],
["metres", "m"],
]);
const METERS_PER_UNIT = {
mm: 0.001,
inch: 0.0254,
m: 1,
};
export function normalizeLinearUnits(units, fallback = "mm") {
const key = String(units || "").trim().toLowerCase();
return UNIT_ALIASES.get(key) || UNIT_ALIASES.get(String(fallback || "mm").trim().toLowerCase()) || "mm";
}
export function linearUnitsToMetersFactor(units) {
return METERS_PER_UNIT[normalizeLinearUnits(units)] || METERS_PER_UNIT.mm;
}
export function linearUnitsToMillimetersFactor(units) {
return linearUnitsToMetersFactor(units) * 1000;
}
export function linearValueToMeters(value, units) {
const number = Number(value) || 0;
return number * linearUnitsToMetersFactor(units);
}
export function linearValueToMillimeters(value, units) {
const number = Number(value) || 0;
return number * linearUnitsToMillimetersFactor(units);
}
export function linearUnitsLabel(units) {
return normalizeLinearUnits(units);
}
export function resolveStateLinearUnits(stateOrProfile, fallback = "mm") {
return normalizeLinearUnits(
stateOrProfile?.programExecution?.summary?.linearUnits
|| stateOrProfile?.linuxCncIniConfig?.traj?.linearUnits
|| stateOrProfile?.profile?.traj?.linearUnits
|| stateOrProfile?.traj?.linearUnits,
fallback,
);
}

View File

@@ -0,0 +1,85 @@
import { xyzacTrtProfile } from "../profiles/xyzac-trt.js";
import { createPyvcpHalBindingSummary, xyzacTrtPyvcpPanelSchema } from "../panel-schema/xyzac-trt-pyvcp.js";
import { createProfileSourceReferenceSummary } from "../profiles/source-reference-map.js";
export function createLinuxCncBoundaryAdapter({
profile = xyzacTrtProfile,
panelSchema = profile.panelSchema || xyzacTrtPyvcpPanelSchema,
runtime = null,
} = {}) {
const sourceSummary = createProfileSourceReferenceSummary(profile.id);
const panelSummary = createPyvcpHalBindingSummary(panelSchema);
const kinematicsRuntimeReady = Boolean(runtime?.kinematicsWasm?.loaded);
const interpreterRuntimeReady = Boolean(runtime?.interpreterWasm?.loaded);
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",
profileId: profile.id,
profile,
sourceSummary,
panelSummary,
runtimeReady,
kinematicsRuntimeReady,
interpreterRuntimeReady,
linuxCncInterpreterReady,
profileSummary: createProfileSummary(profile),
linuxCncKinematicsReady,
promotionAllowed: linuxCncKinematicsReady,
fullLinuxCncProgramExecutionReady: false,
semanticBoundary: linuxCncKinematicsReady
? linuxCncInterpreterReady
? "linuxcnc_kinematics_and_interpreter_wasm_connected_remap_planner_not_promoted"
: "linuxcnc_kinematics_wasm_runtime_connected"
: "adapter_entrypoint_only_runtime_not_connected",
adapterPoints: {
kinematicsWasm: runtime?.kinematicsWasm || null,
interpreterWasm: runtime?.interpreterWasm || null,
halPins: profile.halPins,
pyvcpSchemaId: panelSchema.id,
},
};
}
function createProfileSummary(profile) {
return {
machineName: profile.machineName,
coordinates: profile.traj.coordinates,
jointCount: profile.jointConfig.length,
axisCount: Object.keys(profile.axisLimits).length,
mdiCommandCount: profile.halui.mdiCommands.length,
remapCount: profile.remaps.length,
toolCount: profile.toolTable.toolCount,
offsetNetCount: profile.hal.halcmd.offsetNets.length,
feedbackNetCount: profile.hal.halcmd.feedbackNets.length,
halFileCount: profile.hal.halFiles.length + profile.hal.postguiHalFiles.length,
sourceKinds: profile.sourceReferenceObjects.map((reference) => reference.kind),
};
}
export function createLinuxCncBoundaryReadiness(adapter = createLinuxCncBoundaryAdapter()) {
const missing = [];
if (!adapter.kinematicsRuntimeReady) missing.push("kinematics runtime");
if (!adapter.interpreterRuntimeReady) missing.push("interpreter/remap runtime");
if (!adapter.sourceSummary.referenceCount) missing.push("source reference map");
if (!adapter.panelSummary.controlCount) missing.push("PyVCP/HAL panel schema");
return {
apiName: "web-rtcp-5axis-linuxcnc-boundary-readiness",
profileId: adapter.profileId,
ready: adapter.linuxCncKinematicsReady
&& !missing.includes("kinematics runtime")
&& !missing.includes("source reference map")
&& !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,478 @@
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 emcmot = {
module: getFirstValue(sections, "EMCMOT", "EMCMOT") || null,
servoPeriodNs: numberOrNull(getFirstValue(sections, "EMCMOT", "SERVO_PERIOD")),
};
const task = {
module: getFirstValue(sections, "TASK", "TASK") || null,
cycleTimeSeconds: numberOrNull(getFirstValue(sections, "TASK", "CYCLE_TIME")),
};
const emcio = {
toolTable: getFirstValue(sections, "EMCIO", "TOOL_TABLE") || null,
};
const halui = {
mdiCommands: getValues(sections, "HALUI", "MDI_COMMAND"),
};
const kinematicsModuleId = inferKinematicsModuleId(kinsText);
return {
apiName: "web-rtcp-5axis-linuxcnc-ini-config",
profileId,
path,
sourceText: text,
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,
emcmot,
task,
emcio,
validation: validateIniConfig({
sections,
coordinates,
jointCount,
axisLimits,
jointConfig,
kinsText,
remaps,
hal,
halui,
rs274ngc: {
halPinVars: boolFromIni(getFirstValue(sections, "RS274NGC", "HAL_PIN_VARS")),
parameterFile: getFirstValue(sections, "RS274NGC", "PARAMETER_FILE") || null,
},
emcmot,
task,
emcio,
}),
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 = linuxCncIniCandidateUrls(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,
});
}
function linuxCncIniCandidateUrls(iniPath, baseUrl) {
const inDist = String(baseUrl).includes("/app/dist/");
if (String(iniPath).startsWith("linuxcnc/")) {
return inDist
? [new URL(`../../${iniPath}`, baseUrl)]
: [new URL(`../../../../${iniPath}`, baseUrl)];
}
return inDist
? [new URL(`../../${iniPath}`, baseUrl)]
: [new URL(`../../../../wasm-port/vendor/linuxcnc/${iniPath}`, baseUrl)];
}
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.halui.mdiCommands.length > 0
? 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,
emcmot: {
...profile.emcmot,
...dropNullish(iniConfig.emcmot || {}),
},
task: {
...profile.task,
...dropNullish(iniConfig.task || {}),
},
emcio: {
...profile.emcio,
...dropNullish(iniConfig.emcio || {}),
},
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({
sections,
coordinates,
jointCount,
axisLimits,
jointConfig,
kinsText,
remaps,
hal,
halui,
rs274ngc,
emcmot,
task,
emcio,
}) {
const missing = [];
const displayName = getFirstValue(sections, "DISPLAY", "DISPLAY") || "";
const isSwitchkinsTrt = String(kinsText || "").includes("sparm=identityfirst");
const isGmoccapyFixedTrt = String(displayName).toLowerCase() === "gmoccapy"
&& /^xyz[ab]c-trt-kins\b/i.test(String(kinsText || ""))
&& !isSwitchkinsTrt;
const requiredSections = [
"EMC",
"DISPLAY",
"RS274NGC",
"KINS",
"HAL",
"TRAJ",
"EMCMOT",
"TASK",
"EMCIO",
];
if (isSwitchkinsTrt) {
requiredSections.push("HALUI");
}
for (const section of requiredSections) {
if (!sections.has(section)) missing.push(`[${section}]`);
}
if (!["XYZAC", "XYZBC"].includes(coordinates)) missing.push("TRAJ.COORDINATES XYZAC/XYZBC");
if (!coordinates) missing.push("TRAJ.COORDINATES");
if (!jointCount) missing.push("KINS.JOINTS");
if (jointCount !== 5) missing.push("KINS.JOINTS=5");
if (!kinsText) missing.push("KINS.KINEMATICS");
if (!isSwitchkinsTrt && !isGmoccapyFixedTrt) {
missing.push("KINS.KINEMATICS sparm=identityfirst");
}
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}`);
}
for (let joint = 0; joint < 5; joint += 1) {
if (!sections.has(`JOINT_${joint}`)) missing.push(`JOINT_${joint}`);
}
if (isSwitchkinsTrt) {
for (const code of ["M428", "M429", "M430"]) {
if (!remaps.some((remap) => remap.code === code && remap.ngc)) {
missing.push(`RS274NGC.REMAP ${code}`);
}
}
if (rs274ngc.halPinVars !== true) missing.push("RS274NGC.HAL_PIN_VARS=1");
} else if (isGmoccapyFixedTrt) {
for (const code of ["M6", "M61"]) {
if (!remaps.some((remap) => remap.code === code && remap.ngc)) {
missing.push(`RS274NGC.REMAP ${code}`);
}
}
} else {
if (rs274ngc.halPinVars !== true) missing.push("RS274NGC.HAL_PIN_VARS=1");
}
if (!rs274ngc.parameterFile) missing.push("RS274NGC.PARAMETER_FILE");
if (!hal.halui) missing.push("HAL.HALUI");
if (!hal.halFiles.length) missing.push("HAL.HALFILE");
if (!hal.postguiHalFiles.length) missing.push("HAL.POSTGUI_HALFILE");
if (isSwitchkinsTrt && !hal.halcmd.some((line) => line.includes("motion.analog-out-03") && line.includes("motion.switchkins-type"))) {
missing.push("HAL.HALCMD motion.analog-out-03=>motion.switchkins-type");
}
if (isGmoccapyFixedTrt) {
for (const token of ["xyzac-trt-gui", "xyzac-trt-kins.tool-offset", "xyzac-trt-kins.y-offset", "xyzac-trt-kins.z-offset"]) {
if (!hal.halcmd.some((line) => line.includes(token))) {
missing.push(`HAL.HALCMD ${token}`);
}
}
} else if (isSwitchkinsTrt && halui.mdiCommands.length < 3) {
missing.push("HALUI.MDI_COMMAND M428/M429/M430");
}
if (!emcmot.module) missing.push("EMCMOT.EMCMOT");
if (!emcmot.servoPeriodNs) missing.push("EMCMOT.SERVO_PERIOD");
if (!task.module) missing.push("TASK.TASK");
if (!task.cycleTimeSeconds) missing.push("TASK.CYCLE_TIME");
if (!emcio.toolTable) missing.push("EMCIO.TOOL_TABLE");
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,544 @@
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: ["z", "x", "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 resultTextString = String(resultText);
const canonicalEventCount = resultTextString.split("\n").filter((line) => line.startsWith("canon_event=")).length;
const remapRuntimeReady = Boolean(machineFilePlan && resultTextString.includes("fiveaxis_remaps_ready=1"));
const machineFileRunCompleted = Boolean(machineFilePlan && (
FIVE_AXIS_REMAP_FLAGS.every((flag) => resultTextString.includes(flag)) ||
resultTextString.includes("fiveaxis_file_reached_exit=1") ||
resultTextString.includes("fiveaxis_linuxcnc_remap_file_execute=0")
));
const machineFileExecutionReady = Boolean(machineFileRunCompleted && motion.length > 0);
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,
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 {
const sampleStride = motion.length > 1000 ? Math.ceil(motion.length / 90) : 10;
return tpRuntime.sdk.runCanonicalMotionTiming({
motion,
options: {
cycleTime: 0.001,
queueSize: 32,
maxCycles: 2000000,
sampleStride,
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 feedModesByLine = feedModesBySourceLine(programText);
const linearUnitsByLine = linearUnitsBySourceLine(programText);
const switchkinsByLine = switchkinsEventsByLine(switchkinsEvents);
const motion = [];
let activePlane = 170;
let activeSwitchkinsEvent = null;
let activeFeedRate = null;
let activeFeedMode = "units-per-minute";
let activeLinearUnits = "mm";
for (const line of String(resultText).split("\n")) {
const feedRate = readCanonicalFeedRate(line);
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(activeFeedRate) || activeFeedRate <= 0)
&& Number.isFinite(sourceFeedRate) && sourceFeedRate > 0) {
activeFeedRate = sourceFeedRate;
}
activeFeedMode = latestFeedModeAtOrBeforeLine(feedModesByLine, sourceLine) || activeFeedMode;
activeLinearUnits = latestLinearUnitsAtOrBeforeLine(linearUnitsByLine, sourceLine) || activeLinearUnits;
}
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,
feedMode: activeFeedMode,
linearUnits: activeLinearUnits,
raw: line,
});
}
return motion;
}
function feedModesBySourceLine(programText) {
const modes = [{ line: 0, feedMode: "units-per-minute" }];
String(programText).split(/\r?\n/).forEach((line, index) => {
const codeOnly = stripComments(line);
let activeMode = null;
for (const match of codeOnly.matchAll(/\bG\s*([0-9]+(?:\.[0-9]+)?)\b/gi)) {
const value = Number(match[1]);
if (value === 93) activeMode = "inverse-time";
if (value === 94) activeMode = "units-per-minute";
}
if (activeMode) {
modes.push({ line: index + 1, feedMode: activeMode });
}
});
return modes;
}
function latestFeedModeAtOrBeforeLine(modes, sourceLine) {
let feedMode = "units-per-minute";
for (const entry of modes) {
if (entry.line <= sourceLine) feedMode = entry.feedMode;
}
return feedMode;
}
function linearUnitsBySourceLine(programText) {
const units = [{ line: 0, linearUnits: "mm" }];
String(programText).split(/\r?\n/).forEach((line, index) => {
const codeOnly = stripComments(line);
let activeUnits = null;
for (const match of codeOnly.matchAll(/\bG\s*([0-9]+(?:\.[0-9]+)?)\b/gi)) {
const value = Number(match[1]);
if (value === 20) activeUnits = "inch";
if (value === 21) activeUnits = "mm";
}
if (activeUnits) {
units.push({ line: index + 1, linearUnits: activeUnits });
}
});
return units;
}
function latestLinearUnitsAtOrBeforeLine(units, sourceLine) {
let linearUnits = "mm";
for (const entry of units) {
if (entry.line <= sourceLine) linearUnits = entry.linearUnits;
}
return linearUnits;
}
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;
if (String(line).trim() === "%") {
return "(linuxcnc program delimiter)";
}
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;
}
function readCanonicalFeedRate(line) {
const text = String(line);
if (text.startsWith("canon_event=SET_FEED_RATE")) {
return readCanonicalNumber(text, "rate");
}
if (text.startsWith("canon_event=UPDATE_TAG")) {
return readCanonicalNumber(text, "feed");
}
return readCanonicalNumber(text, "feed_rate");
}
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

@@ -0,0 +1,144 @@
const DEFAULT_MODULE_ID = "xyzbc-trt";
const DEFAULT_JOINT_COUNT = 5;
const SOURCE_MODE = "source-derived-kinematics-wasm";
const SEMANTIC_BOUNDARY = "linuxcnc_kinematics_wasm_c_abi";
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 = 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 || await createDefaultModuleOptions({ wasmRoot, wasmFile });
const sdk = await createLinuxCncKinematicsSdk({ moduleId, moduleOptions: resolvedModuleOptions });
let activeSwitchkinsType = switchkinsType;
let activeSwitchRc = typeof sdk.switchKinematics === "function"
? sdk.switchKinematics(switchkinsType)
: 0;
return {
apiName: "web-rtcp-5axis-linuxcnc-kinematics-runtime",
moduleId,
wasmFile,
supportedModules: supportedLinuxCncKinematicsModules(),
loaded: true,
sourceMode: SOURCE_MODE,
semanticBoundary: SEMANTIC_BOUNDARY,
executionContext: "direct",
get switchkinsType() {
return activeSwitchkinsType;
},
get switchRc() {
return activeSwitchRc;
},
jointCount,
sdk,
readiness() {
return {
apiName: "web-rtcp-5axis-linuxcnc-kinematics-runtime-readiness",
moduleId,
wasmFile,
supportedModules: supportedLinuxCncKinematicsModules(),
loaded: true,
sourceMode: SOURCE_MODE,
semanticBoundary: SEMANTIC_BOUNDARY,
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);
},
inverse(pose, count = jointCount, options = {}) {
return sdk.inverse(pose, count, options);
},
frameForJoints(joints, options = {}) {
const jointValues = Array.from(joints, Number);
const forward = sdk.forward(jointValues, options.forwardOptions || {});
const inverse = sdk.inverse(
forward.pose,
options.jointCount || jointCount,
{ seedJoints: jointValues, ...(options.inverseOptions || {}) },
);
return {
moduleId,
switchkinsType: activeSwitchkinsType,
forward,
inverse,
};
},
};
}
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 {
apiName: runtime.apiName,
moduleId: runtime.moduleId,
wasmFile: runtime.wasmFile,
supportedModules: runtime.supportedModules,
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 = "xyzbc-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,562 @@
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 DEFAULT_REPO_ROOT_URLS = [
new URL("../../../../", import.meta.url).href,
new URL("../../", import.meta.url).href,
];
const DEFAULT_TEST_SOURCE_ROOT_URLS = [
new URL("../../../working_run/test_linuxcnc_source/", import.meta.url).href,
new URL("../../working_run/test_linuxcnc_source/", import.meta.url).href,
new URL("../../../../working_run/test_linuxcnc_source/", 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 GMOCAPY_TRT_MACHINE_REL = "gmoccapy/non_trivial_kinematics/table-rotary-tilting";
const GMOCAPY_TRT_EXAMPLE_SOURCE_PREFIX = `configs/sim/${GMOCAPY_TRT_MACHINE_REL}/examples/`;
const OPFS_ROOT = "web-rtcp-5axis-xyzbc-trt-sim-plan/machines";
let browserMemoryMachineFileStorage = null;
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 machineRel = machineRelForProfile(profile);
const demoDirectory = demoDirectoryForProfile(profile);
const plan = planSimConfigStaging({
manifestText: resolvedManifestText,
machineRel,
iniFile,
iniText: resolvedIniText,
wasmDir: wasmDir || profile.machineFileStaging?.wasmDir || `/work/sim/${machineRel}/${profile.id}`,
});
const files = addProfileRuntimeFiles(
addVendoredDemoSources(plan.files, resolvedManifestText, plan.wasmDir, {
sourcePrefix: sourcePrefixForProfile(profile),
demoDirectory,
}),
profile,
plan.wasmDir,
);
return {
apiName: "web-rtcp-5axis-machine-file-staging-plan",
profileId: profile.id,
machineRel,
demoDirectory,
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),
taskHalSession: createTaskHalSession({
profileId: profile.id,
wasmDir: plan.wasmDir,
iniPath: plan.iniPath,
programPath: plan.programPath,
files,
}),
semanticBoundary: "linuxcnc_sim_config_file_staging_plan_only",
};
}
export function listLinuxCncGcodeSources(save) {
return [...(save?.files || [])]
.filter((file) => file.kind === "demo" && isLinuxCncFiveAxisGcodeSourceRel(file.sourceRel, save?.plan || save))
.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 listProjectGcodeFiles(save) {
return [...(save?.files || [])]
.filter((file) => file.kind === "demo" || file.kind === "remap")
.map((file) => ({
sourceRel: file.sourceRel,
wasmPath: file.wasmPath,
opfsPath: file.opfsPath,
filename: basename(file.sourceRel),
bytes: file.bytes,
kind: file.kind,
semanticBoundary: file.kind === "demo"
? "linuxcnc_vendored_5axis_gcode_source_file"
: "linuxcnc_vendored_5axis_remap_subroutine_file",
}))
.sort((left, right) => left.sourceRel.localeCompare(right.sourceRel));
}
export function selectMachineFileProgram(plan, save, sourceRel) {
if (!isLinuxCncFiveAxisGcodeSourceRel(sourceRel, plan)) {
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,
taskHalSession: {
...(plan.taskHalSession || {}),
programPath: selectedFile.wasmPath || selectedFile.path,
programSourceRel: selectedFile.sourceRel,
},
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 storage = resolveMachineFileStorage(options);
const sourceTextOverrides = options.sourceTextOverrides || {};
const savedFiles = [];
for (const file of plan.files) {
const text = typeof sourceTextOverrides[file.sourceRel] === "string"
? sourceTextOverrides[file.sourceRel]
: await readTextFromCandidateUrls(sourceUrlsFor(file.sourceRel));
await saveTextFile(file.opfsPath, text, storage.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}`,
storageMode: storage.mode,
storageCapability: storage.capability,
files: savedFiles,
plan,
gcodeSources: listLinuxCncGcodeSources({ ...plan, files: savedFiles }),
gcodeFiles: listProjectGcodeFiles({ files: savedFiles }),
summary: summarizeSavedFiles(savedFiles),
taskHalSession: {
...(plan.taskHalSession || {}),
files: savedFiles.map((file) => ({
sourceRel: file.sourceRel,
wasmPath: file.wasmPath,
path: file.wasmPath,
kind: file.kind,
bytes: file.bytes,
})),
},
semanticBoundary: storage.mode === "opfs"
? "opfs_machine_file_text_staging_only"
: "memory_machine_file_text_staging_current_page_lifecycle_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,
storageMode: options.storageMode,
sourceTextOverrides: options.sourceTextOverrides,
});
return { plan, save };
}
export function detectMachineFileStorageCapability(globalScope = globalThis) {
const forcedUnavailable = Boolean(globalScope.__WEB_RTCP_FORCE_OPFS_UNAVAILABLE__);
const isBrowser = typeof globalScope.window === "object" || typeof globalScope.document === "object";
const secureContext = !isBrowser || globalScope.isSecureContext !== false;
const hasOpfs = typeof globalScope.navigator?.storage?.getDirectory === "function";
const opfsAvailable = hasOpfs && secureContext && !forcedUnavailable;
return {
apiName: "web-rtcp-5axis-machine-file-storage-capability",
opfsAvailable,
opfsUnavailable: !opfsAvailable,
secureContext,
forcedUnavailable,
hasNavigatorStorage: Boolean(globalScope.navigator?.storage),
hasGetDirectory: hasOpfs,
fallbackMode: opfsAvailable ? null : "memory-fallback",
reason: opfsAvailable
? "opfs_available"
: forcedUnavailable
? "forced_unavailable"
: !secureContext
? "non_secure_context"
: !hasOpfs
? "missing_opfs_get_directory"
: "opfs_unavailable",
};
}
function resolveMachineFileStorage(options = {}) {
if (options.storage) {
const mode = options.storageMode || storageModeFor(options.storage);
if (options.requireOpfs === true && mode !== "opfs") {
throw new Error(`OPFS storage required for machine files, got ${mode}`);
}
return {
storage: options.storage,
mode,
capability: {
apiName: "web-rtcp-5axis-machine-file-storage-capability",
opfsAvailable: mode === "opfs",
opfsUnavailable: mode !== "opfs",
secureContext: true,
fallbackMode: mode === "opfs" ? null : mode || "custom",
reason: "explicit_storage",
},
};
}
const capability = detectMachineFileStorageCapability();
if (capability.opfsAvailable) {
return {
storage: globalThis.navigator.storage,
mode: "opfs",
capability,
};
}
if (options.requireOpfs === true) {
throw new Error(`OPFS storage required for machine files: ${capability.reason}`);
}
browserMemoryMachineFileStorage ??= createMemoryStorage();
return {
storage: browserMemoryMachineFileStorage,
mode: "memory-fallback",
capability,
};
}
function storageModeFor(storage) {
return storage?.apiName === "web-rtcp-5axis-memory-machine-file-storage" ||
storage?.apiName === "web-rtcp-5axis-memory-session-storage"
? "memory"
: "custom";
}
function createMemoryStorage(seed = {}) {
const files = new Map(Object.entries(seed));
return {
apiName: "web-rtcp-5axis-memory-machine-file-storage",
files,
async getDirectory() {
return createDirectoryHandle(files, []);
},
};
}
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/")
|| file.sourceRel.includes("/examples/")
)).length,
gcodeFileCount: (kinds.demo || 0) + (kinds.remap || 0),
remapFileCount: kinds.remap || 0,
demoFileCount: kinds.demo || 0,
toolTableFileCount: kinds.toolTable || 0,
halFileCount: kinds.hal || 0,
kinds,
};
}
function createTaskHalSession({ profileId, wasmDir, iniPath, programPath, files }) {
return {
apiName: "web-rtcp-5axis-task-hal-session-plan",
semanticBoundary: "linuxcnc_machine_file_session_for_task_hal_wasm",
profileId,
wasmDir,
iniPath,
programPath,
halFiles: files.filter((file) => classifySourceRel(file.sourceRel) === "hal").map((file) => file.wasmPath),
remapFiles: files.filter((file) => classifySourceRel(file.sourceRel) === "remap").map((file) => file.wasmPath),
toolTableFiles: files.filter((file) => classifySourceRel(file.sourceRel) === "toolTable").map((file) => file.wasmPath),
};
}
function addVendoredDemoSources(files, manifestText, wasmDir, {
sourcePrefix = TRT_DEMO_SOURCE_PREFIX,
demoDirectory = "demos",
} = {}) {
const bySourceRel = new Map(files.map((file) => [file.sourceRel, file]));
for (const sourceRel of String(manifestText).split(/\r?\n/)) {
if (!isLinuxCncFiveAxisGcodeSourceRel(sourceRel, { gcodeSourcePrefix: sourcePrefix })) continue;
if (bySourceRel.has(sourceRel)) continue;
bySourceRel.set(sourceRel, {
sourceRel,
wasmPath: `${wasmDir}/${demoDirectory}/${basename(sourceRel)}`,
executable: false,
});
}
return [...bySourceRel.values()];
}
function addProfileRuntimeFiles(files, profile, wasmDir) {
const bySourceRel = new Map(files.map((file) => [file.sourceRel, file]));
for (const sourceRel of [
profile.pyvcpXmlPath,
]) {
if (!sourceRel || bySourceRel.has(sourceRel)) continue;
bySourceRel.set(sourceRel, {
sourceRel,
wasmPath: `${wasmDir}/${basename(sourceRel)}`,
executable: false,
});
}
return [...bySourceRel.values()];
}
function isLinuxCncFiveAxisGcodeSourceRel(sourceRel, context = {}) {
const value = String(sourceRel || "");
const sourcePrefix = gcodeSourcePrefixForContext(context);
return value.startsWith(sourcePrefix)
&& value.endsWith(".ngc")
&& !value.slice(sourcePrefix.length).includes("/");
}
function summarizeSavedFiles(files) {
const kinds = countKinds(files.map((file) => file.kind));
return {
fileCount: files.length,
totalBytes: files.reduce((total, file) => total + file.bytes, 0),
gcodeFileCount: (kinds.demo || 0) + (kinds.remap || 0),
kinds,
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/") || sourceRel.includes("/examples/")) return "demo";
if (sourceRel.endsWith(".xml")) return "pyvcp";
if (sourceRel.endsWith(".var")) return "parameters";
return "asset";
}
function machineRelForProfile(profile = {}) {
return profile.machineFileStaging?.machineRel || TRT_MACHINE_REL;
}
function demoDirectoryForProfile(profile = {}) {
return profile.machineFileStaging?.demoDirectory || "demos";
}
function sourcePrefixForProfile(profile = {}) {
if (machineRelForProfile(profile) === GMOCAPY_TRT_MACHINE_REL) {
return GMOCAPY_TRT_EXAMPLE_SOURCE_PREFIX;
}
return `configs/sim/${machineRelForProfile(profile)}/${demoDirectoryForProfile(profile)}/`;
}
function gcodeSourcePrefixForContext(context = {}) {
if (context.gcodeSourcePrefix) return context.gcodeSourcePrefix;
if (context.machineRel === GMOCAPY_TRT_MACHINE_REL || context.demoDirectory === "examples") {
return GMOCAPY_TRT_EXAMPLE_SOURCE_PREFIX;
}
return TRT_DEMO_SOURCE_PREFIX;
}
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}`);
}
const text = await response.text();
if (/^\s*<!doctype html/i.test(text) || /^\s*<html[\s>]/i.test(text)) {
throw new Error(`machine staging asset resolved to HTML fallback ${url}`);
}
return 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 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 machine file: ${path}`);
return { async text() { return files.get(path); } };
},
};
},
};
}
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) {
if (String(sourceRel).startsWith("linuxcnc/")) {
return sourceOverrideUrlsFor(sourceRel).concat(
DEFAULT_REPO_ROOT_URLS.map((rootUrl) => new URL(sourceRel, rootUrl).href),
);
}
return sourceOverrideUrlsFor(sourceRel).concat(
DEFAULT_VENDOR_ROOT_URLS.map((rootUrl) => new URL(sourceRel, rootUrl).href),
);
}
function sourceOverrideUrlsFor(sourceRel) {
const basenameValue = basename(sourceRel);
if (!basenameValue) return [];
if (sourceRel === `configs/sim/${TRT_MACHINE_REL}/xyzac-trt.ini`) {
return DEFAULT_TEST_SOURCE_ROOT_URLS.map((rootUrl) => new URL("xyzac-trt.ini", rootUrl).href);
}
if (sourceRel === `${TRT_DEMO_SOURCE_PREFIX}impeller-7bl-xyzac.ngc`) {
return DEFAULT_TEST_SOURCE_ROOT_URLS.map((rootUrl) => new URL("impeller-7bl-xyzac.ngc", rootUrl).href);
}
return [];
}
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

@@ -0,0 +1,460 @@
import { linuxCncSourceReferenceMap } from "../profiles/source-reference-map.js";
export const RIGHT_SIDEBAR_PARITY_ENTRIES = [
"E-STOP",
"POWER",
"RESET",
"AUTO",
"MANUAL",
"JOG",
"MDI",
"IDENTITY",
"TCP",
];
export const SWITCHKINS_PARITY_CODES = ["M428", "M429", "M430"];
export const LINUXCNC_PARITY_SOURCE_REFERENCES = [
{
id: "remap-428",
path: "configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/428remap.ngc",
},
{
id: "remap-429",
path: "configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/429remap.ngc",
},
{
id: "remap-430",
path: "configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/430remap.ngc",
},
{
id: "linuxcnc-emc-hh",
path: "linuxcnc/src/emc/nml_intf/emc.hh",
},
{
id: "linuxcnc-emctaskmain",
path: "linuxcnc/src/emc/task/emctaskmain.cc",
},
{
id: "linuxcnc-emctask",
path: "linuxcnc/src/emc/task/emctask.cc",
},
{
id: "linuxcnc-motion-control",
path: "linuxcnc/src/emc/motion/control.c",
},
{
id: "linuxcnc-hal-lib",
path: "linuxcnc/src/hal/hal_lib.c",
},
{
id: "web-machine-project-root",
path: "web-rtcp-5axis-xyzbc-trt-sim-plan/machines/<profile>/configs/sim/axis/vismach/5axis/table-rotary-tilting",
},
];
export const LINUXCNC_PARITY_ITEMS = [
{
id: "xyzac-trt-axis-vismach-config",
category: "machine-config",
label: "XYZAC TRT AXIS/Vismach machine config",
linuxCncPaths: [
"configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini",
"configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.xml",
"configs/sim/axis/vismach/5axis/table-rotary-tilting/switchkins_postgui.hal",
"configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.tbl",
"configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/428remap.ngc",
"configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/429remap.ngc",
"configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/430remap.ngc",
],
functions: [
"INI [KINS] xyzac-trt-kins sparm=identityfirst",
"TRAJ XYZAC",
"HALUI M429/M428/M430",
"motion.switchkins-type",
"project-staged INI/HAL/TBL/NGC",
],
},
{
id: "xyzbc-trt-axis-vismach-config",
category: "machine-config",
label: "XYZBC TRT AXIS/Vismach machine config",
linuxCncPaths: [
"configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini",
"configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.xml",
"configs/sim/axis/vismach/5axis/table-rotary-tilting/switchkins_postgui.hal",
"configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.tbl",
"configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/428remap.ngc",
"configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/429remap.ngc",
"configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/430remap.ngc",
],
functions: [
"INI [KINS] xyzbc-trt-kins sparm=identityfirst",
"TRAJ XYZBC",
"HALUI M429/M428/M430",
"motion.switchkins-type",
"project-staged INI/HAL/TBL/NGC",
],
},
{
id: "gmoccapy-trt-config",
category: "machine-config",
label: "gmoccapy non-trivial TRT sample config",
linuxCncPaths: [
"configs/sim/gmoccapy/non_trivial_kinematics/table-rotary-tilting/xyzac-trt.ini",
"configs/sim/gmoccapy/non_trivial_kinematics/table-rotary-tilting/postgui.hal",
"configs/sim/gmoccapy/non_trivial_kinematics/table-rotary-tilting/xyzac-trt.tbl",
"configs/sim/gmoccapy/non_trivial_kinematics/table-rotary-tilting/examples/impeller-7bl-xyzac.ngc",
"configs/sim/gmoccapy/non_trivial_kinematics/table-rotary-tilting/examples/boat-xyzac.ngc",
"configs/sim/gmoccapy/non_trivial_kinematics/table-rotary-tilting/examples/boat-xyzbc.ngc",
"configs/sim/gmoccapy/non_trivial_kinematics/table-rotary-tilting/examples/test-xyzac.ngc",
"configs/sim/gmoccapy/non_trivial_kinematics/table-rotary-tilting/examples/test-xyzbc.ngc",
],
functions: [
"DISPLAY gmoccapy",
"POSTGUI spindle/tool HAL bindings",
"xyzac-trt-gui vismach feedback pins",
"gmoccapy TRT example programs",
"gmoccapy TRT ngcgui subroutine examples",
],
},
{
id: "gmoccapy-native-operator-ui",
category: "operator-ui",
label: "gmoccapy native operator workflow",
linuxCncPaths: [
"linuxcnc/src/emc/usr_intf/gmoccapy/gmoccapy.py",
"linuxcnc/share/gmoccapy/gmoccapy.glade",
"web-rtcp-5axis-sim-plan/app/src/ui-reference/gmoccapy-button-icons.json",
],
functions: [
"E-STOP/POWER/RESET task-state entry",
"AUTO/MANUAL/JOG/MDI task-mode entry",
"button active/blocked color rule",
"gmoccapy HAL button/icon mapping",
],
},
{
id: "right-sidebar-task-interlocks",
category: "operator-ui",
label: "Right vertical machine-state entrances",
linuxCncPaths: [
"linuxcnc/src/emc/nml_intf/emc.hh",
"linuxcnc/src/emc/task/emctaskmain.cc",
"linuxcnc/src/emc/task/emctask.cc",
],
functions: [
"EMC_TASK_SET_STATE",
"EMC_TASK_SET_MODE",
"EMC_JOINT_HOME",
"EMC_JOG_INCR",
"EMC_TASK_PLAN_RUN",
"EMC_TASK_PLAN_EXECUTE",
"EMC_TASK_ABORT",
"mode and interpreter idle gates",
],
},
{
id: "vismach-machine-preview",
category: "visualization",
label: "Vismach TRT preview and feedback model",
linuxCncPaths: [
"src/hal/user_comps/vismach/xyzac-trt-gui.py",
"src/hal/user_comps/vismach/xyzbc-trt-gui.py",
],
functions: [
"table/saddle/spindle feedback pins",
"A/B tilt and C rotate feedback pins",
"tool-offset/y-offset/x-offset/z-offset visualization",
"WebGL toolpath preview and current tool marker",
],
},
{
id: "source-derived-kinematics-switchkins",
category: "runtime",
label: "TRT kinematics and switchkins",
linuxCncPaths: [
"src/emc/kinematics/xyzac-trt-kins.c",
"src/emc/kinematics/xyzbc-trt-kins.c",
"src/emc/kinematics/trtfuncs.c",
"src/emc/kinematics/switchkins.c",
],
functions: [
"forward/inverse kinematics frame",
"identity/TCP/userk kinstype mapping",
"M428 TCP",
"M429 identity",
"M430 userk",
"DRO TCP axis value refresh",
],
},
{
id: "linuxcnc-interpreter-program-validation",
category: "runtime",
label: "LinuxCNC interpreter-backed G-code validation",
linuxCncPaths: [
"configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins.ngc",
"configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzbc_switchkins.ngc",
"configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/boat-xyzac.ngc",
"configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/boat-xyzbc.ngc",
"configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/impeller-7bl-xyzac.ngc",
"configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins_test_1.ngc",
"configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins_test_2.ngc",
"configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins_test_3.ngc",
],
functions: [
"real LinuxCNC 5-axis program source guard",
"canonical motion extraction",
"planner sample timing",
"current line and realtime axis feedback",
"toolpath preview path verification",
],
},
{
id: "machine-project-directory",
category: "project-files",
label: "Machine project directory and INI parity",
linuxCncPaths: [
"web-rtcp-5axis-xyzbc-trt-sim-plan/machines/<profile>/configs/sim/axis/vismach/5axis/table-rotary-tilting",
],
functions: [
"profile-specific project root",
"LinuxCNC INI source text match",
"config file count",
"G-code/remap file count",
"selected program provenance",
],
},
{
id: "task-hal-status-loop",
category: "runtime",
label: "Task/HAL run feedback and DRO updates",
linuxCncPaths: [
"linuxcnc/src/emc/task/emctaskmain.cc",
"linuxcnc/src/emc/motion/control.c",
"linuxcnc/src/hal/hal_lib.c",
],
functions: [
"task cycle status",
"servo cycle status",
"HAL changed pin summary",
"run/pause/resume/step/stop feedback",
"DRO axis value and DTG refresh",
],
},
{
id: "gmoccapy-postgui-tool-spindle",
category: "operator-ui",
label: "gmoccapy POSTGUI tool/spindle HAL functions",
linuxCncPaths: [
"configs/sim/gmoccapy/non_trivial_kinematics/table-rotary-tilting/postgui.hal",
"linuxcnc/configs/sim/gmoccapy/gmoccapy_postgui.hal",
"linuxcnc/configs/sim/gmoccapy/core_sim_XYZAB.hal",
"linuxcnc/configs/sim/gmoccapy/spindle_sim.hal",
],
functions: [
"gmoccapy.spindle_feedback_bar",
"gmoccapy.spindle_at_speed_led",
"gmoccapy.toolchange-change/changing loop",
"gmoccapy.tooloffset-z",
"coolant/spindle/operator override gates",
],
},
];
export function createLinuxCncParityMatrix(state = {}) {
const items = LINUXCNC_PARITY_ITEMS.map((item) => createParityItemState(item, state));
const implementedCount = items.filter((item) => item.implemented).length;
const activeCount = items.filter((item) => item.active).length;
const sourcePaths = unique(items.flatMap((item) => item.linuxCncPaths));
const functions = unique(items.flatMap((item) => item.functions));
const categories = Object.fromEntries(
unique(items.map((item) => item.category)).map((category) => [
category,
{
itemCount: items.filter((item) => item.category === category).length,
implementedCount: items.filter((item) => item.category === category && item.implemented).length,
activeCount: items.filter((item) => item.category === category && item.active).length,
},
]),
);
const sidebarLabels = (state.rightSidebarEntrances || []).map((entry) => entry.label);
const switchkinsCodes = state.profile?.kinematicsParameters?.switchkinsTypes
?.map((type) => type.mdiCommand)
.filter(Boolean) || [];
return {
apiName: "web-rtcp-5axis-linuxcnc-parity-matrix",
profileId: state.profile?.id || state.machineProfile || "unknown",
status: implementedCount === items.length ? "implemented" : "partial",
implementedCount,
activeCount,
itemCount: items.length,
sourcePathCount: sourcePaths.length,
linuxCncProgramCount: sourcePaths.filter((path) => path.endsWith(".ngc")).length,
functionCount: functions.length,
categories,
requiredRightSidebarEntries: RIGHT_SIDEBAR_PARITY_ENTRIES,
actualRightSidebarEntries: sidebarLabels,
rightSidebarComplete: RIGHT_SIDEBAR_PARITY_ENTRIES.every((label) => sidebarLabels.includes(label)),
requiredSwitchkinsCodes: SWITCHKINS_PARITY_CODES,
activeSwitchkinsCodes: switchkinsCodes,
switchkinsComplete: SWITCHKINS_PARITY_CODES.every((code) => switchkinsCodes.includes(code)),
items,
linuxCncPrograms: sourcePaths,
linuxCncFunctions: functions,
sourceReferenceIds: unique(sourceReferenceIdsForPaths(sourcePaths)),
semanticBoundary: "linuxcnc_source_function_parity_matrix_for_browser_simulation_not_hardware_control",
};
}
function createParityItemState(item, state) {
const evidence = createItemEvidence(item.id, state);
const sourceMapped = item.linuxCncPaths.every((path) => hasSourceReference(path));
const implemented = sourceMapped && evidence.implemented !== false;
const active = implemented && evidence.active === true;
return {
...item,
linuxCncReferenceIds: sourceReferenceIdsForPaths(item.linuxCncPaths),
sourceMapped,
implemented,
active,
status: active
? "active"
: implemented
? evidence.status || "implemented"
: "missing-source-reference",
evidence,
};
}
function createItemEvidence(id, state) {
switch (id) {
case "xyzac-trt-axis-vismach-config":
return {
status: state.profile?.id === "xyzac-trt" ? "active-profile" : "implemented",
active: state.profile?.id === "xyzac-trt",
profileAvailable: profileAvailable(state, "xyzac-trt"),
iniPath: state.profile?.id === "xyzac-trt" ? state.profile?.iniPath : null,
};
case "xyzbc-trt-axis-vismach-config":
return {
status: state.profile?.id === "xyzbc-trt" ? "active-profile" : "implemented",
active: state.profile?.id === "xyzbc-trt",
profileAvailable: profileAvailable(state, "xyzbc-trt"),
iniPath: state.profile?.id === "xyzbc-trt" ? state.profile?.iniPath : null,
};
case "gmoccapy-trt-config":
return {
status: "reference-mapped",
active: Boolean(state.gmoccapyGui),
displayStyle: "gmoccapy-web-shell",
};
case "gmoccapy-native-operator-ui":
return {
active: Boolean(state.gmoccapyGui) && rightSidebarComplete(state),
rightSidebarComplete: rightSidebarComplete(state),
buttonCount: state.rightSidebarEntrances?.length || 0,
};
case "right-sidebar-task-interlocks":
return {
active: Boolean(state.linuxCncTaskPolicy) && rightSidebarComplete(state),
taskState: state.linuxCncTaskPolicy?.taskState || null,
taskMode: state.linuxCncTaskPolicy?.taskMode || null,
interpState: state.linuxCncTaskPolicy?.interpState || null,
gates: {
canJog: state.linuxCncTaskPolicy?.canJog === true,
canRunAuto: state.linuxCncTaskPolicy?.canRunAuto === true,
canExecuteMdi: state.linuxCncTaskPolicy?.canExecuteMdi === true,
},
};
case "vismach-machine-preview":
return {
active: Boolean(state.rtcpFrame) && Boolean(state.preview),
frameApi: state.rtcpFrame?.apiName || null,
previewPoints: state.preview?.pathPoints || 0,
profileKinematics: state.profile?.kinematics || null,
};
case "source-derived-kinematics-switchkins":
return {
status: state.kinematicsRuntimeReadiness?.loaded ? "runtime-ready" : "implemented",
active: Boolean(state.profile?.kinematics) && switchkinsComplete(state),
runtimeLoaded: state.kinematicsRuntimeReadiness?.loaded === true,
kinsType: state.kinsType || null,
rtcpState: state.rtcpState || null,
activeSwitchkinsCodes: state.profile?.kinematicsParameters?.switchkinsTypes?.map((type) => type.mdiCommand) || [],
};
case "linuxcnc-interpreter-program-validation":
return {
status: state.programValidation?.ready ? "runtime-validated" : "implemented",
active: state.programValidation?.ready === true,
sourceGuard: state.programValidation?.sourceGuard || null,
motionEventCount: state.programValidation?.motionEventCount || 0,
plannerSampleCount: state.programValidation?.plannerSampleCount || 0,
};
case "machine-project-directory":
return {
status: state.machineProject?.status === "staged" ? "staged" : "implemented",
active: state.machineProject?.status === "staged",
projectRoot: state.machineProject?.projectRoot || null,
iniSourceMatchesProfile: state.machineProject?.ini?.sourceMatchesProfile === true,
configFileCount: state.machineProject?.configFileCount || 0,
gcodeFileCount: state.machineProject?.gcodeFileCount || 0,
};
case "task-hal-status-loop":
return {
status: state.taskHalStatusLoop?.active ? "runtime-active" : "implemented",
active: Boolean(state.taskHalRuntimeReadiness?.loaded || state.programRuntimeFeedback),
taskHalLoaded: state.taskHalRuntimeReadiness?.loaded === true,
statusLoopActive: state.taskHalStatusLoop?.active === true,
runtimeFeedbackSource: state.programRuntimeFeedback?.sourceMode || null,
};
case "gmoccapy-postgui-tool-spindle":
return {
active: Boolean(state.gmoccapyGui) && Boolean(state.toolPreview) && Boolean(state.spindle) && Boolean(state.coolant),
toolNumber: state.toolPreview?.toolNumber || null,
spindleDirection: state.spindle?.direction || null,
coolantFlood: state.coolant?.flood === true,
coolantMist: state.coolant?.mist === true,
};
default:
return {
active: false,
};
}
}
function profileAvailable(state, profileId) {
return (state.availableProfiles || []).some((profile) => profile.id === profileId);
}
function rightSidebarComplete(state) {
const labels = (state.rightSidebarEntrances || []).map((entry) => entry.label);
return RIGHT_SIDEBAR_PARITY_ENTRIES.every((label) => labels.includes(label));
}
function switchkinsComplete(state) {
const codes = state.profile?.kinematicsParameters?.switchkinsTypes
?.map((type) => type.mdiCommand)
.filter(Boolean) || [];
return SWITCHKINS_PARITY_CODES.every((code) => codes.includes(code));
}
function hasSourceReference(path) {
return allParityReferences().some((reference) => reference.path === path);
}
function sourceReferenceIdsForPaths(paths) {
return paths
.map((path) => allParityReferences().find((reference) => reference.path === path)?.id)
.filter(Boolean);
}
function allParityReferences() {
return [...linuxCncSourceReferenceMap, ...LINUXCNC_PARITY_SOURCE_REFERENCES];
}
function unique(values) {
return [...new Set(values.filter(Boolean))];
}

View File

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

View File

@@ -0,0 +1,71 @@
import { wrapTaskHalSdk } from "./linuxcnc-task-hal-runtime.js";
export async function createLinuxCncTaskHalWorkerRuntime({
workerUrl = new URL("./linuxcnc-task-hal-worker.js", import.meta.url).href,
sdkModuleUrl = null,
moduleOptions = {},
} = {}) {
if (typeof Worker !== "function") {
throw new Error("Worker is not available");
}
const worker = new Worker(workerUrl, { type: "module" });
const client = createWorkerClient(worker);
await client.call("init", { sdkModuleUrl, moduleOptions });
return {
apiName: "web-rtcp-5axis-linuxcnc-task-hal-worker-runtime",
semanticBoundary: "linuxcnc_task_motion_hal_wasm_simulation_runtime",
executionContext: "worker",
workerUrl,
loaded: true,
readiness: () => client.call("readiness"),
initSession: (session) => client.call("initSession", { session }),
stageFiles: (files) => client.call("stageFiles", { files }),
openProgram: (path) => client.call("openProgram", { path }),
loadProgramMotionPlan: (plan) => client.call("loadProgramMotionPlan", { plan }),
sendCommand: (command) => client.call("command", { command }),
runCycles: (options) => client.call("runCycles", { options }),
readStatus: () => client.call("readStatus"),
readEvents: () => client.call("readEvents"),
resetSession: () => client.call("reset"),
terminate: () => worker.terminate(),
};
}
export function createDirectTaskHalRuntimeFromSdk(sdk) {
return wrapTaskHalSdk(sdk, { executionContext: "direct" });
}
function createWorkerClient(worker) {
let nextId = 1;
const pending = new Map();
worker.addEventListener("message", (event) => {
const { id, ok, result, error } = event.data || {};
const request = pending.get(id);
if (!request) return;
pending.delete(id);
if (ok) {
request.resolve(result);
} else {
request.reject(new Error(error || "LinuxCNC task/HAL worker failed"));
}
});
worker.addEventListener("error", (event) => {
for (const request of pending.values()) {
request.reject(new Error(event.message || "LinuxCNC task/HAL worker error"));
}
pending.clear();
});
return {
call(type, payload = {}) {
const id = nextId;
nextId += 1;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
worker.postMessage({ id, type, payload });
});
},
};
}

View File

@@ -0,0 +1,63 @@
import { createLinuxCncTaskHalRuntime } from "./linuxcnc-task-hal-runtime.js";
let runtime = null;
self.addEventListener("message", async (event) => {
const { id, type, payload = {} } = event.data || {};
try {
const result = await handleMessage(type, payload);
self.postMessage({ id, ok: true, result });
} catch (error) {
self.postMessage({
id,
ok: false,
error: error instanceof Error ? error.message : String(error),
});
}
});
async function handleMessage(type, payload) {
switch (type) {
case "init":
runtime = await createLinuxCncTaskHalRuntime(payload);
return runtime.readiness();
case "readiness":
assertRuntime();
return runtime.readiness();
case "stageFiles":
assertRuntime();
return runtime.stageFiles(payload.files || []);
case "initSession":
assertRuntime();
return runtime.initSession(payload.session || {});
case "openProgram":
assertRuntime();
return runtime.openProgram(payload.path);
case "loadProgramMotionPlan":
assertRuntime();
return runtime.loadProgramMotionPlan(payload.plan || {});
case "command":
assertRuntime();
return runtime.sendCommand(payload.command);
case "runCycles":
assertRuntime();
return runtime.runCycles(payload.options || {});
case "readStatus":
assertRuntime();
return runtime.readStatus();
case "readEvents":
assertRuntime();
return runtime.readEvents();
case "reset":
assertRuntime();
return runtime.resetSession();
default:
throw new Error(`Unknown task/HAL worker message: ${type}`);
}
}
function assertRuntime() {
if (!runtime) {
throw new Error("LinuxCNC task/HAL worker runtime is not initialized");
}
}

View File

@@ -0,0 +1,181 @@
const WEB_SIMULATION_BOUNDARY = "linuxcnc_task_motion_hal_wasm_simulation_runtime";
const ALLOWED_NATIVE_PROBE_STATUSES = new Set([
"passed",
"ready_disabled_by_default",
"skipped_missing_host_runtime",
]);
export function createNativeTaskHalReadinessAudit({
sourceManifest = {},
nativeProbe = {},
fullExecutionBoundary = {},
taskHalRuntimeReadiness = {},
taskHalStatus = {},
generatedAt = new Date().toISOString(),
artifactPaths = {},
} = {}) {
const taskSourceCount = numberFrom(sourceManifest.task_source_count);
const halSourceCount = numberFrom(sourceManifest.hal_source_count);
const motionSourceCount = numberFrom(sourceManifest.motion_source_count);
const sourceManifestReady = truthy(sourceManifest.task_hal_source_manifest_ready)
&& truthy(sourceManifest.task_hal_reference_source_ready)
&& taskSourceCount > 0
&& halSourceCount > 0
&& motionSourceCount > 0;
const nativeProbeStatus = String(
nativeProbe.native_probe_status
|| nativeProbe.trt_task_hal_runtime_probe_status
|| "missing",
);
const nativeProbeOk = String(nativeProbe.native_task_hal_probe || "") === "ok"
&& ALLOWED_NATIVE_PROBE_STATUSES.has(nativeProbeStatus);
const nativePromotionBlocked = !truthy(nativeProbe.trt_task_hal_promotion_allowed)
&& String(nativeProbe.nativeTaskReady) !== "true"
&& String(nativeProbe.nativeHalSyncReady) !== "true";
const taskReady = Boolean(
fullExecutionBoundary.taskRuntimeReady
|| taskHalRuntimeReadiness.taskRuntimeReady
|| taskHalStatus.summary?.taskRuntimeReady,
);
const motionReady = Boolean(
fullExecutionBoundary.motionRuntimeReady
|| taskHalRuntimeReadiness.motionRuntimeReady
|| taskHalStatus.summary?.motionRuntimeReady,
);
const halReady = Boolean(
fullExecutionBoundary.halRuntimeReady
|| taskHalRuntimeReadiness.halRuntimeReady
|| taskHalStatus.summary?.halRuntimeReady,
);
const halSyncReady = Boolean(
fullExecutionBoundary.halSyncReady
|| taskHalRuntimeReadiness.halSyncReady
|| taskHalStatus.summary?.halSyncReady,
);
const fullBoundarySimulationReady = Boolean(
fullExecutionBoundary.semanticBoundary === WEB_SIMULATION_BOUNDARY
&& fullExecutionBoundary.fullLinuxCncProgramExecutionReady === true
&& fullExecutionBoundary.promotionAllowed === true,
);
const hardwareBlocked = fullExecutionBoundary.hardwareDrive === false
&& fullExecutionBoundary.hostRealtimeKernel === false
&& fullExecutionBoundary.hostExternalUserMProcessReady === false
&& fullExecutionBoundary.hostToolDbProcessReady === false
&& fullExecutionBoundary.arbitraryUserMExecution === false;
const toolUserWebSimulationReady = fullExecutionBoundary.externalUserMProcessReady === true
&& fullExecutionBoundary.externalUserMProcessScope === "web_simulation_only"
&& fullExecutionBoundary.toolDbProcessReady === true
&& fullExecutionBoundary.toolDbProcessScope === "web_simulation_only";
const webSimulationConsistent = sourceManifestReady
&& nativeProbeOk
&& nativePromotionBlocked
&& taskReady
&& motionReady
&& halReady
&& halSyncReady
&& fullBoundarySimulationReady
&& toolUserWebSimulationReady
&& hardwareBlocked;
return {
apiName: "web-rtcp-5axis-native-task-hal-readiness-audit",
batch: "M18-native-task-hal-source-and-artifact-audit",
generatedAt,
status: webSimulationConsistent ? "ok" : "blocked",
semanticBoundary: WEB_SIMULATION_BOUNDARY,
promotionScope: "web_simulation_only",
taskHalWebSimulationBoundaryConsistent: webSimulationConsistent,
webSimulation: {
promoted: fullBoundarySimulationReady,
taskRuntimeReady: taskReady,
motionRuntimeReady: motionReady,
halRuntimeReady: halReady,
nativeTaskReady: fullExecutionBoundary.nativeTaskReady === true,
nativeHalSyncReady: fullExecutionBoundary.nativeHalSyncReady === true,
fullLinuxCncProgramExecutionReady:
fullExecutionBoundary.fullLinuxCncProgramExecutionReady === true,
promotionAllowed: fullExecutionBoundary.promotionAllowed === true,
},
nativeHostAndHardware: {
nativeProbe: nativeProbeOk ? "ok" : "blocked",
nativeProbeStatus,
nativePromotionAllowed: truthy(nativeProbe.trt_task_hal_promotion_allowed),
hardwareDrive: false,
hostRealtimeKernel: false,
externalUserMProcessReady: false,
toolDbProcessReady: false,
hostExternalUserMProcessReady: false,
hostToolDbProcessReady: false,
arbitraryUserMExecution: false,
},
toolUserWebSimulation: {
externalUserMProcessReady: fullExecutionBoundary.externalUserMProcessReady === true,
externalUserMProcessScope: fullExecutionBoundary.externalUserMProcessScope || "not_ready",
toolDbProcessReady: fullExecutionBoundary.toolDbProcessReady === true,
toolDbProcessScope: fullExecutionBoundary.toolDbProcessScope || "not_ready",
ready: toolUserWebSimulationReady,
},
sourceManifest: {
ready: sourceManifestReady,
taskSourceCount,
halSourceCount,
motionSourceCount,
nmlSourceCount: numberFrom(sourceManifest.nml_source_count),
libnmlSourceCount: numberFrom(sourceManifest.libnml_source_count),
referenceSourceReady: truthy(sourceManifest.task_hal_reference_source_ready),
vendorSourceReady: truthy(sourceManifest.task_hal_vendor_source_ready),
vendorHashMatchReady: truthy(sourceManifest.task_hal_vendor_hash_match_ready),
},
gates: {
task_hal_web_simulation_boundary_consistent: webSimulationConsistent ? 1 : 0,
native_task_hal_host_probe_status: nativeProbeStatus,
hardware_drive: 0,
host_realtime_kernel: 0,
external_user_m_process_ready: toolUserWebSimulationReady ? 1 : 0,
tool_db_process_ready: toolUserWebSimulationReady ? 1 : 0,
host_external_user_m_process_ready: 0,
host_tool_db_process_ready: 0,
promotion_scope: "web_simulation_only",
},
artifacts: artifactPaths,
blockers: webSimulationConsistent ? [] : buildBlockers({
sourceManifestReady,
nativeProbeOk,
nativePromotionBlocked,
taskReady,
motionReady,
halReady,
halSyncReady,
fullBoundarySimulationReady,
toolUserWebSimulationReady,
hardwareBlocked,
}),
};
}
function buildBlockers(checks) {
const blockers = [];
if (!checks.sourceManifestReady) blockers.push("task/HAL source manifest proof is incomplete");
if (!checks.nativeProbeOk) blockers.push("native host probe did not produce an accepted default status");
if (!checks.nativePromotionBlocked) blockers.push("native probe unexpectedly allowed promotion");
if (!checks.taskReady) blockers.push("task runtime is not ready in the Web simulation boundary");
if (!checks.motionReady) blockers.push("motion runtime is not ready in the Web simulation boundary");
if (!checks.halReady) blockers.push("HAL runtime is not ready in the Web simulation boundary");
if (!checks.halSyncReady) blockers.push("task/motion/HAL sync is not ready in the Web simulation boundary");
if (!checks.fullBoundarySimulationReady) blockers.push("full execution boundary is not promoted for Web simulation");
if (!checks.toolUserWebSimulationReady) blockers.push("tool DB or controlled user-M Web simulation is not ready");
if (!checks.hardwareBlocked) blockers.push("hardware/native process blocked fields are not explicitly false");
return blockers;
}
function truthy(value) {
return value === true || value === 1 || value === "1" || value === "true";
}
function numberFrom(value) {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : 0;
}

View File

@@ -0,0 +1,214 @@
import { xyzacTrtProfile } from "../profiles/xyzac-trt.js";
const DEG_TO_RAD = Math.PI / 180;
export function buildRtcpFrame({
axisPose,
activeLine,
kinsType = "identity",
rtcpEnabled = false,
sourceMode = "fixture-ui-only",
profile = xyzacTrtProfile,
linuxCncKinematicsResult = null,
}) {
if (linuxCncKinematicsResult) {
return buildLinuxCncKinematicsFrame({
axisPose,
activeLine,
kinsType,
rtcpEnabled,
sourceMode,
profile,
linuxCncKinematicsResult,
});
}
const pose = normalizeAxisPose(axisPose);
const toolLength = 84.019;
const toolAxisVector = computeToolAxisVector(pose, profile);
const compensation = rtcpEnabled
? {
x: -toolAxisVector.x * toolLength,
y: -toolAxisVector.y * toolLength,
z: -toolAxisVector.z * toolLength,
}
: { x: 0, y: 0, z: 0 };
const tcpPose = {
x: pose.x + compensation.x,
y: pose.y + compensation.y,
z: pose.z + compensation.z,
a: pose.a,
b: pose.b,
c: pose.c,
};
return {
apiName: "web-rtcp-5axis-motion-frame",
profileId: profile.id,
sourceMode,
semanticBoundary:
sourceMode === "fixture-ui-only"
? "fixture_frame_ui_plumbing_not_linuxcnc_kinematics_proof"
: "linuxcnc_or_source_derived_runtime_required",
activeLine,
kinsType,
rtcpEnabled,
rtcpState: rtcpEnabled ? "on" : "off",
axisPose: pose,
jointPose: buildJointPose(pose, profile),
tcpPose,
toolAxisVector,
compensation,
toolLength,
readiness: {
frameReady: true,
uiReady: true,
profileReady: true,
linuxCncKinematicsReady: false,
promotionAllowed: false,
},
};
}
function buildLinuxCncKinematicsFrame({
axisPose,
activeLine,
kinsType,
rtcpEnabled,
profile,
linuxCncKinematicsResult,
}) {
const forward = linuxCncKinematicsResult.forward || {};
const inverse = linuxCncKinematicsResult.inverse || {};
const wasmPose = normalizeLinuxCncPose(forward.pose);
const pose = normalizeAxisPose({
...axisPose,
...wasmPose,
});
const jointValues = Array.isArray(inverse.joints) ? inverse.joints : [];
const toolAxisVector = computeToolAxisVector(pose, profile);
return {
apiName: "web-rtcp-5axis-motion-frame",
profileId: profile.id,
sourceMode: "source-derived-kinematics-wasm",
semanticBoundary: "linuxcnc_kinematics_wasm_c_abi",
activeLine,
kinsType,
rtcpEnabled,
rtcpState: rtcpEnabled ? "on" : "off",
axisPose: pose,
jointPose: buildJointPoseFromLinuxCncJoints(jointValues, pose, profile),
tcpPose: {
x: pose.x,
y: pose.y,
z: pose.z,
a: pose.a,
b: pose.b,
c: pose.c,
},
toolAxisVector,
compensation: { x: 0, y: 0, z: 0 },
toolLength: 84.019,
kinematicsModuleId: linuxCncKinematicsResult.moduleId,
kinematicsSwitchkinsType: linuxCncKinematicsResult.switchkinsType,
kinematicsForwardRc: forward.rc,
kinematicsInverseRc: inverse.rc,
kinematicsFlags: {
fflags: forward.fflags,
iflags: forward.iflags,
inverseFflags: inverse.fflags,
inverseIflags: inverse.iflags,
},
readiness: {
frameReady: true,
uiReady: true,
profileReady: true,
linuxCncKinematicsReady: forward.rc === 0 && inverse.rc === 0,
promotionAllowed: forward.rc === 0 && inverse.rc === 0,
fullLinuxCncProgramExecutionReady: false,
},
};
}
function normalizeAxisPose(axisPose) {
return {
x: Number(axisPose?.x ?? 0),
y: Number(axisPose?.y ?? 0),
z: Number(axisPose?.z ?? 0),
a: Number(axisPose?.a ?? 0),
b: Number(axisPose?.b ?? 0),
c: Number(axisPose?.c ?? 0),
};
}
function buildJointPose(pose, profile = xyzacTrtProfile) {
return axesForProfile(profile).map((axis, joint) => ({
joint,
axis,
value: pose[axis.toLowerCase()] ?? 0,
}));
}
function buildJointPoseFromLinuxCncJoints(joints, fallbackPose, profile = xyzacTrtProfile) {
const axes = axesForProfile(profile);
const fallbackValues = axes.map((axis) => fallbackPose[axis.toLowerCase()] ?? 0);
return axes.map((axis, joint) => ({
joint,
axis,
value: Number(joints[joint] ?? fallbackValues[joint] ?? 0),
}));
}
function axesForProfile(profile = xyzacTrtProfile) {
const coordinates = profile?.coordinates?.length
? profile.coordinates
: String(profile?.traj?.coordinates || "XYZAC").split("");
return coordinates.map((axis) => String(axis).toUpperCase()).filter(Boolean);
}
function normalizeLinuxCncPose(pose = {}) {
return {
x: Number(pose.x ?? pose.tran?.x ?? 0),
y: Number(pose.y ?? pose.tran?.y ?? 0),
z: Number(pose.z ?? pose.tran?.z ?? 0),
a: Number(pose.a ?? 0),
b: Number(pose.b ?? 0),
c: Number(pose.c ?? 0),
};
}
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 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: sinTilt * sinC,
y: -sinTilt * cosC,
z: cosTilt,
});
}
function normalizeVector(vector) {
const length = Math.hypot(vector.x, vector.y, vector.z) || 1;
return {
x: vector.x / length,
y: vector.y / length,
z: vector.z / length,
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,111 @@
const DEFAULT_LINEAR_UNITS = "mm";
export const XYZBC_TRT_VISMACH_PINS = [
"table-x",
"saddle-y",
"spindle-z",
"tilt-b",
"rotate-c",
"tool-offset",
"x-offset",
"z-offset",
];
export function buildVismachModelState(state = {}) {
const profile = state.profile || {};
const axisPose = resolveAxisPose(state);
const offsets = profile.offsets || {};
const toolOffset = resolveToolOffset(state);
const xOffset = finiteNumber(offsets.x, -20);
const zOffset = finiteNumber(offsets.z, -15);
const linearUnits = resolveLinearUnits(state);
return {
apiName: "web-rtcp-5axis-vismach-model-state",
profileId: profile.id || state.machineProfile || null,
sourceGui: "src/hal/user_comps/vismach/xyzbc-trt-gui.py",
webModel: "app/src/visualization/five-axis-scene.js",
linearUnits,
pins: {
"table-x": finiteNumber(axisPose.x, 0),
"saddle-y": finiteNumber(axisPose.y, 0),
"spindle-z": finiteNumber(axisPose.z, 0),
"tilt-b": finiteNumber(axisPose.b, 0),
"rotate-c": finiteNumber(axisPose.c, 0),
"tool-offset": toolOffset,
"x-offset": xOffset,
"z-offset": zOffset,
},
transforms: {
table: {
translate: { x: finiteNumber(axisPose.x, 0), y: 0, z: 0 },
sourcePins: ["table-x"],
},
saddle: {
translate: { x: 0, y: finiteNumber(axisPose.y, 0), z: 0 },
sourcePins: ["saddle-y"],
},
spindle: {
translate: { x: 0, y: 0, z: finiteNumber(axisPose.z, 0) },
sourcePins: ["spindle-z"],
},
tilt: {
rotateDeg: { x: 0, y: finiteNumber(axisPose.b, 0), z: 0 },
sourcePins: ["tilt-b"],
},
rotary: {
rotateDeg: { x: 0, y: 0, z: finiteNumber(axisPose.c, 0) },
sourcePins: ["rotate-c"],
},
tool: {
translate: { x: xOffset, y: 0, z: zOffset - toolOffset },
sourcePins: ["tool-offset", "x-offset", "z-offset"],
},
},
halNets: [
{ signal: "table-x", source: "joint.0.pos-fb", target: "xyzbc-trt-gui.table-x" },
{ signal: "saddle-y", source: "joint.1.pos-fb", target: "xyzbc-trt-gui.saddle-y" },
{ signal: "spindle-z", source: "joint.2.pos-fb", target: "xyzbc-trt-gui.spindle-z" },
{ signal: "tilt-b", source: "joint.3.pos-fb", target: "xyzbc-trt-gui.tilt-b" },
{ signal: "rotate-c", source: "joint.4.pos-fb", target: "xyzbc-trt-gui.rotate-c" },
{ signal: "tool-offset", source: "motion.tooloffset.z", target: "xyzbc-trt-kins.tool-offset" },
{ signal: "tool-offset", source: "xyzbc-trt-kins.tool-offset", target: "xyzbc-trt-gui.tool-offset" },
{ signal: "x-offset", source: "xyzbc-trt-kins.x-offset", target: "xyzbc-trt-gui.x-offset" },
{ signal: "z-offset", source: "xyzbc-trt-kins.z-offset", target: "xyzbc-trt-gui.z-offset" },
],
clearTraceSignal: "pyvcp.vismach-clear => vismach.plotclear",
semanticBoundary: "web_threejs_vismach_equivalent_driven_by_xyzbc_trt_hal_pins",
};
}
function resolveAxisPose(state) {
if (state.programRuntimeFeedback?.axisPose) return state.programRuntimeFeedback.axisPose;
if (state.taskHalStatus?.ui?.axisPose) return state.taskHalStatus.ui.axisPose;
return state.axisPose || {};
}
function resolveToolOffset(state) {
if (Number.isFinite(Number(state.toolRuntimeState?.vismach?.toolOffset))) {
return Number(state.toolRuntimeState.vismach.toolOffset);
}
return finiteNumber(
state.toolDbSimulation?.activeToolOffset?.offset?.z,
state.toolPreview?.length,
0,
);
}
function resolveLinearUnits(state) {
return state.linuxCncIniConfig?.traj?.linearUnits
|| state.programRuntimeFeedback?.linearUnits
|| state.programExecutionTiming?.linearUnits
|| DEFAULT_LINEAR_UNITS;
}
function finiteNumber(...values) {
for (const value of values) {
const number = Number(value);
if (Number.isFinite(number)) return number;
}
return 0;
}