接入 LinuxCNC TP 运行反馈

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

View File

@@ -6,8 +6,8 @@
"scripts": {
"build": "node scripts/build-static.mjs",
"dev": "python3 -m http.server 4173",
"smoke": "bash ../tests/browser/verify_gmoccapy_shell_browser.sh",
"smoke:node": "node ../tests/node/verify_linuxcnc_kinematics_runtime.mjs && node ../tests/node/verify_rtcp_store.mjs && node ../tests/node/verify_profile_boundary.mjs"
"smoke": "bash ../tests/browser/verify_gmoccapy_shell_browser.sh && bash ../tests/browser/verify_gmoccapy_dist_browser.sh",
"smoke:node": "node ../tests/node/verify_linuxcnc_kinematics_runtime.mjs && node ../tests/node/verify_linuxcnc_interpreter_runtime.mjs && node ../tests/node/verify_linuxcnc_ini_runtime.mjs && node ../tests/node/verify_full_linuxcnc_5axis_source.mjs && node ../tests/node/verify_full_execution_boundary.mjs && node ../tests/node/verify_machine_file_staging.mjs && node ../tests/node/verify_five_axis_session.mjs && node ../tests/node/verify_rtcp_store.mjs && node ../tests/node/verify_profile_boundary.mjs"
},
"dependencies": {},
"devDependencies": {}

View File

@@ -1,14 +1,20 @@
import { cp, mkdir, rm, readFile } from "node:fs/promises";
import { cp, mkdir, readdir, rm, readFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const appRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const repoRoot = dirname(dirname(appRoot));
const distDir = join(appRoot, "dist");
await rm(distDir, { recursive: true, force: true });
await mkdir(distDir, { recursive: true });
await cp(join(appRoot, "index.html"), join(distDir, "index.html"));
await cp(join(appRoot, "src"), join(distDir, "src"), { recursive: true });
await copyLinuxCncManifest();
await copyLinuxCncConfigAssets();
await copyKinematicsRuntimeAssets();
await copyInterpreterRuntimeAssets();
await copyTpRuntimeAssets();
const packageJson = JSON.parse(await readFile(join(appRoot, "package.json"), "utf8"));
const forbiddenDependencies = ["react", "vue", "@angular/core", "svelte"];
@@ -22,4 +28,59 @@ if (forbidden.length > 0) {
throw new Error(`forbidden frontend framework dependency detected: ${forbidden.join(", ")}`);
}
async function copyInterpreterRuntimeAssets() {
const coreSrcDir = join(repoRoot, "wasm-port/build/wasm/core");
const coreDistDir = join(distDir, "wasm-port/build/wasm/core");
await mkdir(coreDistDir, { recursive: true });
for (const entry of ["linuxcnc_interp.js", "linuxcnc_interp.wasm"]) {
await cp(join(coreSrcDir, entry), join(coreDistDir, entry));
}
}
async function copyTpRuntimeAssets() {
const tpSrcDir = join(repoRoot, "wasm-port/build/wasm/tp");
const tpDistDir = join(distDir, "wasm-port/build/wasm/tp");
await mkdir(tpDistDir, { recursive: true });
for (const entry of ["linuxcnc_tp.js", "linuxcnc_tp.wasm"]) {
await cp(join(tpSrcDir, entry), join(tpDistDir, entry));
}
}
console.log("gmoccapy_static_build=ok");
async function copyLinuxCncConfigAssets() {
const configSrcDir = join(repoRoot, "wasm-port/vendor/linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting");
const configDistDir = join(distDir, "configs/sim/axis/vismach/5axis/table-rotary-tilting");
await mkdir(configDistDir, { recursive: true });
await cp(configSrcDir, configDistDir, { recursive: true });
}
async function copyLinuxCncManifest() {
const manifestDistDir = join(distDir, "wasm-port/tools");
await mkdir(manifestDistDir, { recursive: true });
await cp(
join(repoRoot, "wasm-port/tools/source-manifest.txt"),
join(manifestDistDir, "source-manifest.txt"),
);
}
async function copyKinematicsRuntimeAssets() {
const sdkSrcDir = join(repoRoot, "wasm-port/runtime/sdk/src");
const sdkDistDir = join(distDir, "wasm-port/runtime/sdk/src");
await mkdir(sdkDistDir, { recursive: true });
await cp(
join(sdkSrcDir, "linuxcnc-kinematics.js"),
join(sdkDistDir, "linuxcnc-kinematics.js"),
);
for (const entry of ["linuxcnc-interp.js", "linuxcnc-hal.js", "linuxcnc-tp.js"]) {
await cp(join(sdkSrcDir, entry), join(sdkDistDir, entry));
}
const kinematicsSrcDir = join(repoRoot, "wasm-port/build/wasm/kinematics");
const kinematicsDistDir = join(distDir, "wasm-port/build/wasm/kinematics");
await mkdir(kinematicsDistDir, { recursive: true });
for (const entry of await readdir(kinematicsSrcDir)) {
if (!/^linuxcnc_.*_kinematics\.(js|wasm)$/.test(entry)) continue;
await cp(join(kinematicsSrcDir, entry), join(kinematicsDistDir, entry));
}
}

View File

@@ -1,5 +1,10 @@
import { createSimulationStore } from "./state/store.js";
import { mountGmoccapyShell } from "./ui/gmoccapy-shell.js";
import { createLinuxCncInterpreterRuntime } from "./runtime/linuxcnc-interpreter-runtime.js";
import { createLinuxCncInterpreterWorkerRuntime } from "./runtime/linuxcnc-interpreter-worker-client.js";
import { createLinuxCncKinematicsRuntime } from "./runtime/linuxcnc-kinematics-runtime.js";
import { createLinuxCncKinematicsWorkerRuntime } from "./runtime/linuxcnc-kinematics-worker-client.js";
import { loadLinuxCncIniConfig } from "./runtime/linuxcnc-ini-runtime.js";
const app = document.querySelector("#app");
@@ -9,11 +14,134 @@ if (!app) {
const store = createSimulationStore();
const shell = mountGmoccapyShell(app, store);
const iniConfigReady = attachProfileIniConfig(store, store.getState().profile);
const kinematicsRuntimeReady = attachDefaultKinematicsRuntime(store, store.getState().profile.kinematicsModuleId || store.getState().machineProfile);
const interpreterRuntimeReady = attachDefaultInterpreterRuntime(store);
let attachedKinematicsProfile = store.getState().machineProfile;
let attachedIniProfile = store.getState().machineProfile;
store.subscribe((state) => {
if (state.machineProfile !== attachedIniProfile) {
attachedIniProfile = state.machineProfile;
attachProfileIniConfig(store, state.profile).catch(() => {});
}
if (state.machineProfile !== attachedKinematicsProfile) {
attachedKinematicsProfile = state.machineProfile;
attachDefaultKinematicsRuntime(store, state.profile.kinematicsModuleId || state.machineProfile).catch(() => {});
}
});
window.webRtcp5AxisSimulation = {
getState: store.getState,
dispatch: store.dispatch,
refreshKinematicsFrame: store.refreshKinematicsFrame,
saveSession: store.saveSession,
restoreSession: store.restoreSession,
stageMachineFiles: store.stageMachineFiles,
runFullBoundaryAudit: store.runFullBoundaryAudit,
getRegions: shell.getRegions,
iniConfigReady,
kinematicsRuntimeReady,
interpreterRuntimeReady,
};
store.dispatch({ type: "BOOT_READY" });
async function attachProfileIniConfig(store, profile) {
try {
const iniConfig = await loadLinuxCncIniConfig(profile);
store.dispatch({ type: "ATTACH_INI_CONFIG", profileId: profile.id, iniConfig });
return store.getState().iniConfigReadiness;
} catch (error) {
store.dispatch({ type: "INI_CONFIG_FAILED", path: profile.iniPath, error: error.message });
return store.getState().iniConfigReadiness;
}
}
async function attachDefaultKinematicsRuntime(store, moduleId = "xyzac-trt") {
const sdkModuleUrls = [
new URL("../../../wasm-port/runtime/sdk/src/linuxcnc-kinematics.js", import.meta.url).href,
new URL("../wasm-port/runtime/sdk/src/linuxcnc-kinematics.js", import.meta.url).href,
];
const errors = [];
for (const sdkModuleUrl of sdkModuleUrls) {
if (typeof Worker === "function") {
try {
const runtime = await createLinuxCncKinematicsWorkerRuntime({ moduleId, sdkModuleUrl });
store.dispatch({ type: "ATTACH_KINEMATICS_RUNTIME", runtime });
await store.refreshKinematicsFrame({ operatorMessage: `LinuxCNC kinematics ${runtime.moduleId} worker ready` });
return runtime.readiness();
} catch (error) {
errors.push(`${sdkModuleUrl} worker: ${error.message}`);
}
}
try {
const runtime = await createLinuxCncKinematicsRuntime({ moduleId, sdkModuleUrl });
store.dispatch({ type: "ATTACH_KINEMATICS_RUNTIME", runtime });
return runtime.readiness();
} catch (error) {
errors.push(`${sdkModuleUrl}: ${error.message}`);
}
}
try {
const runtime = await createLinuxCncKinematicsRuntime({ moduleId });
store.dispatch({ type: "ATTACH_KINEMATICS_RUNTIME", runtime });
return runtime.readiness();
} catch (error) {
errors.push(`default runtime: ${error.message}`);
store.dispatch({
type: "SET_FRAME_SOURCE",
sourceMode: "fixture-ui-only",
operatorMessage: `LinuxCNC kinematics runtime unavailable: ${errors.join(" | ")}`,
});
return {
apiName: "web-rtcp-5axis-linuxcnc-kinematics-runtime-readiness",
moduleId,
loaded: false,
sourceMode: "fixture-ui-only",
error: errors.join(" | "),
};
}
}
async function attachDefaultInterpreterRuntime(store) {
const sdkModuleUrls = [
new URL("../../../wasm-port/runtime/sdk/src/linuxcnc-interp.js", import.meta.url).href,
new URL("../wasm-port/runtime/sdk/src/linuxcnc-interp.js", import.meta.url).href,
];
const errors = [];
for (const sdkModuleUrl of sdkModuleUrls) {
if (typeof Worker === "function") {
try {
const runtime = await createLinuxCncInterpreterWorkerRuntime({ sdkModuleUrl });
store.dispatch({ type: "ATTACH_INTERPRETER_RUNTIME", runtime });
return runtime.readiness();
} catch (error) {
errors.push(`${sdkModuleUrl} worker: ${error.message}`);
}
}
try {
const runtime = await createLinuxCncInterpreterRuntime({ sdkModuleUrl });
store.dispatch({ type: "ATTACH_INTERPRETER_RUNTIME", runtime });
return runtime.readiness();
} catch (error) {
errors.push(`${sdkModuleUrl}: ${error.message}`);
}
}
try {
const runtime = await createLinuxCncInterpreterRuntime();
store.dispatch({ type: "ATTACH_INTERPRETER_RUNTIME", runtime });
return runtime.readiness();
} catch (error) {
errors.push(`default runtime: ${error.message}`);
return {
apiName: "web-rtcp-5axis-linuxcnc-interpreter-runtime-readiness",
loaded: false,
sourceMode: "fixture-line-playback",
error: errors.join(" | "),
};
}
}

View File

@@ -0,0 +1,12 @@
import { xyzacTrtProfile } from "./xyzac-trt.js";
import { xyzbcTrtProfile } from "./xyzbc-trt.js";
export const fiveAxisProfiles = [xyzacTrtProfile, xyzbcTrtProfile];
export function getFiveAxisProfile(profileId = "xyzac-trt") {
const profile = fiveAxisProfiles.find((entry) => entry.id === profileId);
if (!profile) {
throw new Error(`unknown five-axis profile: ${profileId}`);
}
return profile;
}

View File

@@ -71,6 +71,62 @@ export const linuxCncSourceReferenceMap = [
boundary: "linuxcnc_program_reference",
usage: "representative XYZAC switchkins demonstration program",
},
{
id: "xyzbc-trt-ini",
profileId: "xyzbc-trt",
kind: "ini",
path: "configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini",
boundary: "linuxcnc_config_reference",
usage: "machine profile, KINS, TRAJ coordinates, HAL and remap declarations",
},
{
id: "xyzbc-trt-pyvcp",
profileId: "xyzbc-trt",
kind: "pyvcp_xml",
path: "configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.xml",
boundary: "ui_hal_binding_reference",
usage: "SWITCHKINS labels and operator buttons",
},
{
id: "xyzbc-trt-table",
profileId: "xyzbc-trt",
kind: "tool_table",
path: "configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.tbl",
boundary: "linuxcnc_config_reference",
usage: "tool table source for future LinuxCNC-backed runtime session",
},
{
id: "xyzbc-trt-kins",
profileId: "xyzbc-trt",
kind: "kinematics_source",
path: "src/emc/kinematics/xyzbc-trt-kins.c",
boundary: "linuxcnc_source_required",
usage: "final source-derived XYZBC kinematics implementation source",
},
{
id: "trtfuncs-xyzbc",
profileId: "xyzbc-trt",
kind: "kinematics_source",
path: "src/emc/kinematics/trtfuncs.c",
boundary: "linuxcnc_source_required",
usage: "shared table-rotary-tilting kinematics functions",
},
{
id: "switchkins-source-xyzbc",
profileId: "xyzbc-trt",
kind: "kinematics_source",
path: "src/emc/kinematics/switchkins.c",
boundary: "linuxcnc_source_required",
usage: "switchable kinematics behavior backing M428/M429/M430",
},
{
id: "xyzbc-switchkins-demo",
profileId: "xyzbc-trt",
kind: "gcode_demo",
path: "configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzbc_switchkins.ngc",
boundary: "linuxcnc_program_reference",
usage: "representative XYZBC switchkins demonstration program",
},
];
export function getSourceReferencesForProfile(profileId) {

View File

@@ -0,0 +1,136 @@
import { xyzacTrtProfile } from "./xyzac-trt.js";
import { getSourceReferencesForProfile } from "./source-reference-map.js";
import { xyzacTrtPyvcpPanelSchema } from "../panel-schema/xyzac-trt-pyvcp.js";
const sourceReferenceObjects = getSourceReferencesForProfile("xyzbc-trt");
export const xyzbcTrtProfile = {
...xyzacTrtProfile,
id: "xyzbc-trt",
title: "XYZBC table rotary tilting",
iniPath: "configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini",
pyvcpXmlPath: "configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.xml",
generatedHalPath: null,
toolTablePath: "configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.tbl",
machineName: "sim-xyzbc-trt-kins (switchkins)",
display: {
...xyzacTrtProfile.display,
geometry: "XYZB",
openFile: "./demos/xyzbc_switchkins.ngc",
pyvcp: "./xyzbc-trt.xml",
},
rs274ngc: {
...xyzacTrtProfile.rs274ngc,
parameterFile: "xyzbc.var",
},
coordinates: ["X", "Y", "Z", "B", "C"],
joints: ["joint.0", "joint.1", "joint.2", "joint.3", "joint.4"],
kinematics: "xyzbc-trt-kins",
kinematicsModuleId: "xyzbc-trt",
kinematicsParameters: {
...xyzacTrtProfile.kinematicsParameters,
switchkinsTypes: [
{ value: 0, label: "identity", mdiCommand: "M429", webKinsType: "identity" },
{ value: 1, label: "XYZBC TCP", mdiCommand: "M428", webKinsType: "tcp-xyzbc" },
{ value: 2, label: "USERK", mdiCommand: "M430", webKinsType: "userk" },
],
},
hal: {
...xyzacTrtProfile.hal,
halcmd: {
...xyzacTrtProfile.hal.halcmd,
loadusr: ["xyzbc-trt-gui"],
feedbackNets: [
{ 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" },
],
offsetNets: [
{ 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" },
],
initialSets: [
{ pin: "x-offset", value: -20 },
{ pin: "z-offset", value: -15 },
{ pin: "xyzbc-trt-kins.x-rot-point", value: 0 },
{ pin: "xyzbc-trt-kins.y-rot-point", value: 0 },
{ pin: "xyzbc-trt-kins.z-rot-point", value: 0 },
{ pin: "xyzbc-trt-kins.conventional-directions", value: 0 },
],
},
},
traj: {
...xyzacTrtProfile.traj,
coordinates: "XYZBC",
},
axisLimits: {
X: xyzacTrtProfile.axisLimits.X,
Y: xyzacTrtProfile.axisLimits.Y,
Z: xyzacTrtProfile.axisLimits.Z,
B: { min: -36000, max: 36000, maxVelocity: 30, maxAcceleration: 300 },
C: { min: -36000, max: 36000, maxVelocity: 30, maxAcceleration: 300 },
},
jointConfig: [
xyzacTrtProfile.jointConfig[0],
xyzacTrtProfile.jointConfig[1],
xyzacTrtProfile.jointConfig[2],
{ id: 3, axis: "B", type: "ANGULAR", home: 0, min: -100, max: 50, maxVelocity: 30, maxAcceleration: 300 },
{ id: 4, axis: "C", type: "ANGULAR", home: 0, min: -36000, max: 36000, maxVelocity: 30, maxAcceleration: 300 },
],
halPins: [
"motion.switchkins-type",
"motion.analog-out-03",
"motion.tooloffset.z",
"xyzbc-trt-kins.tool-offset",
"xyzbc-trt-kins.x-offset",
"xyzbc-trt-kins.z-offset",
"xyzbc-trt-kins.x-rot-point",
"xyzbc-trt-kins.y-rot-point",
"xyzbc-trt-kins.z-rot-point",
"xyzbc-trt-kins.conventional-directions",
"halui.mdi-command-00",
"halui.mdi-command-01",
"halui.mdi-command-02",
],
offsets: {
x: -20,
z: -15,
xRotPoint: 0,
yRotPoint: 0,
zRotPoint: 0,
conventionalDirections: 0,
},
sourceReferences: sourceReferenceObjects.map((reference) => reference.path),
sourceReferenceObjects,
panelSchema: {
...xyzacTrtPyvcpPanelSchema,
id: "xyzbc-trt-switchkins-pyvcp",
profileId: "xyzbc-trt",
sourceXmlPath: "configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.xml",
groups: xyzacTrtPyvcpPanelSchema.groups.map((group) => ({
...group,
controls: group.controls.map((control) => (
control.id === "kinstype-legends"
? {
...control,
legends: ["0:IDENTITY", "1: XYZBC ", "2: USERK "],
}
: control.id === "type1-button"
? {
...control,
text: "TCP:XYZBC",
webAction: { type: "SET_KINS_TYPE", kinsType: "tcp-xyzbc" },
}
: control
)),
})),
},
samplePrograms: [
"configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzbc_switchkins.ngc",
"linuxcnc/nc_files/3D_Chips.ngc",
],
};

View File

@@ -0,0 +1,166 @@
const LINEAR_AXES = ["x", "y", "z", "u", "v", "w"];
const ANGULAR_AXES = ["a", "b", "c"];
const ALL_AXES = [...LINEAR_AXES, ...ANGULAR_AXES];
export function buildProgramExecutionTiming({
motion = [],
profile = null,
feedOverride = 100,
rapidOverride = 100,
defaultFeedRate = 100,
} = {}) {
const limits = buildVelocityLimits(profile);
const segments = [];
let previousAxes = null;
let elapsedSeconds = 0;
let feedRate = Number(defaultFeedRate) > 0 ? Number(defaultFeedRate) : 100;
for (let index = 0; index < motion.length; index += 1) {
const event = motion[index];
const axes = normalizeAxes(event.axes, previousAxes);
if (Number.isFinite(event.feedRate) && event.feedRate > 0) {
feedRate = event.feedRate;
}
const segment = buildTimingSegment({
event,
index,
axes,
previousAxes: previousAxes || axes,
limits,
feedRate,
feedOverride,
rapidOverride,
elapsedSeconds,
});
elapsedSeconds += segment.durationSeconds;
segments.push({
...segment,
elapsedSeconds,
});
previousAxes = axes;
}
const feedSeconds = segments
.filter((segment) => segment.motionClass === "feed")
.reduce((total, segment) => total + segment.durationSeconds, 0);
const rapidSeconds = segments
.filter((segment) => segment.motionClass === "rapid")
.reduce((total, segment) => total + segment.durationSeconds, 0);
return {
apiName: "web-rtcp-5axis-program-execution-timing",
semanticBoundary: "linuxcnc_canonical_motion_timing_estimate_not_planner_queue",
sourceBasis: "LinuxCNC canonical motion events plus INI/profile velocity limits and feed overrides",
totalSeconds: elapsedSeconds,
totalMinutes: elapsedSeconds / 60,
feedSeconds,
rapidSeconds,
motionCount: segments.length,
segments,
limits,
};
}
export function timingAtMotionIndex(timing, motionIndex = 0) {
const segment = timing?.segments?.[motionIndex] || null;
return {
elapsedSeconds: segment?.elapsedSeconds || 0,
remainingSeconds: Math.max((timing?.totalSeconds || 0) - (segment?.elapsedSeconds || 0), 0),
currentVelocity: segment?.velocityMmPerMin || 0,
segmentDurationSeconds: segment?.durationSeconds || 0,
segmentDistanceMm: segment?.linearDistanceMm || 0,
};
}
function buildTimingSegment({
event,
index,
axes,
previousAxes,
limits,
feedRate,
feedOverride,
rapidOverride,
elapsedSeconds,
}) {
const deltas = Object.fromEntries(ALL_AXES.map((axis) => [axis, axes[axis] - previousAxes[axis]]));
const linearDistanceMm = vectorLength(LINEAR_AXES.map((axis) => deltas[axis]));
const angularDistanceDeg = vectorLength(ANGULAR_AXES.map((axis) => deltas[axis]));
const motionClass = event.type === "STRAIGHT_TRAVERSE" ? "rapid" : "feed";
const requestedLinearVelocity = motionClass === "rapid"
? limits.maxLinearVelocityMmPerMin * percent(rapidOverride)
: Math.max(feedRate, 0) * percent(feedOverride);
const cappedLinearVelocity = Math.min(
requestedLinearVelocity || limits.defaultLinearVelocityMmPerMin,
limits.maxLinearVelocityMmPerMin,
);
const linearSeconds = linearDistanceMm > 0
? linearDistanceMm / Math.max(cappedLinearVelocity / 60, 0.000001)
: 0;
const angularVelocityDegPerMin = motionClass === "rapid"
? limits.maxAngularVelocityDegPerMin * percent(rapidOverride)
: Math.min(Math.max(feedRate, 0) * percent(feedOverride), limits.maxAngularVelocityDegPerMin);
const angularSeconds = angularDistanceDeg > 0
? angularDistanceDeg / Math.max(angularVelocityDegPerMin / 60, 0.000001)
: 0;
const durationSeconds = Math.max(linearSeconds, angularSeconds);
return {
index,
line: event.line,
type: event.type,
motionClass,
linearDistanceMm,
angularDistanceDeg,
feedRate,
requestedVelocityMmPerMin: requestedLinearVelocity,
velocityMmPerMin: cappedLinearVelocity,
angularVelocityDegPerMin,
durationSeconds,
startSeconds: elapsedSeconds,
axes,
deltas,
};
}
function buildVelocityLimits(profile) {
const traj = profile?.traj || {};
const axisLimits = profile?.axisLimits || {};
const maxLinearVelocity = firstFinite(
Number(traj.maxLinearVelocity) * 60,
...LINEAR_AXES.map((axis) => Number(axisLimits[axis.toUpperCase()]?.maxVelocity) * 60),
2100,
);
const defaultLinearVelocity = firstFinite(Number(traj.defaultLinearVelocity) * 60, maxLinearVelocity, 1200);
const maxAngularVelocity = firstFinite(
...ANGULAR_AXES.map((axis) => Number(axisLimits[axis.toUpperCase()]?.maxVelocity) * 60),
maxLinearVelocity,
);
return {
maxLinearVelocityMmPerMin: maxLinearVelocity,
defaultLinearVelocityMmPerMin: defaultLinearVelocity,
maxAngularVelocityDegPerMin: maxAngularVelocity,
};
}
function normalizeAxes(axes = {}, fallback = null) {
return Object.fromEntries(ALL_AXES.map((axis) => [
axis,
Number.isFinite(Number(axes[axis]))
? Number(axes[axis])
: Number(fallback?.[axis] || 0),
]));
}
function vectorLength(values) {
return Math.sqrt(values.reduce((total, value) => total + value * value, 0));
}
function percent(value) {
const number = Number(value);
return Number.isFinite(number) ? Math.max(number, 0) / 100 : 1;
}
function firstFinite(...values) {
return values.find((value) => Number.isFinite(value) && value > 0) || 1;
}

View File

@@ -0,0 +1,271 @@
export const FIVE_AXIS_SESSION_FORMAT = "web-rtcp-5axis-session-snapshot";
export const FIVE_AXIS_SESSION_VERSION = 1;
export const DEFAULT_SESSION_ID = "gmoccapy-web-session";
export const DEFAULT_SESSION_FILENAME = "web-rtcp-5axis-session.json";
export function createFiveAxisSessionPayload(state) {
return validateFiveAxisSessionPayload({
apiName: "web-rtcp-5axis-session-payload",
payloadVersion: 1,
machineProfile: state.machineProfile,
sessionName: state.sessionName,
sourceMode: state.sourceMode,
machine: state.machine,
runState: state.runState,
activeProgram: state.activeProgram,
programSource: state.programSource,
programStartLine: state.programStartLine,
activeLine: state.activeLine,
lineCount: state.lineCount,
fileSizeBytes: state.fileSizeBytes,
programLines: state.programLines,
axisPose: state.axisPose,
jointPose: state.jointPose,
tcpPose: state.tcpPose,
toolAxisVector: state.toolAxisVector,
rtcpState: state.rtcpState,
kinsType: state.kinsType,
feed: state.feed,
spindle: state.spindle,
coolant: state.coolant,
preview: state.preview,
toolPreview: state.toolPreview,
programExecutionSourceMode: state.programExecutionSourceMode,
programExecutionTiming: state.programExecutionTiming,
programElapsedSeconds: state.programElapsedSeconds,
programRemainingSeconds: state.programRemainingSeconds,
programRuntimeFeedback: state.programRuntimeFeedback,
programExecution: state.programExecution
? {
apiName: state.programExecution.apiName,
sourceMode: state.programExecution.sourceMode,
semanticBoundary: state.programExecution.semanticBoundary,
motion: state.programExecution.motion,
plannerTiming: state.programExecution.plannerTiming || null,
summary: state.programExecution.summary,
}
: null,
kinematicsRuntimeReadiness: state.kinematicsRuntimeReadiness,
interpreterRuntimeReadiness: state.interpreterRuntimeReadiness,
linuxCncBoundaryReadiness: state.linuxCncBoundaryReadiness,
});
}
export function createFiveAxisSessionSnapshot(sessionId, payload, options = {}) {
validateSessionId(sessionId);
validateFiveAxisSessionPayload(payload);
return {
format: FIVE_AXIS_SESSION_FORMAT,
version: FIVE_AXIS_SESSION_VERSION,
sessionId,
createdAt: options.createdAt || new Date().toISOString(),
metadata: {
source: "web-rtcp-5axis-sim-plan",
profile: payload.machineProfile,
program: payload.activeProgram,
...(options.metadata || {}),
},
payload,
};
}
export function validateFiveAxisSessionSnapshot(snapshot, sessionId) {
assertPlainObject(snapshot, "five-axis session snapshot");
if (snapshot.format !== FIVE_AXIS_SESSION_FORMAT) {
throw new Error(`Unsupported five-axis session snapshot format: ${snapshot.format}`);
}
if (snapshot.version !== FIVE_AXIS_SESSION_VERSION) {
throw new Error(`Unsupported five-axis session snapshot version: ${snapshot.version}`);
}
if (snapshot.sessionId !== sessionId) {
throw new Error(`Five-axis session snapshot id mismatch: ${snapshot.sessionId}`);
}
assertPlainObject(snapshot.metadata, "five-axis session snapshot metadata");
validateFiveAxisSessionPayload(snapshot.payload);
return snapshot;
}
export async function saveFiveAxisSessionSnapshot(sessionId, payload, options = {}) {
const snapshot = createFiveAxisSessionSnapshot(sessionId, payload, options);
const path = sessionSnapshotPath(sessionId, options.filename);
await saveTextFile(path, `${JSON.stringify(snapshot, null, 2)}\n`, options.storage);
return { snapshot, path };
}
export async function loadFiveAxisSessionSnapshot(sessionId, options = {}) {
const path = sessionSnapshotPath(sessionId, options.filename);
const text = await loadTextFile(path, options.storage);
let snapshot;
try {
snapshot = JSON.parse(text);
} catch (error) {
throw new Error(`Invalid five-axis session snapshot JSON: ${error.message}`);
}
return { snapshot: validateFiveAxisSessionSnapshot(snapshot, sessionId), path };
}
export function restoreFiveAxisSessionState(snapshot) {
const payload = validateFiveAxisSessionPayload(snapshot.payload);
return {
machineProfile: payload.machineProfile,
sessionName: payload.sessionName,
machine: payload.machine,
runState: payload.runState,
activeProgram: payload.activeProgram,
programSource: payload.programSource,
programStartLine: payload.programStartLine,
activeLine: payload.activeLine,
lineCount: payload.lineCount,
fileSizeBytes: payload.fileSizeBytes,
programLines: payload.programLines,
axisPose: payload.axisPose,
rtcpState: payload.rtcpState,
kinsType: payload.kinsType,
feed: payload.feed,
spindle: payload.spindle,
coolant: payload.coolant,
preview: payload.preview,
toolPreview: payload.toolPreview,
programExecutionSourceMode: payload.programExecutionSourceMode,
programExecutionTiming: payload.programExecutionTiming,
programElapsedSeconds: payload.programElapsedSeconds,
programRemainingSeconds: payload.programRemainingSeconds,
programRuntimeFeedback: payload.programRuntimeFeedback,
programExecution: payload.programExecution,
};
}
export function createMemorySessionStorage(seed = {}) {
const files = new Map(Object.entries(seed));
return {
files,
async getDirectory() {
return createDirectoryHandle(files, []);
},
};
}
function validateFiveAxisSessionPayload(payload) {
assertPlainObject(payload, "five-axis session payload");
if (payload.apiName !== "web-rtcp-5axis-session-payload") {
throw new Error(`Unsupported five-axis session payload API: ${payload.apiName}`);
}
if (payload.payloadVersion !== 1) {
throw new Error(`Unsupported five-axis session payload version: ${payload.payloadVersion}`);
}
if (!["xyzac-trt", "xyzbc-trt"].includes(payload.machineProfile)) {
throw new Error(`Unsupported five-axis machine profile: ${payload.machineProfile}`);
}
if (!Array.isArray(payload.programLines)) {
throw new Error("five-axis session programLines must be an array.");
}
if (payload.programExecution !== null) {
assertPlainObject(payload.programExecution, "five-axis session programExecution");
if (!Array.isArray(payload.programExecution.motion)) {
throw new Error("five-axis session programExecution.motion must be an array.");
}
assertPlainObject(payload.programExecution.summary, "five-axis session programExecution.summary");
}
for (const key of ["machine", "axisPose", "feed", "spindle", "coolant", "preview", "toolPreview"]) {
assertPlainObject(payload[key], `five-axis session ${key}`);
}
return payload;
}
function sessionSnapshotPath(sessionId, filename = DEFAULT_SESSION_FILENAME) {
validateSessionId(sessionId);
validateFilename(filename);
return `web-rtcp-5axis-sim-plan/sessions/${sessionId}/${filename}`;
}
async function saveTextFile(path, text, storage = globalThis.navigator?.storage) {
const root = await getStorageRoot(storage);
const dir = await ensureParentDir(root, path);
const filename = splitPath(path).at(-1);
const fileHandle = await dir.getFileHandle(filename, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(text);
await writable.close();
}
async function loadTextFile(path, storage = globalThis.navigator?.storage) {
const root = await getStorageRoot(storage);
const parts = splitPath(path);
let current = root;
for (const part of parts.slice(0, -1)) {
current = await current.getDirectoryHandle(part);
}
const fileHandle = await current.getFileHandle(parts.at(-1));
const file = await fileHandle.getFile();
return file.text();
}
async function getStorageRoot(storage) {
if (!storage?.getDirectory) {
throw new Error("OPFS is not available in this browser.");
}
return storage.getDirectory();
}
async function ensureParentDir(root, path) {
let current = root;
for (const part of splitPath(path).slice(0, -1)) {
current = await current.getDirectoryHandle(part, { create: true });
}
return current;
}
function createDirectoryHandle(files, prefix) {
return {
async getDirectoryHandle(name) {
return createDirectoryHandle(files, [...prefix, name]);
},
async getFileHandle(name) {
const path = [...prefix, name].join("/");
return {
async createWritable() {
let content = "";
return {
async write(text) {
content += String(text);
},
async close() {
files.set(path, content);
},
};
},
async getFile() {
if (!files.has(path)) throw new Error(`Missing memory session file: ${path}`);
return { async text() { return files.get(path); } };
},
};
},
};
}
function splitPath(path) {
const value = String(path || "").replaceAll("\\", "/");
const parts = value.split("/").filter(Boolean);
if (parts.length === 0 || parts.some((part) => part === "." || part === "..")) {
throw new Error(`Invalid session path: ${path}`);
}
return parts;
}
function validateSessionId(sessionId) {
if (!/^[a-zA-Z0-9._-]+$/.test(String(sessionId || ""))) {
throw new Error(`Invalid five-axis session id: ${sessionId}`);
}
}
function validateFilename(filename) {
if (!/^[a-zA-Z0-9._-]+\.json$/.test(String(filename || ""))) {
throw new Error(`Invalid five-axis session filename: ${filename}`);
}
}
function assertPlainObject(value, label) {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`${label} must be a plain object.`);
}
}

View File

@@ -0,0 +1,106 @@
const MACHINE_FILE_FLAGS = [
"fiveaxis_ini_open=1",
"fiveaxis_remaps_ready=1",
"fiveaxis_file_reached_exit=1",
];
export function createFullLinuxCncExecutionBoundary(state = {}) {
const adapter = state.linuxCncBoundaryAdapter || {};
const frame = state.rtcpFrame || {};
const programExecution = state.programExecution || null;
const machineFileExecution = state.machineFileExecution || null;
const machineFileText = String(machineFileExecution?.resultText || "");
const kinematicsReady = Boolean(
adapter.linuxCncKinematicsReady ||
frame.readiness?.linuxCncKinematicsReady,
);
const interpreterReady = Boolean(
adapter.linuxCncInterpreterReady ||
state.interpreterRuntimeReadiness?.loaded,
);
const canonicalProgramReady = Boolean(
programExecution?.sourceMode === "linuxcnc-interpreter-wasm" &&
programExecution?.summary?.motionEventCount > 0,
);
const machineFileStagingReady = state.machineFileStaging?.status === "staged"
&& state.machineFileStaging?.fileCount > 0;
const machineFileRemapReady = Boolean(
machineFileExecution?.sourceMode === "linuxcnc-machine-file-remap-wasm" &&
machineFileExecution?.summary?.machineFileExecutionReady === true &&
MACHINE_FILE_FLAGS.every((flag) => machineFileText.includes(flag)),
);
const plannerRuntimeReady = Boolean(
programExecution?.summary?.plannerRuntimeReady === true &&
programExecution?.plannerTiming?.plannerRuntimeReady === true,
);
const halSwitchkinsEvidenceReady = machineFileText.includes("fiveaxis_hal_switchkins: rc=0 found=1");
const satisfied = [
kinematicsReady ? "linuxcnc-kinematics-wasm" : null,
interpreterReady ? "linuxcnc-interpreter-wasm" : null,
canonicalProgramReady ? "canonical-motion-events" : null,
machineFileStagingReady ? "machine-file-staging" : null,
machineFileRemapReady ? "fiveaxis-remap-machine-file-run" : null,
plannerRuntimeReady ? "linuxcnc-tp-queue-runtime-timing" : null,
halSwitchkinsEvidenceReady ? "switchkins-hal-bridge-evidence" : null,
].filter(Boolean);
const missing = [];
if (!kinematicsReady) missing.push("linuxcnc kinematics WASM frame");
if (!interpreterReady) missing.push("linuxcnc interpreter WASM runtime");
if (!canonicalProgramReady) missing.push("linuxcnc canonical motion execution");
if (!machineFileStagingReady) missing.push("LinuxCNC machine-file staging");
if (!machineFileRemapReady) missing.push("machine-file backed five-axis remap run");
if (!plannerRuntimeReady) missing.push("LinuxCNC trajectory planner queue timing runtime");
if (!halSwitchkinsEvidenceReady) missing.push("switchkins HAL bridge evidence");
const blockers = [
"native LinuxCNC task/NML process is not ported",
"native realtime HAL thread synchronization is not ported",
"external user-M process and full tool DB process are not promoted",
];
if (!plannerRuntimeReady) {
blockers.push("LinuxCNC trajectory planner queue is not promoted as browser runtime");
}
return {
apiName: "web-rtcp-5axis-full-linuxcnc-execution-boundary",
profileId: state.machineProfile || adapter.profileId || "unknown",
phase: machineFileRemapReady
? "partial-linuxcnc-remap-boundary"
: canonicalProgramReady
? "canonical-interpreter-boundary"
: "blocked",
semanticBoundary: machineFileRemapReady
? "linuxcnc_machine_file_remap_ready_planner_task_hal_blocked"
: canonicalProgramReady
? "linuxcnc_interpreter_canonical_ready_planner_task_hal_blocked"
: "linuxcnc_full_execution_boundary_blocked",
sourceMode: machineFileRemapReady
? "linuxcnc-machine-file-remap-wasm"
: programExecution?.sourceMode || state.programExecutionSourceMode || "fixture-line-playback",
readyForUiSimulation: kinematicsReady && interpreterReady && canonicalProgramReady,
machineFileBackedRemapReady: machineFileRemapReady,
remapRuntimeReady: machineFileRemapReady,
halSwitchkinsEvidenceReady,
plannerRuntimeReady,
nativeTaskReady: false,
nativeHalSyncReady: false,
fullLinuxCncProgramExecutionReady: false,
promotionAllowed: false,
satisfied,
missing,
blockers,
evidence: {
kinematics: kinematicsReady ? frame.semanticBoundary || adapter.semanticBoundary : null,
interpreter: interpreterReady ? state.interpreterRuntimeReadiness?.semanticBoundary || adapter.semanticBoundary : null,
canonicalMotionEvents: programExecution?.summary?.motionEventCount || 0,
canonicalEventCount: programExecution?.summary?.canonicalEventCount || 0,
plannerTiming: plannerRuntimeReady ? programExecution.plannerTiming?.semanticBoundary : null,
machineFileFlags: MACHINE_FILE_FLAGS.filter((flag) => machineFileText.includes(flag)),
machineFileExecutionReady: machineFileExecution?.summary?.machineFileExecutionReady === true,
stagedFileCount: state.machineFileStaging?.fileCount || 0,
},
};
}

View File

@@ -4,7 +4,7 @@ import { createProfileSourceReferenceSummary } from "../profiles/source-referenc
export function createLinuxCncBoundaryAdapter({
profile = xyzacTrtProfile,
panelSchema = xyzacTrtPyvcpPanelSchema,
panelSchema = profile.panelSchema || xyzacTrtPyvcpPanelSchema,
runtime = null,
} = {}) {
const sourceSummary = createProfileSourceReferenceSummary(profile.id);
@@ -14,6 +14,8 @@ export function createLinuxCncBoundaryAdapter({
const runtimeReady = kinematicsRuntimeReady && interpreterRuntimeReady;
const linuxCncKinematicsReady = kinematicsRuntimeReady
&& runtime.kinematicsWasm.sourceMode === "source-derived-kinematics-wasm";
const linuxCncInterpreterReady = interpreterRuntimeReady
&& runtime.interpreterWasm.sourceMode === "linuxcnc-interpreter-wasm";
return {
apiName: "web-rtcp-5axis-linuxcnc-boundary-adapter",
@@ -24,13 +26,14 @@ export function createLinuxCncBoundaryAdapter({
runtimeReady,
kinematicsRuntimeReady,
interpreterRuntimeReady,
linuxCncInterpreterReady,
profileSummary: createProfileSummary(profile),
linuxCncKinematicsReady,
promotionAllowed: linuxCncKinematicsReady,
fullLinuxCncProgramExecutionReady: false,
semanticBoundary: linuxCncKinematicsReady
? interpreterRuntimeReady
? "linuxcnc_runtime_supplied_but_interpreter_or_remap_not_promoted"
? linuxCncInterpreterReady
? "linuxcnc_kinematics_and_interpreter_wasm_connected_remap_planner_not_promoted"
: "linuxcnc_kinematics_wasm_runtime_connected"
: "adapter_entrypoint_only_runtime_not_connected",
adapterPoints: {
@@ -74,6 +77,7 @@ export function createLinuxCncBoundaryReadiness(adapter = createLinuxCncBoundary
&& !missing.includes("PyVCP/HAL panel schema"),
missing,
linuxCncKinematicsReady: adapter.linuxCncKinematicsReady,
linuxCncInterpreterReady: adapter.linuxCncInterpreterReady,
promotionAllowed: adapter.promotionAllowed,
fullLinuxCncProgramExecutionReady: adapter.fullLinuxCncProgramExecutionReady,
semanticBoundary: adapter.semanticBoundary,

View File

@@ -0,0 +1,345 @@
const AXIS_SECTION_RE = /^AXIS_([A-Z])$/;
const JOINT_SECTION_RE = /^JOINT_(\d+)$/;
export function parseLinuxCncIni(text, { path = "inline.ini", profileId = "unknown" } = {}) {
const sections = parseIniSections(text);
const kinsText = getFirstValue(sections, "KINS", "KINEMATICS") || "";
const coordinates = getFirstValue(sections, "TRAJ", "COORDINATES") || "";
const jointCount = numberOrNull(getFirstValue(sections, "KINS", "JOINTS"));
const axisLimits = parseAxisLimits(sections);
const jointConfig = parseJointConfig(sections, coordinates);
const remaps = parseRemaps(sections);
const hal = parseHal(sections);
const display = parseDisplay(sections);
const halui = {
mdiCommands: getValues(sections, "HALUI", "MDI_COMMAND"),
};
const kinematicsModuleId = inferKinematicsModuleId(kinsText);
return {
apiName: "web-rtcp-5axis-linuxcnc-ini-config",
profileId,
path,
machineName: getFirstValue(sections, "EMC", "MACHINE") || null,
kinematics: parseKinematics(kinsText),
kinematicsModuleId,
kinematicsParameters: {
sparm: parseKinematicsParameter(kinsText, "sparm"),
joints: jointCount,
switchkinsTypes: inferSwitchkinsTypes({ coordinates, halui, kinematicsModuleId }),
},
traj: {
coordinates,
linearUnits: getFirstValue(sections, "TRAJ", "LINEAR_UNITS") || null,
angularUnits: getFirstValue(sections, "TRAJ", "ANGULAR_UNITS") || null,
defaultLinearVelocity: numberOrNull(getFirstValue(sections, "TRAJ", "DEFAULT_LINEAR_VELOCITY")),
maxLinearVelocity: numberOrNull(getFirstValue(sections, "TRAJ", "MAX_LINEAR_VELOCITY")),
defaultLinearAcceleration: numberOrNull(getFirstValue(sections, "TRAJ", "DEFAULT_LINEAR_ACCELERATION")),
maxLinearAcceleration: numberOrNull(getFirstValue(sections, "TRAJ", "MAX_LINEAR_ACCELERATION")),
},
display,
rs274ngc: {
subroutinePath: getFirstValue(sections, "RS274NGC", "SUBROUTINE_PATH") || null,
halPinVars: boolFromIni(getFirstValue(sections, "RS274NGC", "HAL_PIN_VARS")),
parameterFile: getFirstValue(sections, "RS274NGC", "PARAMETER_FILE") || null,
remaps,
},
hal,
halui,
axisLimits,
jointConfig,
emcio: {
toolTable: getFirstValue(sections, "EMCIO", "TOOL_TABLE") || null,
},
validation: validateIniConfig({ coordinates, jointCount, axisLimits, jointConfig, kinsText }),
semanticBoundary: "linuxcnc_ini_file_browser_parser",
};
}
export async function loadLinuxCncIniConfig(profile, { baseUrl = import.meta.url, fetchImpl = globalThis.fetch } = {}) {
if (!profile?.iniPath) {
throw new Error("profile is missing iniPath");
}
if (typeof fetchImpl !== "function") {
throw new Error("fetch is not available for LinuxCNC INI loading");
}
const candidateUrls = [
new URL(`../../${profile.iniPath}`, baseUrl),
new URL(`../../../../wasm-port/vendor/linuxcnc/${profile.iniPath}`, baseUrl),
];
const errors = [];
let response = null;
for (const url of candidateUrls) {
try {
response = await fetchImpl(url.href);
if (response.ok) break;
errors.push(`${url.href}: HTTP ${response.status}`);
response = null;
} catch (error) {
errors.push(`${url.href}: ${error.message}`);
}
}
if (!response) {
throw new Error(`failed to load LinuxCNC INI ${profile.iniPath}: ${errors.join(" | ")}`);
}
return parseLinuxCncIni(await response.text(), {
path: profile.iniPath,
profileId: profile.id,
});
}
export function applyIniConfigToProfile(profile, iniConfig) {
if (!iniConfig) return profile;
return {
...profile,
machineName: iniConfig.machineName || profile.machineName,
kinematics: iniConfig.kinematics.name || profile.kinematics,
kinematicsModuleId: iniConfig.kinematicsModuleId || profile.kinematicsModuleId,
kinematicsParameters: {
...profile.kinematicsParameters,
...iniConfig.kinematicsParameters,
switchkinsTypes: mergeSwitchkinsTypes(
profile.kinematicsParameters?.switchkinsTypes || [],
iniConfig.kinematicsParameters.switchkinsTypes,
),
},
display: {
...profile.display,
...iniConfig.display,
},
rs274ngc: {
...profile.rs274ngc,
subroutinePath: iniConfig.rs274ngc.subroutinePath || profile.rs274ngc?.subroutinePath,
halPinVars: iniConfig.rs274ngc.halPinVars ?? profile.rs274ngc?.halPinVars,
parameterFile: iniConfig.rs274ngc.parameterFile || profile.rs274ngc?.parameterFile,
},
hal: {
...profile.hal,
halui: iniConfig.hal.halui || profile.hal?.halui,
halFiles: iniConfig.hal.halFiles.length > 0 ? iniConfig.hal.halFiles : profile.hal?.halFiles,
postguiHalFiles: iniConfig.hal.postguiHalFiles.length > 0
? iniConfig.hal.postguiHalFiles
: profile.hal?.postguiHalFiles,
halcmd: {
...profile.hal?.halcmd,
raw: iniConfig.hal.halcmd,
initialSets: iniConfig.hal.initialSets.length > 0
? iniConfig.hal.initialSets
: profile.hal?.halcmd?.initialSets,
},
},
halui: iniConfig.halui.mdiCommands.length > 0 ? iniConfig.halui : profile.halui,
traj: {
...profile.traj,
...dropNullish(iniConfig.traj),
},
axisLimits: Object.keys(iniConfig.axisLimits).length > 0 ? iniConfig.axisLimits : profile.axisLimits,
jointConfig: iniConfig.jointConfig.length > 0 ? iniConfig.jointConfig : profile.jointConfig,
linuxCncIniConfig: iniConfig,
};
}
function parseIniSections(text) {
const sections = new Map();
let current = null;
for (const rawLine of String(text).split(/\r?\n/)) {
const line = stripIniComment(rawLine).trim();
if (!line) continue;
const sectionMatch = line.match(/^\[([^\]]+)]$/);
if (sectionMatch) {
current = sectionMatch[1].trim().toUpperCase();
if (!sections.has(current)) sections.set(current, new Map());
continue;
}
if (!current) continue;
const equals = line.indexOf("=");
if (equals < 0) continue;
const key = line.slice(0, equals).trim().toUpperCase();
const value = line.slice(equals + 1).trim();
const section = sections.get(current);
if (!section.has(key)) section.set(key, []);
section.get(key).push(value);
}
return sections;
}
function stripIniComment(line) {
let quote = null;
for (let index = 0; index < line.length; index += 1) {
const char = line[index];
if ((char === "\"" || char === "'") && line[index - 1] !== "\\") {
quote = quote === char ? null : quote || char;
}
if (!quote && (char === "#" || char === ";")) {
return line.slice(0, index);
}
}
return line;
}
function getValues(sections, sectionName, key) {
return sections.get(sectionName.toUpperCase())?.get(key.toUpperCase()) || [];
}
function getFirstValue(sections, sectionName, key) {
return getValues(sections, sectionName, key)[0] ?? null;
}
function parseAxisLimits(sections) {
const result = {};
for (const [sectionName, values] of sections) {
const match = sectionName.match(AXIS_SECTION_RE);
if (!match) continue;
result[match[1]] = {
min: numberOrNull(first(values, "MIN_LIMIT")),
max: numberOrNull(first(values, "MAX_LIMIT")),
maxVelocity: numberOrNull(first(values, "MAX_VELOCITY")),
maxAcceleration: numberOrNull(first(values, "MAX_ACCELERATION")),
};
}
return result;
}
function parseJointConfig(sections, coordinates) {
const axisOrder = String(coordinates || "").split("");
return [...sections.entries()]
.map(([sectionName, values]) => {
const match = sectionName.match(JOINT_SECTION_RE);
if (!match) return null;
const id = Number(match[1]);
return {
id,
axis: axisOrder[id] || null,
type: first(values, "TYPE") || null,
home: numberOrNull(first(values, "HOME")),
min: numberOrNull(first(values, "MIN_LIMIT")),
max: numberOrNull(first(values, "MAX_LIMIT")),
maxVelocity: numberOrNull(first(values, "MAX_VELOCITY")),
maxAcceleration: numberOrNull(first(values, "MAX_ACCELERATION")),
homeSearchVelocity: numberOrNull(first(values, "HOME_SEARCH_VEL")),
homeSequence: numberOrNull(first(values, "HOME_SEQUENCE")),
};
})
.filter(Boolean)
.sort((left, right) => left.id - right.id);
}
function parseRemaps(sections) {
return getValues(sections, "RS274NGC", "REMAP").map((value) => {
const code = value.match(/\bM\d+\b/i)?.[0]?.toUpperCase() || null;
const ngc = value.match(/\bngc=([^\s]+)/i)?.[1] || null;
const modalGroup = numberOrNull(value.match(/\bmodalgroup=(\d+)/i)?.[1]);
return { raw: value, code, modalGroup, ngc };
});
}
function parseHal(sections) {
const halcmd = getValues(sections, "HAL", "HALCMD");
return {
halui: getFirstValue(sections, "HAL", "HALUI") || null,
halFiles: getValues(sections, "HAL", "HALFILE"),
postguiHalFiles: getValues(sections, "HAL", "POSTGUI_HALFILE"),
halcmd,
initialSets: halcmd
.map((line) => line.match(/^\s*(sets|setp)\s+:?([^\s]+)\s+([-+0-9.eE]+)/i))
.filter(Boolean)
.map((match) => ({ op: match[1], pin: match[2], value: Number(match[3]) })),
};
}
function parseDisplay(sections) {
const jogAxes = getFirstValue(sections, "DISPLAY", "JOG_AXES");
return dropNullish({
geometry: getFirstValue(sections, "DISPLAY", "GEOMETRY"),
display: getFirstValue(sections, "DISPLAY", "DISPLAY"),
jogAxes: jogAxes ? jogAxes.split("") : null,
pyvcp: getFirstValue(sections, "DISPLAY", "PYVCP"),
openFile: getFirstValue(sections, "DISPLAY", "OPEN_FILE"),
programPrefix: getFirstValue(sections, "DISPLAY", "PROGRAM_PREFIX"),
positionOffset: getFirstValue(sections, "DISPLAY", "POSITION_OFFSET"),
positionFeedback: getFirstValue(sections, "DISPLAY", "POSITION_FEEDBACK"),
maxFeedOverride: numberOrNull(getFirstValue(sections, "DISPLAY", "MAX_FEED_OVERRIDE")),
maxLinearVelocity: numberOrNull(getFirstValue(sections, "DISPLAY", "MAX_LINEAR_VELOCITY")),
maxAngularVelocity: numberOrNull(getFirstValue(sections, "DISPLAY", "MAX_ANGULAR_VELOCITY")),
});
}
function parseKinematics(text) {
const [name, ...parameters] = String(text || "").trim().split(/\s+/).filter(Boolean);
return {
name: name || null,
raw: text,
parameters,
};
}
function parseKinematicsParameter(text, key) {
return String(text || "").match(new RegExp(`\\b${key}=([^\\s]+)`, "i"))?.[1] || null;
}
function inferKinematicsModuleId(kinsText) {
const name = parseKinematics(kinsText).name || "";
if (name.includes("xyzbc")) return "xyzbc-trt";
if (name.includes("xyzac")) return "xyzac-trt";
return name.replace(/-kins$/, "") || null;
}
function inferSwitchkinsTypes({ coordinates, halui, kinematicsModuleId }) {
const mdiCommands = halui.mdiCommands.length > 0 ? halui.mdiCommands : ["M429", "M428", "M430"];
const tcpType = coordinates === "XYZBC" ? "tcp-xyzbc" : "tcp-xyzac";
const tcpLabel = `${coordinates || kinematicsModuleId || "TCP"} TCP`;
return mdiCommands.map((command, index) => ({
value: index === 0 ? 0 : index,
label: index === 0 ? "identity" : index === 1 ? tcpLabel : "USERK",
mdiCommand: command,
webKinsType: index === 0 ? "identity" : index === 1 ? tcpType : "userk",
}));
}
function validateIniConfig({ coordinates, jointCount, axisLimits, jointConfig, kinsText }) {
const missing = [];
if (!coordinates) missing.push("TRAJ.COORDINATES");
if (!jointCount) missing.push("KINS.JOINTS");
if (!kinsText) missing.push("KINS.KINEMATICS");
for (const axis of String(coordinates || "").split("")) {
if (!axisLimits[axis]) missing.push(`AXIS_${axis}`);
}
if (jointCount && jointConfig.length !== jointCount) {
missing.push(`JOINT_ count ${jointConfig.length}/${jointCount}`);
}
return {
ready: missing.length === 0,
missing,
axisCount: Object.keys(axisLimits).length,
jointCount: jointConfig.length,
};
}
function first(values, key) {
return values.get(key)?.[0] ?? null;
}
function numberOrNull(value) {
if (value === null || value === undefined || value === "") return null;
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
function boolFromIni(value) {
if (value === null || value === undefined) return null;
return ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase());
}
function dropNullish(object) {
return Object.fromEntries(Object.entries(object).filter(([, value]) => value !== null && value !== undefined));
}
function mergeSwitchkinsTypes(profileTypes, iniTypes) {
if (!iniTypes?.length) return profileTypes;
return iniTypes.map((iniType) => ({
...iniType,
...(profileTypes.find((entry) => entry.mdiCommand === iniType.mdiCommand || entry.value === iniType.value) || {}),
...iniType,
}));
}

View File

@@ -0,0 +1,465 @@
const DEFAULT_SDK_MODULE_URL = "../../../../wasm-port/runtime/sdk/src/linuxcnc-interp.js";
const DEFAULT_TP_SDK_MODULE_URL = "../../../../wasm-port/runtime/sdk/src/linuxcnc-tp.js";
const SOURCE_MODE = "linuxcnc-interpreter-wasm";
const SEMANTIC_BOUNDARY = "linuxcnc_interpreter_wasm_canonical_events";
const PLANNER_TIMING_BOUNDARY = "linuxcnc_tp_queue_runtime_timing_from_canonical_motion";
const SWITCHKINS_REMAP_BOUNDARY = "linuxcnc_switchkins_remap_mcode_preserved_web_runtime_applied";
const FIVE_AXIS_REMAP_FLAGS = [
"fiveaxis_ini_open=1",
"fiveaxis_remaps_ready=1",
"fiveaxis_file_reached_exit=1",
];
const AXES = ["x", "y", "z", "a", "b", "c", "u", "v", "w"];
const SWITCHKINS_M_CODES = new Map([
[428, { switchkinsType: 1, requestedKinsType: "tcp" }],
[429, { switchkinsType: 0, requestedKinsType: "identity" }],
[430, { switchkinsType: 2, requestedKinsType: "userk" }],
]);
const PLANE_AXIS_MAP = {
170: ["x", "y", "z"],
180: ["x", "z", "y"],
190: ["y", "z", "x"],
};
export async function createLinuxCncInterpreterRuntime({
moduleOptions = null,
tpModuleOptions = null,
wasmRoot = null,
sdkModuleUrl = DEFAULT_SDK_MODULE_URL,
tpSdkModuleUrl = DEFAULT_TP_SDK_MODULE_URL,
} = {}) {
const { createLinuxCncInterpSdk } = await import(sdkModuleUrl);
const resolvedModuleOptions = moduleOptions || await createDefaultModuleOptions({ wasmRoot });
const sdk = await createLinuxCncInterpSdk(resolvedModuleOptions);
const tpRuntime = await createOptionalTpRuntime({ tpModuleOptions, wasmRoot, tpSdkModuleUrl });
return {
apiName: "web-rtcp-5axis-linuxcnc-interpreter-runtime",
loaded: true,
sourceMode: SOURCE_MODE,
semanticBoundary: SEMANTIC_BOUNDARY,
executionContext: "direct",
sdk,
tpRuntime,
readiness() {
return {
apiName: "web-rtcp-5axis-linuxcnc-interpreter-runtime-readiness",
loaded: true,
sourceMode: SOURCE_MODE,
semanticBoundary: SEMANTIC_BOUNDARY,
executionContext: "direct",
runProgramReady: typeof sdk.runProgram === "function",
remapRuntimeReady: false,
plannerRuntimeReady: tpRuntime?.loaded === true,
plannerSemanticBoundary: tpRuntime?.semanticBoundary || null,
};
},
runProgram(programText) {
const prepared = prepareLinuxCncProgramForRuntime(programText);
const resultText = sdk.runProgram(prepared.runtimeProgramText);
const motion = parseLinuxCncCanonicalMotion(resultText, programText, prepared.switchkinsEvents);
return createProgramExecutionResult({
programText,
resultText: prependRuntimeEvents(resultText, prepared.switchkinsEvents),
motion,
switchkinsEvents: prepared.switchkinsEvents,
runtimeProgramText: prepared.runtimeProgramText,
plannerTiming: runPlannerTiming(tpRuntime, motion),
});
},
runMachineFileProgram({ plan, files = null, executionMode = "fiveAxisRemap" } = {}) {
if (!plan?.wasmIniPath || !plan?.wasmProgramPath) {
throw new Error("runMachineFileProgram requires a machine-file staging plan with INI and program paths");
}
if (typeof sdk.runSimConfigProgram !== "function") {
throw new Error("LinuxCNC interpreter SDK missing runSimConfigProgram");
}
const stagedFiles = files || plan.files;
const resultText = sdk.runSimConfigProgram({
files: stagedFiles.map((file) => ({
path: file.wasmPath || file.path,
text: file.text,
executable: file.executable,
})),
programPath: plan.wasmProgramPath,
iniPath: plan.wasmIniPath,
executionMode,
});
const programFile = stagedFiles.find((file) => (
(file.wasmPath || file.path) === plan.wasmProgramPath
));
const programText = programFile?.text || "";
const motion = parseLinuxCncCanonicalMotion(resultText, programText);
return createProgramExecutionResult({
programText,
resultText,
motion,
machineFilePlan: plan,
sourceMode: "linuxcnc-machine-file-remap-wasm",
semanticBoundary: "linuxcnc_fiveaxis_remap_wasm_machine_file_execution",
plannerTiming: runPlannerTiming(tpRuntime, motion),
});
},
};
}
export function createLinuxCncInterpreterRuntimeDescriptor(runtime) {
if (!runtime?.loaded) return null;
return {
apiName: runtime.apiName,
loaded: runtime.loaded,
sourceMode: runtime.sourceMode,
semanticBoundary: runtime.semanticBoundary,
executionContext: runtime.executionContext || "direct",
};
}
function createProgramExecutionResult({
programText,
resultText,
motion,
switchkinsEvents = [],
runtimeProgramText = programText,
machineFilePlan = null,
sourceMode = SOURCE_MODE,
semanticBoundary = SEMANTIC_BOUNDARY,
plannerTiming = null,
}) {
const canonicalEventCount = String(resultText).split("\n").filter((line) => line.startsWith("canon_event=")).length;
const machineFileExecutionReady = Boolean(
machineFilePlan && FIVE_AXIS_REMAP_FLAGS.every((flag) => String(resultText).includes(flag)),
);
const plannerRuntimeReady = plannerTiming?.plannerRuntimeReady === true
&& plannerTiming.motionCount === motion.length;
return {
apiName: "web-rtcp-5axis-linuxcnc-interpreter-program-execution",
sourceMode,
semanticBoundary,
resultText,
runtimeProgramText,
motion,
plannerTiming,
switchkinsEvents,
switchkinsRemapBoundary: switchkinsEvents.length > 0 ? SWITCHKINS_REMAP_BOUNDARY : null,
machineFilePlan: machineFilePlan
? {
apiName: machineFilePlan.apiName,
profileId: machineFilePlan.profileId,
wasmIniPath: machineFilePlan.wasmIniPath,
wasmProgramPath: machineFilePlan.wasmProgramPath,
selectedProgramSourceRel: machineFilePlan.selectedProgramSourceRel || null,
selectedProgramFilename: machineFilePlan.selectedProgramFilename || null,
fileCount: machineFilePlan.files?.length ?? 0,
semanticBoundary: machineFilePlan.semanticBoundary,
}
: null,
summary: {
ready: motion.length > 0,
programLineCount: programText.split(/\r?\n/).filter((line) => line.trim()).length,
canonicalEventCount,
motionEventCount: motion.length,
motionTypes: [...new Set(motion.map((event) => event.type))],
finalAxes: motion.at(-1)?.axes || Object.fromEntries(AXES.map((axis) => [axis, 0])),
switchkinsEventCount: switchkinsEvents.length,
switchkinsCodes: [...new Set(switchkinsEvents.map((event) => event.code))],
switchkinsRemapBoundary: switchkinsEvents.length > 0 ? SWITCHKINS_REMAP_BOUNDARY : null,
remapRuntimeReady: machineFileExecutionReady,
plannerRuntimeReady,
plannerSemanticBoundary: plannerRuntimeReady ? PLANNER_TIMING_BOUNDARY : null,
machineFileExecutionReady,
fullLinuxCncProgramExecutionReady: false,
},
};
}
async function createOptionalTpRuntime({ tpModuleOptions, wasmRoot, tpSdkModuleUrl }) {
try {
const { createLinuxCncTpSdk } = await import(tpSdkModuleUrl);
const resolvedOptions = tpModuleOptions || await createDefaultTpModuleOptions({ wasmRoot });
const sdk = await createLinuxCncTpSdk(resolvedOptions);
return {
loaded: true,
semanticBoundary: PLANNER_TIMING_BOUNDARY,
sdk,
};
} catch (error) {
return {
loaded: false,
semanticBoundary: null,
error: error instanceof Error ? error.message : String(error),
};
}
}
function runPlannerTiming(tpRuntime, motion) {
if (!tpRuntime?.loaded || typeof tpRuntime.sdk?.runCanonicalMotionTiming !== "function") {
return null;
}
try {
return tpRuntime.sdk.runCanonicalMotionTiming({
motion,
options: {
cycleTime: 0.001,
queueSize: 32,
maxCycles: 2000000,
sampleStride: 10,
maxVelocity: 35,
maxAcceleration: 500,
maxJerk: 1000,
tolerance: 0,
},
});
} catch (error) {
return {
apiName: "web-rtcp-5axis-linuxcnc-tp-program-timing",
semanticBoundary: PLANNER_TIMING_BOUNDARY,
plannerRuntimeReady: false,
error: error instanceof Error ? error.message : String(error),
motionCount: motion.length,
totalSeconds: 0,
totalMinutes: 0,
feedSeconds: 0,
rapidSeconds: 0,
segments: [],
};
}
}
export function parseLinuxCncCanonicalMotion(resultText, programText = "", switchkinsEvents = []) {
const axes = Object.fromEntries(AXES.map((axis) => [axis, 0]));
const sourceLines = programLineMap(programText);
const feedRatesByLine = feedRatesBySourceLine(programText);
const switchkinsByLine = switchkinsEventsByLine(switchkinsEvents);
const motion = [];
let activePlane = 170;
let activeSwitchkinsEvent = null;
let activeFeedRate = null;
for (const line of String(resultText).split("\n")) {
const feedRate = readCanonicalNumber(line, "feed_rate");
if (Number.isFinite(feedRate) && feedRate > 0) {
activeFeedRate = feedRate;
}
const plane = readCanonicalNumber(line, "plane");
if (plane && PLANE_AXIS_MAP[plane]) {
activePlane = plane;
}
const event = line.match(/^canon_event=(STRAIGHT_TRAVERSE|STRAIGHT_FEED|ARC_FEED)\b/);
if (!event) continue;
if (event[1] === "ARC_FEED") {
const [firstAxis, secondAxis, thirdAxis] = PLANE_AXIS_MAP[activePlane] ?? PLANE_AXIS_MAP[170];
const firstEnd = readCanonicalNumber(line, "first_end");
const secondEnd = readCanonicalNumber(line, "second_end");
const axisEndPoint = readCanonicalNumber(line, "axis_end_point");
if (Number.isFinite(firstEnd)) axes[firstAxis] = firstEnd;
if (Number.isFinite(secondEnd)) axes[secondAxis] = secondEnd;
if (Number.isFinite(axisEndPoint)) axes[thirdAxis] = axisEndPoint;
axes.arc = {
plane: activePlane,
firstAxis,
secondAxis,
thirdAxis,
firstEnd,
secondEnd,
centerFirst: readCanonicalNumber(line, "first_axis"),
centerSecond: readCanonicalNumber(line, "second_axis"),
rotation: readCanonicalNumber(line, "rotation"),
axisEndPoint,
};
} else {
for (const axis of AXES) {
const value = readCanonicalNumber(line, axis);
if (value !== null && Number.isFinite(value)) axes[axis] = value;
}
}
const sourceLine = readCanonicalNumber(line, "line");
if (Number.isFinite(sourceLine)) {
const event = latestSwitchkinsEventAtOrBeforeLine(switchkinsByLine, sourceLine);
if (event) activeSwitchkinsEvent = event;
const sourceFeedRate = latestFeedRateAtOrBeforeLine(feedRatesByLine, sourceLine);
if (Number.isFinite(sourceFeedRate) && sourceFeedRate > 0) {
activeFeedRate = sourceFeedRate;
}
}
motion.push({
type: event[1],
line: Number.isFinite(sourceLine) ? sourceLine : null,
statement: Number.isFinite(sourceLine) ? (sourceLines.get(sourceLine) ?? "-") : "-",
axes: { ...axes },
kinsType: activeSwitchkinsEvent?.requestedKinsType || null,
switchkinsType: activeSwitchkinsEvent?.switchkinsType ?? null,
switchkinsCode: activeSwitchkinsEvent?.code || null,
switchkinsRemapBoundary: activeSwitchkinsEvent ? SWITCHKINS_REMAP_BOUNDARY : null,
feedRate: activeFeedRate,
raw: line,
});
}
return motion;
}
function feedRatesBySourceLine(programText) {
const rates = [];
String(programText).split(/\r?\n/).forEach((line, index) => {
const codeOnly = stripComments(line);
let feedRate = null;
for (const match of codeOnly.matchAll(/\bF\s*([-+]?\d+(?:\.\d+)?)/gi)) {
const value = Number(match[1]);
if (Number.isFinite(value) && value > 0) feedRate = value;
}
if (feedRate !== null) {
rates.push({ line: index + 1, feedRate });
}
});
return rates;
}
function latestFeedRateAtOrBeforeLine(rates, sourceLine) {
let feedRate = null;
for (const entry of rates) {
if (entry.line <= sourceLine) feedRate = entry.feedRate;
}
return feedRate;
}
export function prepareLinuxCncProgramForRuntime(programText) {
const switchkinsEvents = [];
const runtimeLines = String(programText).split(/\r?\n/).map((line, index) => {
const lineNumber = index + 1;
const events = readSwitchkinsEventsFromLine(line, lineNumber);
if (events.length === 0) return line;
switchkinsEvents.push(...events);
return stripSwitchkinsMcodesFromLine(line, events);
});
return {
runtimeProgramText: runtimeLines.join("\n"),
switchkinsEvents,
};
}
function readSwitchkinsEventsFromLine(line, lineNumber) {
const codeOnly = stripComments(String(line));
const events = [];
for (const match of codeOnly.matchAll(/\bM\s*([0-9]+(?:\.[0-9]+)?)\b/gi)) {
const value = Number(match[1]);
const mCode = Number.isInteger(value) ? value : null;
const switchkins = SWITCHKINS_M_CODES.get(mCode);
if (!switchkins) continue;
events.push({
line: lineNumber,
code: `M${mCode}`,
switchkinsType: switchkins.switchkinsType,
requestedKinsType: switchkins.requestedKinsType,
semanticBoundary: SWITCHKINS_REMAP_BOUNDARY,
});
}
return events;
}
function stripSwitchkinsMcodesFromLine(line, events) {
const eventCodes = new Set(events.map((event) => event.code.slice(1)));
let nextLine = String(line).replace(/\bM\s*([0-9]+(?:\.[0-9]+)?)\b/gi, (token, value) => {
const numericValue = Number(value);
if (Number.isInteger(numericValue) && eventCodes.has(String(numericValue))) {
return " ";
}
return token;
});
const codeOnly = stripComments(nextLine).replace(/\bN\s*[0-9]+\b/gi, "").trim();
if (!codeOnly) {
nextLine = `(web runtime switchkins ${events.map((event) => event.code).join(" ")})`;
}
return nextLine;
}
function stripComments(line) {
return String(line)
.replace(/\([^)]*\)/g, " ")
.replace(/;.*$/g, " ");
}
function switchkinsEventsByLine(events) {
return [...events]
.filter((event) => Number.isFinite(event.line))
.sort((left, right) => left.line - right.line);
}
function latestSwitchkinsEventAtOrBeforeLine(events, lineNumber) {
let latest = null;
for (const event of events) {
if (event.line > lineNumber) break;
latest = event;
}
return latest;
}
function prependRuntimeEvents(resultText, switchkinsEvents) {
if (switchkinsEvents.length === 0) return resultText;
const eventLines = switchkinsEvents.map((event) => (
`web_runtime_event=SWITCHKINS line=${event.line} code=${event.code} switchkins_type=${event.switchkinsType} requested_kins=${event.requestedKinsType}`
));
return `${eventLines.join("\n")}\n${resultText}`;
}
function programLineMap(programText) {
const lines = new Map();
programText.split(/\r?\n/).forEach((line, index) => {
lines.set(index + 1, line.trim() || "(blank)");
});
return lines;
}
function readCanonicalNumber(line, field) {
const match = String(line).match(new RegExp(`\\b${field}=([-+0-9.eE]+)`));
return match ? Number(match[1]) : null;
}
async function createDefaultModuleOptions({ wasmRoot }) {
const quietOptions = { print() {}, printErr() {} };
if (!isNodeRuntime()) return quietOptions;
const [{ readFileSync }, { dirname, resolve }, { fileURLToPath }] = await Promise.all([
import("node:fs"),
import("node:path"),
import("node:url"),
]);
const moduleDir = dirname(fileURLToPath(import.meta.url));
const resolvedWasmRoot = wasmRoot || resolve(moduleDir, "../../../../wasm-port/build/wasm/core");
return {
...quietOptions,
wasmBinary: readFileSync(resolve(resolvedWasmRoot, "linuxcnc_interp.wasm")),
};
}
async function createDefaultTpModuleOptions({ wasmRoot }) {
const quietOptions = { print() {}, printErr() {} };
if (!isNodeRuntime()) return quietOptions;
const [{ readFileSync }, { dirname, resolve }, { fileURLToPath }] = await Promise.all([
import("node:fs"),
import("node:path"),
import("node:url"),
]);
const moduleDir = dirname(fileURLToPath(import.meta.url));
const resolvedWasmRoot = wasmRoot || resolve(moduleDir, "../../../../wasm-port/build/wasm/tp");
return {
...quietOptions,
wasmBinary: readFileSync(resolve(resolvedWasmRoot, "linuxcnc_tp.wasm")),
};
}
function isNodeRuntime() {
return typeof process === "object"
&& typeof process.versions === "object"
&& typeof process.versions.node === "string"
&& process.type !== "renderer";
}

View File

@@ -0,0 +1,80 @@
const SOURCE_MODE = "linuxcnc-interpreter-wasm";
const SEMANTIC_BOUNDARY = "linuxcnc_interpreter_wasm_canonical_events";
export async function createLinuxCncInterpreterWorkerRuntime({
sdkModuleUrl,
workerUrl = new URL("./linuxcnc-interpreter-worker.js", import.meta.url).href,
} = {}) {
if (typeof Worker !== "function") {
throw new Error("Web Worker is not available in this runtime");
}
const worker = new Worker(workerUrl, { type: "module" });
const request = createWorkerRequest(worker);
const readiness = await request("init", { sdkModuleUrl });
const runtime = {
apiName: "web-rtcp-5axis-linuxcnc-interpreter-worker-runtime",
loaded: true,
sourceMode: SOURCE_MODE,
semanticBoundary: SEMANTIC_BOUNDARY,
executionContext: "worker",
workerUrl,
readiness() {
return {
...readiness,
apiName: "web-rtcp-5axis-linuxcnc-interpreter-worker-runtime-readiness",
executionContext: "worker",
workerUrl,
};
},
runProgram(programText) {
return request("runProgram", { programText });
},
runMachineFileProgram(options = {}) {
return request("runMachineFileProgram", options);
},
terminate() {
worker.terminate();
},
};
return runtime;
}
function createWorkerRequest(worker) {
let nextId = 1;
const pending = new Map();
worker.addEventListener("message", (event) => {
const { id, ok, value, error } = event.data || {};
const request = pending.get(id);
if (!request) return;
pending.delete(id);
if (ok) {
request.resolve(value);
} else {
request.reject(new Error(error || "LinuxCNC interpreter worker request failed"));
}
});
worker.addEventListener("error", (event) => {
const error = new Error(event.message || "LinuxCNC interpreter worker error");
for (const request of pending.values()) {
request.reject(error);
}
pending.clear();
});
return function request(type, payload = {}) {
const id = nextId++;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
worker.postMessage({ id, type, payload });
});
};
}

View File

@@ -0,0 +1,49 @@
import { createLinuxCncInterpreterRuntime } from "./linuxcnc-interpreter-runtime.js";
let runtime = null;
self.addEventListener("message", async (event) => {
const { id, type, payload = {} } = event.data || {};
try {
if (type === "init") {
runtime = await createLinuxCncInterpreterRuntime({
moduleOptions: payload.moduleOptions,
wasmRoot: payload.wasmRoot,
sdkModuleUrl: payload.sdkModuleUrl,
});
postSuccess(id, runtime.readiness());
return;
}
if (!runtime?.loaded) {
throw new Error("LinuxCNC interpreter worker runtime is not initialized");
}
if (type === "readiness") {
postSuccess(id, runtime.readiness());
return;
}
if (type === "runProgram") {
postSuccess(id, runtime.runProgram(payload.programText || ""));
return;
}
if (type === "runMachineFileProgram") {
postSuccess(id, runtime.runMachineFileProgram(payload));
return;
}
throw new Error(`unknown LinuxCNC interpreter worker request: ${type}`);
} catch (error) {
self.postMessage({
id,
ok: false,
error: error instanceof Error ? error.message : String(error),
});
}
});
function postSuccess(id, value) {
self.postMessage({ id, ok: true, value });
}

View File

@@ -1,39 +1,31 @@
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
createLinuxCncKinematicsSdk,
linuxCncKinematicsWasmFile,
supportedLinuxCncKinematicsModules,
} from "../../../../wasm-port/runtime/sdk/src/index.js";
const DEFAULT_MODULE_ID = "xyzac-trt";
const DEFAULT_JOINT_COUNT = 5;
const SOURCE_MODE = "source-derived-kinematics-wasm";
const SEMANTIC_BOUNDARY = "linuxcnc_kinematics_wasm_c_abi";
const __dirname = dirname(fileURLToPath(import.meta.url));
const defaultWasmRoot = resolve(__dirname, "../../../../wasm-port/build/wasm/kinematics");
const DEFAULT_SDK_MODULE_URL = "../../../../wasm-port/runtime/sdk/src/linuxcnc-kinematics.js";
export async function createLinuxCncKinematicsRuntime({
moduleId = DEFAULT_MODULE_ID,
moduleOptions = null,
switchkinsType = 0,
jointCount = DEFAULT_JOINT_COUNT,
wasmRoot = defaultWasmRoot,
wasmRoot = null,
sdkModuleUrl = DEFAULT_SDK_MODULE_URL,
} = {}) {
const {
createLinuxCncKinematicsSdk,
linuxCncKinematicsWasmFile,
supportedLinuxCncKinematicsModules,
} = await import(sdkModuleUrl);
const wasmFile = linuxCncKinematicsWasmFile(moduleId);
if (!wasmFile) {
throw new Error(`unsupported LinuxCNC kinematics module: ${moduleId}`);
}
const resolvedModuleOptions = moduleOptions || {
wasmBinary: readFileSync(resolve(wasmRoot, wasmFile)),
print() {},
printErr() {},
};
const resolvedModuleOptions = moduleOptions || await createDefaultModuleOptions({ wasmRoot, wasmFile });
const sdk = await createLinuxCncKinematicsSdk({ moduleId, moduleOptions: resolvedModuleOptions });
const switchRc = typeof sdk.switchKinematics === "function"
let activeSwitchkinsType = switchkinsType;
let activeSwitchRc = typeof sdk.switchKinematics === "function"
? sdk.switchKinematics(switchkinsType)
: 0;
@@ -45,8 +37,13 @@ export async function createLinuxCncKinematicsRuntime({
loaded: true,
sourceMode: SOURCE_MODE,
semanticBoundary: SEMANTIC_BOUNDARY,
switchkinsType,
switchRc,
executionContext: "direct",
get switchkinsType() {
return activeSwitchkinsType;
},
get switchRc() {
return activeSwitchRc;
},
jointCount,
sdk,
@@ -59,11 +56,21 @@ export async function createLinuxCncKinematicsRuntime({
loaded: true,
sourceMode: SOURCE_MODE,
semanticBoundary: SEMANTIC_BOUNDARY,
switchkinsType,
switchRc,
executionContext: "direct",
switchkinsType: activeSwitchkinsType,
switchRc: activeSwitchRc,
};
},
switchKinematics(nextSwitchkinsType) {
const requestedType = Number(nextSwitchkinsType) || 0;
activeSwitchRc = typeof sdk.switchKinematics === "function"
? sdk.switchKinematics(requestedType)
: 0;
activeSwitchkinsType = requestedType;
return activeSwitchRc;
},
forward(joints, options = {}) {
return sdk.forward(joints, options);
},
@@ -82,7 +89,7 @@ export async function createLinuxCncKinematicsRuntime({
);
return {
moduleId,
switchkinsType,
switchkinsType: activeSwitchkinsType,
forward,
inverse,
};
@@ -90,6 +97,35 @@ export async function createLinuxCncKinematicsRuntime({
};
}
async function createDefaultModuleOptions({ wasmRoot, wasmFile }) {
const quietOptions = {
print() {},
printErr() {},
};
if (!isNodeRuntime()) {
return quietOptions;
}
const [{ readFileSync }, { dirname, resolve }, { fileURLToPath }] = await Promise.all([
import("node:fs"),
import("node:path"),
import("node:url"),
]);
const moduleDir = dirname(fileURLToPath(import.meta.url));
const resolvedWasmRoot = wasmRoot || resolve(moduleDir, "../../../../wasm-port/build/wasm/kinematics");
return {
...quietOptions,
wasmBinary: readFileSync(resolve(resolvedWasmRoot, wasmFile)),
};
}
function isNodeRuntime() {
return typeof process === "object"
&& typeof process.versions === "object"
&& typeof process.versions.node === "string"
&& process.type !== "renderer";
}
export function createLinuxCncKinematicsRuntimeDescriptor(runtime) {
if (!runtime?.loaded) return null;
return {
@@ -100,6 +136,8 @@ export function createLinuxCncKinematicsRuntimeDescriptor(runtime) {
loaded: runtime.loaded,
sourceMode: runtime.sourceMode,
semanticBoundary: runtime.semanticBoundary,
executionContext: runtime.executionContext || "direct",
workerUrl: runtime.workerUrl || null,
switchkinsType: runtime.switchkinsType,
switchRc: runtime.switchRc,
};

View File

@@ -0,0 +1,107 @@
const DEFAULT_MODULE_ID = "xyzac-trt";
const DEFAULT_JOINT_COUNT = 5;
const SOURCE_MODE = "source-derived-kinematics-wasm";
const SEMANTIC_BOUNDARY = "linuxcnc_kinematics_wasm_c_abi";
export async function createLinuxCncKinematicsWorkerRuntime({
moduleId = DEFAULT_MODULE_ID,
switchkinsType = 0,
jointCount = DEFAULT_JOINT_COUNT,
sdkModuleUrl,
workerUrl = new URL("./linuxcnc-kinematics-worker.js", import.meta.url).href,
} = {}) {
if (typeof Worker !== "function") {
throw new Error("Web Worker is not available in this runtime");
}
const worker = new Worker(workerUrl, { type: "module" });
const request = createWorkerRequest(worker);
const readiness = await request("init", {
moduleId,
switchkinsType,
jointCount,
sdkModuleUrl,
});
const runtime = {
apiName: "web-rtcp-5axis-linuxcnc-kinematics-worker-runtime",
moduleId,
wasmFile: readiness.wasmFile,
supportedModules: readiness.supportedModules,
loaded: true,
sourceMode: SOURCE_MODE,
semanticBoundary: SEMANTIC_BOUNDARY,
executionContext: "worker",
workerUrl,
switchkinsType,
switchRc: readiness.switchRc,
jointCount,
readiness() {
return {
...readiness,
apiName: "web-rtcp-5axis-linuxcnc-kinematics-worker-runtime-readiness",
executionContext: "worker",
workerUrl,
};
},
forward(joints, options = {}) {
return request("forward", { joints: Array.from(joints, Number), options });
},
inverse(pose, count = jointCount, options = {}) {
return request("inverse", { pose, count, options });
},
async switchKinematics(nextSwitchkinsType) {
const result = await request("switchKinematics", { switchkinsType: nextSwitchkinsType });
runtime.switchkinsType = result.switchkinsType;
runtime.switchRc = result.switchRc;
return result.switchRc;
},
frameForJoints(joints, options = {}) {
return request("frameForJoints", { joints: Array.from(joints, Number), options });
},
terminate() {
worker.terminate();
},
};
return runtime;
}
function createWorkerRequest(worker) {
let nextId = 1;
const pending = new Map();
worker.addEventListener("message", (event) => {
const { id, ok, value, error } = event.data || {};
const request = pending.get(id);
if (!request) return;
pending.delete(id);
if (ok) {
request.resolve(value);
} else {
request.reject(new Error(error || "LinuxCNC kinematics worker request failed"));
}
});
worker.addEventListener("error", (event) => {
const error = new Error(event.message || "LinuxCNC kinematics worker error");
for (const request of pending.values()) {
request.reject(error);
}
pending.clear();
});
return function request(type, payload = {}) {
const id = nextId++;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
worker.postMessage({ id, type, payload });
});
};
}

View File

@@ -0,0 +1,66 @@
import { createLinuxCncKinematicsRuntime } from "./linuxcnc-kinematics-runtime.js";
let runtime = null;
self.addEventListener("message", async (event) => {
const { id, type, payload = {} } = event.data || {};
try {
if (type === "init") {
runtime = await createLinuxCncKinematicsRuntime({
moduleId: payload.moduleId,
moduleOptions: payload.moduleOptions,
switchkinsType: payload.switchkinsType,
jointCount: payload.jointCount,
wasmRoot: payload.wasmRoot,
sdkModuleUrl: payload.sdkModuleUrl,
});
postSuccess(id, runtime.readiness());
return;
}
if (!runtime?.loaded) {
throw new Error("LinuxCNC kinematics worker runtime is not initialized");
}
if (type === "readiness") {
postSuccess(id, runtime.readiness());
return;
}
if (type === "forward") {
postSuccess(id, runtime.forward(payload.joints, payload.options || {}));
return;
}
if (type === "inverse") {
postSuccess(id, runtime.inverse(payload.pose, payload.count, payload.options || {}));
return;
}
if (type === "switchKinematics") {
const switchRc = runtime.switchKinematics(payload.switchkinsType);
postSuccess(id, {
switchkinsType: runtime.switchkinsType,
switchRc,
});
return;
}
if (type === "frameForJoints") {
postSuccess(id, runtime.frameForJoints(payload.joints, payload.options || {}));
return;
}
throw new Error(`unknown LinuxCNC kinematics worker request: ${type}`);
} catch (error) {
self.postMessage({
id,
ok: false,
error: error instanceof Error ? error.message : String(error),
});
}
});
function postSuccess(id, value) {
self.postMessage({ id, ok: true, value });
}

View File

@@ -0,0 +1,289 @@
const DEFAULT_SDK_MODULE_URL = "../../../../wasm-port/runtime/sdk/src/sim-config-staging.js";
const DEFAULT_MANIFEST_URLS = [
new URL("../../../../wasm-port/tools/source-manifest.txt", import.meta.url).href,
new URL("../../wasm-port/tools/source-manifest.txt", import.meta.url).href,
];
const DEFAULT_VENDOR_ROOT_URLS = [
new URL("../../../../wasm-port/vendor/linuxcnc/", import.meta.url).href,
new URL("../../wasm-port/vendor/linuxcnc/", import.meta.url).href,
];
const TRT_MACHINE_REL = "axis/vismach/5axis/table-rotary-tilting";
const TRT_DEMO_SOURCE_PREFIX = `configs/sim/${TRT_MACHINE_REL}/demos/`;
const OPFS_ROOT = "web-rtcp-5axis-sim-plan/machines";
export async function createMachineFileStagingPlan({
profile,
iniText = null,
manifestText = null,
sdkModuleUrl = DEFAULT_SDK_MODULE_URL,
manifestUrl = null,
wasmDir = null,
} = {}) {
if (!profile?.iniPath) {
throw new Error("machine file staging requires a profile with iniPath");
}
const { planSimConfigStaging } = await import(sdkModuleUrl);
const resolvedManifestText = manifestText ?? await readTextFromCandidateUrls(
manifestUrl ? [manifestUrl] : DEFAULT_MANIFEST_URLS,
);
const resolvedIniText = iniText ?? await readTextFromCandidateUrls(sourceUrlsFor(profile.iniPath));
const iniFile = basename(profile.iniPath);
const plan = planSimConfigStaging({
manifestText: resolvedManifestText,
machineRel: TRT_MACHINE_REL,
iniFile,
iniText: resolvedIniText,
wasmDir: wasmDir || `/work/sim/${TRT_MACHINE_REL}/${profile.id}`,
});
const files = addVendoredDemoSources(plan.files, resolvedManifestText, plan.wasmDir);
return {
apiName: "web-rtcp-5axis-machine-file-staging-plan",
profileId: profile.id,
machineRel: TRT_MACHINE_REL,
iniPath: profile.iniPath,
wasmDir: plan.wasmDir,
wasmIniPath: plan.iniPath,
wasmProgramPath: plan.programPath,
files: files.map((file) => ({
...file,
opfsPath: opfsPathFor(profile.id, file.sourceRel),
kind: classifySourceRel(file.sourceRel),
})),
summary: summarizePlan(files),
semanticBoundary: "linuxcnc_sim_config_file_staging_plan_only",
};
}
export function listLinuxCncGcodeSources(save) {
return [...(save?.files || [])]
.filter((file) => file.kind === "demo" && isLinuxCncFiveAxisGcodeSourceRel(file.sourceRel))
.map((file) => ({
sourceRel: file.sourceRel,
wasmPath: file.wasmPath,
opfsPath: file.opfsPath,
filename: basename(file.sourceRel),
bytes: file.bytes,
label: basename(file.sourceRel).replace(/\.ngc$/i, ""),
sourceMode: "linuxcnc-vendored-5axis-gcode",
semanticBoundary: "linuxcnc_vendored_5axis_gcode_source_file",
}))
.sort((left, right) => left.filename.localeCompare(right.filename));
}
export function selectMachineFileProgram(plan, save, sourceRel) {
if (!isLinuxCncFiveAxisGcodeSourceRel(sourceRel)) {
throw new Error(`5-axis G-code source must come from LinuxCNC source demos: ${sourceRel}`);
}
const selectedFile = (save?.files || []).find((file) => file.sourceRel === sourceRel);
if (!selectedFile) {
throw new Error(`LinuxCNC G-code source not staged: ${sourceRel}`);
}
return {
...plan,
wasmProgramPath: selectedFile.wasmPath || selectedFile.path,
selectedProgramSourceRel: selectedFile.sourceRel,
selectedProgramFilename: basename(selectedFile.sourceRel),
selectedProgramBytes: selectedFile.bytes,
semanticBoundary: "linuxcnc_sim_config_file_staging_plan_with_selected_gcode_source",
};
}
export async function saveMachineFileStagingPlan(plan, options = {}) {
if (plan?.apiName !== "web-rtcp-5axis-machine-file-staging-plan") {
throw new Error("saveMachineFileStagingPlan requires a machine-file staging plan");
}
const savedFiles = [];
for (const file of plan.files) {
const text = await readTextFromCandidateUrls(sourceUrlsFor(file.sourceRel));
await saveTextFile(file.opfsPath, text, options.storage);
savedFiles.push({
sourceRel: file.sourceRel,
opfsPath: file.opfsPath,
wasmPath: file.wasmPath,
path: file.wasmPath,
text,
kind: file.kind,
bytes: text.length,
executable: Boolean(file.executable),
});
}
return {
apiName: "web-rtcp-5axis-machine-file-staging-save",
profileId: plan.profileId,
status: "saved",
savedAt: new Date().toISOString(),
fileCount: savedFiles.length,
opfsRoot: `${OPFS_ROOT}/${plan.profileId}`,
files: savedFiles,
gcodeSources: listLinuxCncGcodeSources({ files: savedFiles }),
summary: summarizeSavedFiles(savedFiles),
semanticBoundary: "opfs_machine_file_text_staging_only",
};
}
export async function stageProfileMachineFiles(profile, options = {}) {
const plan = await createMachineFileStagingPlan({
profile,
iniText: options.iniText,
manifestText: options.manifestText,
sdkModuleUrl: options.sdkModuleUrl,
manifestUrl: options.manifestUrl,
wasmDir: options.wasmDir,
});
const save = await saveMachineFileStagingPlan(plan, { storage: options.storage });
return { plan, save };
}
function summarizePlan(files) {
const kinds = countKinds(files.map((file) => classifySourceRel(file.sourceRel)));
return {
fileCount: files.length,
requiredFileCount: files.filter((file) => file.sourceRel.endsWith(".ini") || file.sourceRel.includes("/demos/")).length,
remapFileCount: kinds.remap || 0,
demoFileCount: kinds.demo || 0,
toolTableFileCount: kinds.toolTable || 0,
halFileCount: kinds.hal || 0,
kinds,
};
}
function addVendoredDemoSources(files, manifestText, wasmDir) {
const bySourceRel = new Map(files.map((file) => [file.sourceRel, file]));
for (const sourceRel of String(manifestText).split(/\r?\n/)) {
if (!isLinuxCncFiveAxisGcodeSourceRel(sourceRel)) continue;
if (bySourceRel.has(sourceRel)) continue;
bySourceRel.set(sourceRel, {
sourceRel,
wasmPath: `${wasmDir}/demos/${basename(sourceRel)}`,
executable: false,
});
}
return [...bySourceRel.values()];
}
function isLinuxCncFiveAxisGcodeSourceRel(sourceRel) {
const value = String(sourceRel || "");
return value.startsWith(TRT_DEMO_SOURCE_PREFIX)
&& value.endsWith(".ngc")
&& !value.slice(TRT_DEMO_SOURCE_PREFIX.length).includes("/");
}
function summarizeSavedFiles(files) {
return {
fileCount: files.length,
totalBytes: files.reduce((total, file) => total + file.bytes, 0),
kinds: countKinds(files.map((file) => file.kind)),
opfsPaths: files.map((file) => file.opfsPath),
};
}
function countKinds(kinds) {
return kinds.reduce((counts, kind) => {
counts[kind] = (counts[kind] || 0) + 1;
return counts;
}, {});
}
function classifySourceRel(sourceRel) {
if (sourceRel.endsWith(".ini")) return "ini";
if (sourceRel.endsWith(".tbl")) return "toolTable";
if (sourceRel.endsWith(".hal")) return "hal";
if (sourceRel.includes("/remap_subs/")) return "remap";
if (sourceRel.includes("/demos/")) return "demo";
if (sourceRel.endsWith(".xml")) return "pyvcp";
if (sourceRel.endsWith(".var")) return "parameters";
return "asset";
}
function opfsPathFor(profileId, sourceRel) {
return `${OPFS_ROOT}/${assertPathSegment(profileId)}/${String(sourceRel).replaceAll("\\", "/")}`;
}
async function readTextFromCandidateUrls(urls) {
const errors = [];
for (const url of urls) {
try {
return await readTextFromUrl(url);
} catch (error) {
errors.push(`${url}: ${error.message}`);
}
}
throw new Error(`failed to read machine staging asset: ${errors.join(" | ")}`);
}
async function readTextFromUrl(url) {
if (isNodeRuntime()) {
const [{ readFile }, { resolve }, { fileURLToPath }] = await Promise.all([
import("node:fs/promises"),
import("node:path"),
import("node:url"),
]);
const path = String(url).startsWith("file:")
? fileURLToPath(url)
: resolve(process.cwd(), url);
return readFile(path, "utf8");
}
const response = await fetch(url);
if (!response.ok) {
throw new Error(`failed to fetch machine staging asset ${url}: ${response.status}`);
}
return response.text();
}
async function saveTextFile(path, text, storage = globalThis.navigator?.storage) {
const root = await getStorageRoot(storage);
const dir = await ensureParentDir(root, path);
const filename = splitPath(path).at(-1);
const fileHandle = await dir.getFileHandle(filename, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(text);
await writable.close();
}
async function getStorageRoot(storage) {
if (!storage?.getDirectory) {
throw new Error("OPFS is not available in this browser.");
}
return storage.getDirectory();
}
async function ensureParentDir(root, path) {
let current = root;
for (const part of splitPath(path).slice(0, -1)) {
current = await current.getDirectoryHandle(part, { create: true });
}
return current;
}
function splitPath(path) {
const parts = String(path || "").replaceAll("\\", "/").split("/").filter(Boolean);
if (parts.length === 0 || parts.some((part) => part === "." || part === "..")) {
throw new Error(`Invalid OPFS path: ${path}`);
}
return parts;
}
function sourceUrlsFor(sourceRel) {
return DEFAULT_VENDOR_ROOT_URLS.map((rootUrl) => new URL(sourceRel, rootUrl).href);
}
function basename(path) {
return String(path).split("/").filter(Boolean).at(-1);
}
function assertPathSegment(segment) {
if (!/^[a-z0-9._-]+$/i.test(String(segment))) {
throw new Error(`Invalid OPFS path segment: ${segment}`);
}
return segment;
}
function isNodeRuntime() {
return typeof process === "object"
&& typeof process.versions === "object"
&& typeof process.versions.node === "string"
&& process.type !== "renderer";
}

View File

@@ -25,7 +25,7 @@ export function buildRtcpFrame({
const pose = normalizeAxisPose(axisPose);
const toolLength = 84.019;
const toolAxisVector = computeToolAxisVector(pose.a, pose.c);
const toolAxisVector = computeToolAxisVector(pose, profile);
const compensation = rtcpEnabled
? {
x: -toolAxisVector.x * toolLength,
@@ -85,7 +85,7 @@ function buildLinuxCncKinematicsFrame({
...wasmPose,
});
const jointValues = Array.isArray(inverse.joints) ? inverse.joints : [];
const toolAxisVector = computeToolAxisVector(pose.a, pose.c);
const toolAxisVector = computeToolAxisVector(pose, profile);
return {
apiName: "web-rtcp-5axis-motion-frame",
@@ -97,7 +97,7 @@ function buildLinuxCncKinematicsFrame({
rtcpEnabled,
rtcpState: rtcpEnabled ? "on" : "off",
axisPose: pose,
jointPose: buildJointPoseFromLinuxCncJoints(jointValues, pose),
jointPose: buildJointPoseFromLinuxCncJoints(jointValues, pose, profile),
tcpPose: {
x: pose.x,
y: pose.y,
@@ -150,9 +150,9 @@ function buildJointPose(pose) {
];
}
function buildJointPoseFromLinuxCncJoints(joints, fallbackPose) {
const axes = ["X", "Y", "Z", "A", "C"];
const fallbackValues = [fallbackPose.x, fallbackPose.y, fallbackPose.z, fallbackPose.a, fallbackPose.c];
function buildJointPoseFromLinuxCncJoints(joints, fallbackPose, profile = xyzacTrtProfile) {
const axes = profile?.traj?.coordinates === "XYZBC" ? ["X", "Y", "Z", "B", "C"] : ["X", "Y", "Z", "A", "C"];
const fallbackValues = axes.map((axis) => fallbackPose[axis.toLowerCase()] ?? 0);
return axes.map((axis, joint) => ({
joint,
axis,
@@ -171,18 +171,29 @@ function normalizeLinuxCncPose(pose = {}) {
};
}
function computeToolAxisVector(aDegrees, cDegrees) {
const a = aDegrees * DEG_TO_RAD;
function computeToolAxisVector(pose, profile = xyzacTrtProfile) {
const coordinates = profile?.traj?.coordinates || "XYZAC";
const tiltDegrees = coordinates.includes("B") ? pose.b : pose.a;
const cDegrees = pose.c;
const tilt = tiltDegrees * DEG_TO_RAD;
const c = cDegrees * DEG_TO_RAD;
const sinA = Math.sin(a);
const cosA = Math.cos(a);
const sinTilt = Math.sin(tilt);
const cosTilt = Math.cos(tilt);
const sinC = Math.sin(c);
const cosC = Math.cos(c);
if (coordinates.includes("B")) {
return normalizeVector({
x: sinTilt * cosC,
y: sinTilt * sinC,
z: cosTilt,
});
}
return normalizeVector({
x: sinA * sinC,
y: -sinA * cosC,
z: cosA,
x: sinTilt * sinC,
y: -sinTilt * cosC,
z: cosTilt,
});
}

View File

@@ -0,0 +1,161 @@
export const LINUXCNC_TASK_POLICY = {
apiName: "web-rtcp-5axis-linuxcnc-task-policy",
sourceMode: "linuxcnc-task-source-referenced-policy",
semanticBoundary: "linuxcnc_task_state_mode_command_gate",
sourceReferences: [
{
path: "linuxcnc/src/emc/nml_intf/emc.hh",
symbols: ["EMC_TASK_MODE", "EMC_TASK_STATE", "EMC_TASK_INTERP"],
},
{
path: "linuxcnc/src/emc/task/emctaskmain.cc",
symbols: [
"emcTaskPlan",
"EMC_TASK_PLAN_RUN",
"EMC_TASK_PLAN_EXECUTE",
"EMC_TASK_PLAN_PAUSE",
"EMC_TASK_PLAN_RESUME",
"EMC_TASK_ABORT",
"EMC_JOG_INCR",
"EMC_JOINT_HOME",
],
},
{
path: "linuxcnc/src/emc/task/emctask.cc",
symbols: ["emcTaskSetState", "determineState"],
},
],
};
export const LINUXCNC_TASK_MODES = new Set(["manual", "auto", "mdi"]);
export const LINUXCNC_TASK_STATES = new Set(["estop", "estop-reset", "off", "on"]);
export const LINUXCNC_INTERP_STATES = new Set(["idle", "reading", "paused", "waiting"]);
export function normalizeLinuxCncTaskMode(mode) {
if (mode === "jog") return "manual";
return LINUXCNC_TASK_MODES.has(mode) ? mode : "manual";
}
export function normalizeLinuxCncTaskState(machine = {}) {
if (LINUXCNC_TASK_STATES.has(machine.taskState)) return machine.taskState;
if (machine.estopActive) return "estop";
if (machine.powerOn) return "on";
return "estop-reset";
}
export function normalizeLinuxCncInterpState(machine = {}, runState = "idle") {
if (LINUXCNC_INTERP_STATES.has(machine.interpState)) return machine.interpState;
if (runState === "running") return "reading";
if (runState === "paused" || runState === "stepping") return "paused";
return "idle";
}
export function createLinuxCncTaskPolicyStatus(state) {
const taskState = normalizeLinuxCncTaskState(state.machine);
const taskMode = normalizeLinuxCncTaskMode(state.machine.mode);
const interpState = normalizeLinuxCncInterpState(state.machine, state.runState);
const allHomed = Boolean(state.machine.allHomed);
const noForceHoming = Boolean(state.machine.noForceHoming);
return {
...LINUXCNC_TASK_POLICY,
taskState,
taskMode,
interpState,
allHomed,
noForceHoming,
powerOn: taskState === "on",
estopActive: taskState === "estop",
canMove: taskState === "on",
canJog: taskState === "on" && taskMode === "manual",
canHome: taskState === "on" && taskMode === "manual",
canRunAuto: taskState === "on" && taskMode === "auto" && (allHomed || noForceHoming),
canExecuteMdi: taskState === "on" && taskMode === "mdi" && (allHomed || noForceHoming),
canPause: taskState === "on" && (taskMode === "auto" || taskMode === "mdi"),
canResume: taskState === "on" && (taskMode === "auto" || taskMode === "mdi") && interpState === "paused",
canAbort: true,
canLeaveAuto: taskMode !== "auto" || interpState === "idle",
};
}
export function gateLinuxCncTaskAction(state, action) {
const status = createLinuxCncTaskPolicyStatus(state);
const type = typeof action === "string" ? action : action?.type;
const requestedMode = typeof action === "object" ? action.mode : undefined;
switch (type) {
case "TOGGLE_POWER":
if (status.taskState === "estop") {
return block(status, "power on blocked: reset estop first");
}
return allow(status);
case "SET_MODE":
return gateMode(status, requestedMode);
case "JOG":
if (status.taskState !== "on") return block(status, "jog blocked: machine must be on");
if (status.taskMode !== "manual") return block(status, "jog blocked: switch to manual mode first");
return allow(status);
case "HOME":
if (status.taskState !== "on") return block(status, "home blocked: machine must be on");
if (status.taskMode !== "manual") return block(status, "home blocked: switch to manual mode first");
return allow(status);
case "RUN_MDI":
if (status.taskState !== "on") return block(status, "MDI blocked: machine must be on");
if (status.taskMode !== "mdi") return block(status, "MDI blocked: switch to MDI mode first");
if (!status.allHomed && !status.noForceHoming) return block(status, "MDI blocked: home machine first");
return allow(status);
case "RUN":
case "STEP":
case "RUN_FRAME":
if (status.taskState !== "on") return block(status, `${type.toLowerCase()} blocked: machine must be on`);
if (status.taskMode !== "auto") return block(status, `${type.toLowerCase()} blocked: switch to auto mode first`);
if (!status.allHomed && !status.noForceHoming) return block(status, `${type.toLowerCase()} blocked: home machine first`);
if (type === "RUN" && status.interpState === "paused") return block(status, "run blocked: resume paused program first");
return allow(status);
case "PAUSE":
if (status.taskState !== "on") return block(status, "pause blocked: machine must be on");
if (status.taskMode !== "auto" && status.taskMode !== "mdi") {
return block(status, "pause blocked: task mode must be auto or MDI");
}
return allow(status);
case "RESUME":
if (status.taskState !== "on") return block(status, "resume blocked: machine must be on");
if (status.taskMode !== "auto" && status.taskMode !== "mdi") {
return block(status, "resume blocked: task mode must be auto or MDI");
}
if (status.interpState !== "paused") return block(status, "resume blocked: interpreter is not paused");
return allow(status);
case "STOP":
case "ABORT":
return allow(status);
default:
return allow(status);
}
}
function gateMode(status, requestedMode) {
const targetMode = normalizeLinuxCncTaskMode(requestedMode);
if (!LINUXCNC_TASK_MODES.has(targetMode)) {
return block(status, `mode blocked: invalid LinuxCNC task mode ${requestedMode}`);
}
if (status.taskMode === "auto" && status.interpState !== "idle" && targetMode !== "auto") {
return block(status, "mode blocked: AUTO interpreter is not idle");
}
return allow(status);
}
function allow(status) {
return {
allowed: true,
status,
operatorMessage: null,
};
}
function block(status, operatorMessage) {
return {
allowed: false,
status,
operatorMessage,
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,9 @@
--green-dark: #02bf19;
--black: #050505;
--text: #2e2e2e;
--ink: #242424;
--line: #aaa59b;
--muted: #5e5a52;
font-family: Arial, Helvetica, sans-serif;
}
@@ -19,11 +22,11 @@
html,
body {
width: 100%;
min-width: 1024px;
min-height: 768px;
min-width: 1180px;
min-height: 640px;
height: 100%;
margin: 0;
overflow: auto;
overflow: hidden;
background: #c8c4bc;
color: var(--text);
}
@@ -40,26 +43,44 @@ button:active {
transform: translateY(1px);
}
.profile-select-label {
display: grid;
gap: 1px;
min-width: 210px;
color: var(--muted);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
.profile-select-label select {
min-height: 28px;
border: 1px solid var(--line);
border-radius: 3px;
background: #f7f5ef;
color: var(--ink);
font: inherit;
font-size: 12px;
}
.gmoccapy-shell {
display: grid;
grid-template-columns:
minmax(350px, 0.76fr)
minmax(160px, 0.36fr)
minmax(128px, 0.28fr)
78px
minmax(176px, 0.4fr)
104px;
grid-template-rows: 28px minmax(156px, 0.36fr) minmax(240px, 0.64fr) 224px 62px;
minmax(610px, 1.34fr)
minmax(270px, 0.58fr)
minmax(260px, 0.56fr)
108px;
grid-template-rows: 40px minmax(210px, 1fr) minmax(170px, 0.78fr) 150px 68px;
grid-template-areas:
"title title title title title title"
"preview preview dro dro dro side"
"preview preview gcode gcode gcode side"
"info override override spindle spindle side"
"bottom bottom bottom bottom bottom side";
"title title title title"
"preview dro dro side"
"preview gcode gcode side"
"info override spindle side"
"bottom bottom bottom side";
width: 100vw;
height: 100vh;
min-width: 1024px;
min-height: 768px;
min-width: 1180px;
min-height: 640px;
border: 1px solid var(--border);
background: var(--panel);
}
@@ -70,7 +91,7 @@ button:active {
align-items: center;
gap: 10px;
min-width: 0;
padding: 2px 10px;
padding: 4px 12px;
background: #cfcbc3;
border-bottom: 1px solid var(--border);
}
@@ -78,8 +99,9 @@ button:active {
.brand-dot {
display: grid;
place-items: center;
width: 22px;
height: 22px;
flex: 0 0 auto;
width: 26px;
height: 26px;
border: 2px solid #ffcf00;
border-radius: 50%;
color: #e21d1d;
@@ -98,7 +120,7 @@ button:active {
.title-stack strong {
overflow: hidden;
font-size: 14px;
font-size: 17px;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -106,7 +128,7 @@ button:active {
.title-stack span,
.run-state {
overflow: hidden;
font-size: 12px;
font-size: 14px;
color: #4d4d4d;
text-overflow: ellipsis;
white-space: nowrap;
@@ -140,8 +162,9 @@ button:active {
.machine-preview {
width: 100%;
height: calc(100% - 54px);
margin-top: 24px;
height: calc(100% - 64px);
margin-top: 32px;
display: block;
}
.tool-preview-card {
@@ -215,7 +238,7 @@ button:active {
.rtcp-preview-badge {
position: absolute;
right: 8px;
bottom: 58px;
bottom: 62px;
left: 8px;
z-index: 2;
overflow: hidden;
@@ -242,8 +265,8 @@ button:active {
left: 0;
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 4px;
padding: 4px;
gap: 6px;
padding: 6px;
background: var(--panel);
border-top: 1px solid var(--border);
}
@@ -251,7 +274,7 @@ button:active {
.preview-toolbar button,
.bottom-controls button,
.status-sidebar button {
min-height: 46px;
min-height: 42px;
font-weight: 700;
}
@@ -262,7 +285,8 @@ button:active {
min-width: 0;
min-height: 0;
overflow: hidden;
border-bottom: 1px solid #151515;
border-left: 2px solid #8d887f;
border-bottom: 2px solid #8d887f;
background: var(--black);
}
@@ -277,17 +301,17 @@ button:active {
.dro-row {
display: grid;
grid-template-columns: 24px 40px minmax(74px, 1fr) 48px;
grid-template-columns: 30px 46px minmax(92px, 1fr) 58px;
align-items: center;
min-width: 0;
min-height: 0;
padding: 3px 5px;
padding: 4px 7px;
background: var(--black);
color: var(--green);
}
.dro-axis {
font-size: 22px;
font-size: clamp(24px, 2vw, 32px);
font-weight: 800;
line-height: 1;
}
@@ -295,14 +319,14 @@ button:active {
.dro-mode,
.dro-dtg {
color: var(--green);
font-size: 9px;
font-size: 10px;
line-height: 1.25;
}
.dro-row strong {
overflow: hidden;
text-align: right;
font-size: clamp(20px, 2.4vw, 30px);
font-size: clamp(24px, 2.5vw, 38px);
line-height: 1;
font-variant-numeric: tabular-nums;
text-overflow: clip;
@@ -314,10 +338,10 @@ button:active {
gap: 6px;
justify-content: space-between;
min-width: 0;
padding: 5px 8px;
padding: 6px 9px;
background: #080808;
color: var(--green);
font-size: 12px;
font-size: 13px;
font-weight: 700;
}
@@ -343,11 +367,11 @@ button:active {
.gcode-panel {
grid-area: gcode;
display: grid;
grid-template-rows: 30px minmax(0, 1fr) 22px;
grid-template-rows: 32px minmax(0, 1fr) 20px 54px;
min-width: 0;
min-height: 0;
overflow: hidden;
background: #f4f2ed;
background: #f6f4ef;
border-top: 2px solid var(--border);
border-bottom: 2px solid var(--border);
}
@@ -358,10 +382,10 @@ button:active {
gap: 8px;
align-items: center;
min-width: 0;
padding: 5px 8px;
padding: 6px 9px;
border-bottom: 1px solid #d5d0c7;
background: #eeeae3;
font-size: 12px;
font-size: 13px;
}
.gcode-header strong,
@@ -380,27 +404,32 @@ button:active {
.gcode-list {
min-height: 0;
margin: 0;
padding: 4px 6px 2px;
padding: 6px 8px 3px;
overflow: auto;
list-style: none;
font-family: "Courier New", monospace;
font-size: 15px;
color: #8b8b8b;
font-size: 13px;
color: #55514a;
}
.gcode-row {
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
gap: 6px;
min-height: 20px;
line-height: 1.35;
min-height: 18px;
line-height: 1.25;
}
.gcode-row.active {
background: #e8e8e8;
background: #242424;
color: #202020;
}
.gcode-row.active span,
.gcode-row.active code {
color: #f2f2f2;
}
.gcode-row code {
overflow: hidden;
white-space: nowrap;
@@ -412,8 +441,8 @@ button:active {
grid-template-columns: 120px 1fr;
align-items: center;
gap: 8px;
padding: 2px 8px 4px;
font-size: 12px;
padding: 1px 8px 3px;
font-size: 11px;
color: #777;
}
@@ -428,10 +457,70 @@ button:active {
background: #3888df;
}
.mdi-panel {
display: grid;
grid-template-rows: 27px minmax(0, 1fr);
gap: 2px;
min-width: 0;
min-height: 0;
padding: 3px 8px 5px;
border-top: 1px solid #cbc6bd;
background: #e7e3dc;
}
.mdi-command-row {
display: grid;
grid-template-columns: 44px minmax(0, 1fr) 64px;
gap: 6px;
align-items: center;
min-width: 0;
}
.mdi-command-row strong {
color: #222;
font-size: 13px;
}
.mdi-command-row input {
min-width: 0;
height: 25px;
padding: 3px 8px;
border: 1px solid #8e897f;
border-radius: 3px;
background: #111;
color: var(--green);
font: 14px/1.1 "Courier New", monospace;
}
.mdi-command-row button {
min-height: 25px;
font-size: 12px;
font-weight: 700;
}
.mdi-history {
display: flex;
gap: 4px;
min-width: 0;
overflow: hidden;
}
.mdi-history button {
flex: 0 1 auto;
min-width: 0;
min-height: 19px;
padding: 2px 6px;
overflow: hidden;
color: #303030;
font: 11px/1.1 "Courier New", monospace;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-sidebar {
grid-area: side;
display: grid;
grid-template-rows: repeat(9, minmax(44px, 1fr)) minmax(46px, auto);
grid-template-rows: repeat(9, minmax(42px, 1fr)) minmax(42px, auto);
gap: 5px;
padding: 6px;
border-left: 2px solid var(--border);
@@ -475,7 +564,7 @@ button:active {
grid-area: info;
min-width: 0;
min-height: 0;
overflow: hidden;
overflow: auto;
border-top: 2px solid var(--border);
border-right: 2px solid var(--border);
background: #eeeae3;
@@ -492,9 +581,9 @@ button:active {
border-right: 1px solid var(--border);
border-radius: 0;
background: #e0ddd6;
min-height: 37px;
min-height: 32px;
padding: 4px 5px;
font-size: 14px;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -507,10 +596,10 @@ button:active {
.info-grid {
display: grid;
grid-template-columns: max-content 1fr;
gap: 3px 10px;
margin: 8px;
font-size: 14px;
line-height: 1.22;
gap: 1px 8px;
margin: 6px 8px;
font-size: 12px;
line-height: 1.15;
}
.info-grid dt {
@@ -533,7 +622,7 @@ button:active {
grid-area: override;
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 56px 64px minmax(78px, 1fr);
grid-template-rows: repeat(3, minmax(0, 1fr));
gap: 6px;
padding: 6px;
border-top: 2px solid var(--border);
@@ -549,8 +638,8 @@ button:active {
.spindle {
display: grid;
align-content: start;
gap: 2px;
padding: 5px;
gap: 1px;
padding: 5px 6px;
background: #f8f5ef;
border: 1px solid var(--border);
min-width: 0;
@@ -563,7 +652,7 @@ button:active {
.cooling h2,
.spindle h2 {
margin: 0;
font-size: 14px;
font-size: 12px;
line-height: 1.15;
text-align: center;
}
@@ -573,7 +662,7 @@ button:active {
.spindle strong {
display: block;
overflow: hidden;
font-size: clamp(18px, 1.8vw, 25px);
font-size: clamp(16px, 1.45vw, 22px);
line-height: 1.05;
text-overflow: ellipsis;
white-space: nowrap;
@@ -591,20 +680,20 @@ button:active {
.meter-card strong {
display: inline;
font-size: clamp(20px, 2vw, 25px);
font-size: clamp(18px, 1.6vw, 24px);
line-height: 1;
}
.meter-card span {
padding-left: 5px;
font-size: 14px;
font-size: 12px;
line-height: 1.1;
white-space: nowrap;
}
.stepper {
display: grid;
grid-template-columns: 32px minmax(54px, 1fr) 32px;
grid-template-columns: 30px minmax(54px, 1fr) 30px;
gap: 2px;
align-items: center;
margin-top: 1px;
@@ -612,7 +701,7 @@ button:active {
.stepper div {
min-width: 0;
padding: 6px 4px;
padding: 5px 4px;
background: var(--orange);
border: 1px solid #b4651c;
text-align: center;
@@ -623,7 +712,7 @@ button:active {
.stepper button {
min-width: 0;
min-height: 28px;
min-height: 26px;
padding: 2px 4px;
}
@@ -650,8 +739,8 @@ button:active {
}
.spindle-range {
height: 24px;
margin-top: 8px;
height: 18px;
margin-top: 4px;
background: #bdbdbd;
border: 1px solid #898989;
}
@@ -665,7 +754,7 @@ button:active {
.bottom-controls {
grid-area: bottom;
display: grid;
grid-template-columns: repeat(13, minmax(48px, 1fr));
grid-template-columns: repeat(15, minmax(48px, 1fr));
gap: 5px;
padding: 6px 8px;
border-top: 2px solid var(--border);
@@ -674,8 +763,11 @@ button:active {
}
.bottom-controls button {
font-size: 12px;
font-size: clamp(10px, 0.72vw, 12px);
line-height: 1.05;
min-width: 0;
padding: 3px 4px;
white-space: normal;
}
.program-file-input {
@@ -684,7 +776,11 @@ button:active {
@media (max-width: 1180px) {
.gmoccapy-shell {
grid-template-columns: 350px 160px 128px 78px 176px 96px;
grid-template-columns:
minmax(560px, 1.28fr)
minmax(250px, 0.58fr)
minmax(230px, 0.54fr)
100px;
}
.dro-row strong {

View File

@@ -32,6 +32,9 @@ export function mountGmoccapyShell(root, store) {
);
store.subscribe((state) => render(regions, state, store.dispatch));
root.addEventListener("profile-change", (event) => {
store.dispatch({ type: "SET_PROFILE", profileId: event.detail.profileId });
});
return {
getRegions() {
@@ -46,7 +49,7 @@ function render(regions, state, dispatch) {
renderTitlebar(regions.titlebar, state);
renderPreview(regions.preview, state, dispatch);
renderDro(regions.dro, state);
renderGcode(regions.gcode, state);
renderGcode(regions.gcode, state, dispatch);
renderSidebar(regions["status-sidebar"], state, dispatch);
renderInfoTabs(regions["info-tabs"], state);
renderOverride(regions.override, state, dispatch);
@@ -61,8 +64,22 @@ function renderTitlebar(element, state) {
<strong>gmoccapy Web 5 Axis for LinuxCNC RTCP Simulation</strong>
<span>${state.machineProfile} | ${state.sessionName} | ${state.sourceMode} | ${state.machine.mode}</span>
</div>
<label class="profile-select-label">
Profile
<select data-action="select-profile">
${state.availableProfiles.map((profile) => `
<option value="${profile.id}" ${profile.id === state.machineProfile ? "selected" : ""}>${profile.coordinates} ${profile.id}</option>
`).join("")}
</select>
</label>
<div class="run-state" data-run-state="${state.runState}">${state.runState}</div>
`;
element.querySelector('[data-action="select-profile"]').addEventListener("change", (event) => {
element.dispatchEvent(new CustomEvent("profile-change", {
bubbles: true,
detail: { profileId: event.target.value },
}));
});
}
function renderPreview(element, state, dispatch) {
@@ -137,7 +154,7 @@ function droRow(axis, value, dtg) {
`;
}
function renderGcode(element, state) {
function renderGcode(element, state, dispatch) {
const rows = state.programLines
.map((line, index) => {
const lineNumber = state.programStartLine + index;
@@ -158,12 +175,101 @@ function renderGcode(element, state) {
<span data-program-source="${state.programSource}">${state.programSource}</span>
<span data-active-program-line="${state.activeLine}">Current line ${state.activeLine}</span>
</div>
<div class="linuxcnc-source-row" data-linuxcnc-gcode-source="row">
<label>
LinuxCNC 5-axis source
<select data-action="select-linuxcnc-gcode-source" ${state.machineFileStaging.gcodeSources?.length ? "" : "disabled"}>
${renderLinuxCncGcodeSourceOptions(state)}
</select>
</label>
<button type="button" data-action="stage-linuxcnc-sources">Stage</button>
<span data-linuxcnc-gcode-source="status">${formatLinuxCncGcodeSourceStatus(state)}</span>
</div>
<ol class="gcode-list" start="${state.programStartLine}">${rows}</ol>
<div class="gcode-progress">
<span>${state.activeLine} / ${programEndLine}</span>
<div><i style="width: ${progress}%"></i></div>
</div>
<section class="mdi-panel" data-mdi-mode="${state.machine.mode === "mdi"}">
<form class="mdi-command-row" data-action="mdi-form">
<strong>MDI</strong>
<input
type="text"
data-action="mdi-command"
value="${escapeHtml(state.machine.mdiCommand)}"
spellcheck="false"
autocomplete="off"
aria-label="MDI command"
/>
<button type="submit" data-action="mdi-submit">Run</button>
</form>
<div class="mdi-history">
${mdiQuickCommands(state).map((command) => `
<button type="button" data-action="mdi-history" data-command="${escapeHtml(command)}">${escapeHtml(command)}</button>
`).join("")}
</div>
</section>
`;
const form = element.querySelector('[data-action="mdi-form"]');
const input = element.querySelector('[data-action="mdi-command"]');
form.addEventListener("submit", (event) => {
event.preventDefault();
dispatch({ type: "RUN_MDI", command: input.value });
});
input.addEventListener("change", () => {
dispatch({ type: "SET_MDI_COMMAND", command: input.value });
});
for (const button of element.querySelectorAll('[data-action="mdi-history"]')) {
button.addEventListener("click", () => {
dispatch({ type: "RUN_MDI", command: button.dataset.command });
});
}
element.querySelector('[data-action="stage-linuxcnc-sources"]').addEventListener("click", () => {
dispatch({ type: "STAGE_MACHINE_FILES_REQUEST" });
});
const linuxCncSourceSelect = element.querySelector('[data-action="select-linuxcnc-gcode-source"]');
linuxCncSourceSelect.addEventListener("change", () => {
if (!linuxCncSourceSelect.value) return;
dispatch({ type: "LOAD_LINUXCNC_GCODE_SOURCE", sourceRel: linuxCncSourceSelect.value });
});
}
function renderLinuxCncGcodeSourceOptions(state) {
const sources = state.machineFileStaging.gcodeSources || [];
if (sources.length === 0) {
return `<option value="">stage machine files first</option>`;
}
const selected = state.machineFileStaging.selectedGcodeSourceRel || "";
return [
`<option value="">select source</option>`,
...sources.map((source) => `
<option value="${escapeHtml(source.sourceRel)}" ${source.sourceRel === selected ? "selected" : ""}>
${escapeHtml(source.filename)}
</option>
`),
].join("");
}
function formatLinuxCncGcodeSourceStatus(state) {
const sources = state.machineFileStaging.gcodeSources || [];
if (sources.length === 0) return `${state.machineFileStaging.status} / no staged LinuxCNC G-code sources`;
const selected = state.machineFileStaging.selectedGcodeSourceRel || "-";
return `${sources.length} staged / ${selected}`;
}
function mdiQuickCommands(state) {
const profileCommands = state.profile.kinematicsParameters.switchkinsTypes
.map((type) => type.mdiCommand)
.filter(Boolean);
return [
...(state.mdiHistory || []),
state.machine.mdiCommand,
"G0 X0 Y0 Z0",
"G91 X1",
"G90",
...profileCommands,
].filter((command, index, commands) => command && commands.indexOf(command) === index).slice(0, 7);
}
function renderSidebar(element, state, dispatch) {
@@ -173,10 +279,10 @@ function renderSidebar(element, state, dispatch) {
<button type="button" class="sidebar-button" data-action="reset">RESET</button>
<button type="button" class="sidebar-button" data-action="mode-auto" data-active="${state.machine.mode === "auto"}">AUTO</button>
<button type="button" class="sidebar-button" data-action="mode-manual" data-active="${state.machine.mode === "manual"}">MANUAL</button>
<button type="button" class="sidebar-button" data-action="mode-jog" data-active="${state.machine.mode === "jog"}">JOG</button>
<button type="button" class="sidebar-button" data-action="mode-jog" data-active="${state.machine.mode === "manual"}">JOG</button>
<button type="button" class="sidebar-button" data-action="mode-mdi" data-active="${state.machine.mode === "mdi"}">MDI</button>
<button type="button" class="sidebar-button" data-action="kins-identity" data-active="${state.kinsType === "identity"}">IDENTITY</button>
<button type="button" class="sidebar-button" data-action="kins-tcp" data-active="${state.kinsType === "tcp-xyzac"}">TCP</button>
<button type="button" class="sidebar-button" data-action="kins-tcp" data-active="${state.kinsType.startsWith("tcp-")}">TCP</button>
<time>13:30:31<br />20.06.2026</time>
`;
@@ -191,12 +297,14 @@ function renderSidebar(element, state, dispatch) {
dispatch({ type: "SET_KINS_TYPE", kinsType: "identity" });
});
element.querySelector('[data-action="kins-tcp"]').addEventListener("click", () => {
dispatch({ type: "SET_KINS_TYPE", kinsType: "tcp-xyzac" });
const tcpKinsType = state.profile.kinematicsParameters.switchkinsTypes.find((type) => type.value === 1)?.webKinsType || "tcp-xyzac";
dispatch({ type: "SET_KINS_TYPE", kinsType: tcpKinsType });
});
}
function renderInfoTabs(element, state) {
const frame = state.rtcpFrame;
const taskPolicy = state.linuxCncTaskPolicy;
element.innerHTML = `
<nav class="tabs">
<button type="button" class="active">Tool info and G-codes</button>
@@ -207,8 +315,21 @@ function renderInfoTabs(element, state) {
<dt>Size:</dt><dd>${state.fileSizeBytes} bytes</dd>
<dt>Lines:</dt><dd>${state.lineCount} gcode lines</dd>
<dt>Machine:</dt><dd data-machine-state="summary">${state.machine.powerOn ? "power on" : "power off"} / ${state.machine.estopActive ? "estop" : "clear"} / ${state.machine.mode}</dd>
<dt>Task policy:</dt><dd data-linuxcnc-task-policy="boundary">${taskPolicy.semanticBoundary}</dd>
<dt>Task state:</dt><dd data-linuxcnc-task-policy="state">${taskPolicy.taskState} / ${taskPolicy.taskMode} / ${taskPolicy.interpState}</dd>
<dt>Task gates:</dt><dd data-linuxcnc-task-policy="gates">${formatLinuxCncTaskGates(taskPolicy)}</dd>
<dt>Task source:</dt><dd data-linuxcnc-task-policy="source">${formatLinuxCncTaskSources(taskPolicy)}</dd>
<dt>Current line:</dt><dd data-program-current-line="${state.activeLine}">${state.activeLine}</dd>
<dt>Program source:</dt><dd data-program-execution-source="${state.programExecutionSourceMode}">${state.programExecutionSourceMode}</dd>
<dt>Canonical:</dt><dd data-program-execution-summary="${state.programExecution?.summary?.motionEventCount ?? 0}">${state.programExecution?.summary?.motionEventCount ?? 0} motion / ${state.programExecution?.summary?.canonicalEventCount ?? 0} events</dd>
<dt>Switchkins:</dt><dd data-program-switchkins-summary="${state.programExecution?.summary?.switchkinsEventCount ?? 0}">${formatSwitchkinsSummary(state.programExecution)}</dd>
<dt>Machine run:</dt><dd data-machine-file-execution="status">${formatMachineFileExecution(state.machineFileExecution)}</dd>
<dt>Session:</dt><dd data-session-persistence="status">${state.sessionPersistence.status} / ${state.sessionPersistence.path ?? "-"}</dd>
<dt>Tool preview:</dt><dd data-tool-preview="detail">T${state.toolPreview.toolNumber} D${formatNumber(state.toolPreview.diameter, 2)} L${formatNumber(state.toolPreview.length, 3)} ${state.toolPreview.units}</dd>
<dt>Program time:</dt><dd data-program-timing="summary">${formatProgramTiming(state)}</dd>
<dt>Segment time:</dt><dd data-program-timing="segment">${formatProgramTimingSegment(state)}</dd>
<dt>Runtime feedback:</dt><dd data-program-runtime-feedback="source">${formatProgramRuntimeFeedback(state)}</dd>
<dt>Runtime DTG:</dt><dd data-program-runtime-feedback="dtg">${formatProgramRuntimeDtg(state)}</dd>
<dt>Rapid distance:</dt><dd>37.634 mm</dd>
<dt>Feed distance:</dt><dd>5814.069 mm</dd>
<dt>X bounds:</dt><dd>8.000 to 113.000 = 105.000 mm</dd>
@@ -216,7 +337,19 @@ function renderInfoTabs(element, state) {
<dt>Z bounds:</dt><dd>-90.500 to -50.000 = 40.500 mm</dd>
<dt>RTCP frame:</dt><dd data-rtcp-diagnostic="frame">${frame.apiName} ${frame.rtcpState}</dd>
<dt>Boundary:</dt><dd data-rtcp-diagnostic="boundary">${frame.semanticBoundary}</dd>
<dt>INI:</dt><dd data-linuxcnc-ini="status">${state.iniConfigReadiness.loaded ? "loaded" : "pending"} / ${state.iniConfigReadiness.path ?? "-"}</dd>
<dt>INI kins:</dt><dd data-linuxcnc-ini="kins">${state.iniConfigReadiness.kinematics ?? "-"} / ${state.iniConfigReadiness.coordinates ?? "-"}</dd>
<dt>INI limits:</dt><dd data-linuxcnc-ini="limits">${formatAxisLimitSummary(state.profile.axisLimits)}</dd>
<dt>INI joints:</dt><dd data-linuxcnc-ini="joints">${state.iniConfigReadiness.jointCount ?? 0} joints / ${state.iniConfigReadiness.axisCount ?? 0} axes</dd>
<dt>Machine files:</dt><dd data-machine-file-staging="status">${formatMachineFileStaging(state.machineFileStaging)}</dd>
<dt>LinuxCNC G-code:</dt><dd data-linuxcnc-gcode-source="selected">${state.machineFileStaging.selectedGcodeSourceRel || state.programSourceRel || "-"}</dd>
<dt>Full boundary:</dt><dd data-full-execution-boundary="status">${formatFullExecutionBoundary(state.fullExecutionBoundary)}</dd>
<dt>Planner/task:</dt><dd data-full-execution-boundary="blockers">${formatFullExecutionBlockers(state.fullExecutionBoundary)}</dd>
<dt>Boundary evidence:</dt><dd data-full-execution-boundary="evidence">${formatFullExecutionEvidence(state.fullExecutionBoundary)}</dd>
<dt>LinuxCNC kins:</dt><dd data-rtcp-diagnostic="kinematics-ready">${frame.readiness.linuxCncKinematicsReady ? "ready" : "pending"}</dd>
<dt>Kins context:</dt><dd data-rtcp-diagnostic="execution-context">${state.kinematicsExecutionContext}</dd>
<dt>Interpreter:</dt><dd data-linuxcnc-boundary="interpreter">${state.interpreterRuntimeReadiness?.loaded ? state.interpreterRuntimeReadiness.semanticBoundary : "pending"}</dd>
<dt>Interp context:</dt><dd data-linuxcnc-boundary="interpreter-context">${state.interpreterRuntimeReadiness?.executionContext ?? "none"}</dd>
<dt>Profile refs:</dt><dd>${state.profile.sourceReferences.length} source references</dd>
<dt>Adapter:</dt><dd data-linuxcnc-boundary="adapter">${state.linuxCncBoundaryAdapter.apiName}</dd>
<dt>Panel schema:</dt><dd data-linuxcnc-boundary="panel">${state.linuxCncBoundaryAdapter.panelSummary.schemaId} / ${state.linuxCncBoundaryAdapter.panelSummary.buttonCount} buttons</dd>
@@ -227,6 +360,97 @@ function renderInfoTabs(element, state) {
`;
}
function formatLinuxCncTaskGates(taskPolicy) {
if (!taskPolicy) return "pending";
return [
taskPolicy.canJog ? "jog" : "jog blocked",
taskPolicy.canHome ? "home" : "home blocked",
taskPolicy.canRunAuto ? "auto" : "auto blocked",
taskPolicy.canExecuteMdi ? "mdi" : "mdi blocked",
taskPolicy.canPause ? "pause" : "pause blocked",
taskPolicy.canResume ? "resume" : "resume blocked",
].join(" / ");
}
function formatLinuxCncTaskSources(taskPolicy) {
if (!taskPolicy?.sourceReferences?.length) return "pending";
return taskPolicy.sourceReferences
.map((reference) => reference.path)
.join(" | ");
}
function formatProgramTiming(state) {
const timing = state.programExecutionTiming;
if (!timing) return "pending";
return `${formatDuration(state.programElapsedSeconds)} / ${formatDuration(timing.totalSeconds)} (${formatDuration(state.programRemainingSeconds)} left)`;
}
function formatProgramTimingSegment(state) {
const segment = state.programExecutionTiming?.segments?.[state.programExecutionMotionIndex || 0];
if (!segment) return "pending";
return `${segment.motionClass} line ${segment.line ?? "-"} ${formatNumber(segment.linearDistanceMm, 3)} mm ${formatDuration(segment.durationSeconds)} @ ${formatNumber(segment.velocityMmPerMin, 1)} mm/min`;
}
function formatProgramRuntimeFeedback(state) {
const feedback = state.programRuntimeFeedback;
if (!feedback) return "pending";
return `${feedback.sourceMode} sample ${feedback.sampleIndex ?? 0} line ${feedback.line ?? "-"} queue ${feedback.queueDepth ?? 0}/${feedback.activeDepth ?? 0} @ ${formatNumber(feedback.currentVelocityMmPerMin, 1)} mm/min`;
}
function formatProgramRuntimeDtg(state) {
const feedback = state.programRuntimeFeedback;
if (!feedback) return "pending";
const dtg = feedback.dtg || {};
return `DTG ${formatNumber(dtg.x, 3)} / ${formatNumber(dtg.y, 3)} / ${formatNumber(dtg.z, 3)} distance ${formatNumber(feedback.distanceToGo, 3)}`;
}
function formatDuration(seconds) {
const safeSeconds = Math.max(Number(seconds) || 0, 0);
const minutes = Math.floor(safeSeconds / 60);
const remainder = safeSeconds - minutes * 60;
return `${minutes}:${remainder.toFixed(1).padStart(4, "0")}`;
}
function formatSwitchkinsSummary(programExecution) {
const count = programExecution?.summary?.switchkinsEventCount ?? 0;
if (count === 0) return "0 events";
const codes = programExecution.summary.switchkinsCodes?.join("/") || "-";
return `${count} events ${codes}`;
}
function formatMachineFileStaging(machineFileStaging) {
if (!machineFileStaging || machineFileStaging.status === "not-staged") {
return "not staged";
}
if (machineFileStaging.status === "error") {
return `error ${machineFileStaging.lastError || "-"}`;
}
return `${machineFileStaging.status} ${machineFileStaging.fileCount || 0} files ${machineFileStaging.opfsRoot || "-"}`;
}
function formatMachineFileExecution(machineFileExecution) {
if (!machineFileExecution?.machineFilePlan) return "not run";
const summary = machineFileExecution.summary || {};
return `${summary.machineFileExecutionReady ? "ready" : "ran"} ${summary.motionEventCount || 0} motion ${machineFileExecution.machineFilePlan.profileId}`;
}
function formatFullExecutionBoundary(boundary) {
if (!boundary) return "pending";
const remap = boundary.machineFileBackedRemapReady ? "remap ready" : "remap pending";
const full = boundary.fullLinuxCncProgramExecutionReady ? "full ready" : "full blocked";
return `${boundary.phase} / ${remap} / ${full}`;
}
function formatFullExecutionBlockers(boundary) {
if (!boundary) return "pending";
return boundary.blockers.slice(0, 2).join("; ");
}
function formatFullExecutionEvidence(boundary) {
if (!boundary) return "pending";
return `${boundary.satisfied.length} satisfied / ${boundary.missing.length} missing / ${boundary.semanticBoundary}`;
}
function renderOverride(element, state, dispatch) {
element.innerHTML = `
<section class="meter-card">
@@ -301,6 +525,7 @@ function renderBottomControls(element, state, dispatch) {
["Run", "RUN", () => dispatch({ type: "RUN" })],
["Stop", "STOP", () => dispatch({ type: "STOP" })],
["Pause", "PAUSE", () => dispatch({ type: "PAUSE" })],
["Resume", "RESUME", () => dispatch({ type: "RESUME" })],
["Step", "STEP", () => dispatch({ type: "STEP" })],
["Home", "HOME", () => dispatch({ type: "HOME" })],
["X-", "JOG_X_NEG", () => dispatch({ type: "JOG", axis: "x", direction: -1 })],
@@ -308,6 +533,9 @@ function renderBottomControls(element, state, dispatch) {
["Y-", "JOG_Y_NEG", () => dispatch({ type: "JOG", axis: "y", direction: -1 })],
["Y+", "JOG_Y_POS", () => dispatch({ type: "JOG", axis: "y", direction: 1 })],
["MDI", "MDI_RUN", () => dispatch({ type: "RUN_MDI" })],
["Save Session", "SAVE_SESSION", () => dispatch({ type: "SAVE_SESSION_REQUEST" })],
["Restore Session", "RESTORE_SESSION", () => dispatch({ type: "RESTORE_SESSION_REQUEST" })],
["Audit", "AUDIT_FULL_BOUNDARY", () => dispatch({ type: "RUN_FULL_BOUNDARY_AUDIT_REQUEST" })],
["Full", "FULL", () => dispatch({ type: "TOGGLE_FULLSCREEN" })],
];
@@ -345,6 +573,12 @@ function formatNumber(value, digits = 3) {
return Number(value).toFixed(digits);
}
function formatAxisLimitSummary(axisLimits) {
return Object.entries(axisLimits || {})
.map(([axis, limit]) => `${axis}[${formatNumber(limit.min, 0)},${formatNumber(limit.max, 0)}]`)
.join(" ");
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")

View File

@@ -3,24 +3,43 @@ import * as THREE from "../vendor/three/three.module.js";
const scenes = new WeakMap();
export function renderFiveAxisScene(canvas, state) {
const preview = scenes.get(canvas) || createScene(canvas);
scenes.set(canvas, preview);
let preview = scenes.get(canvas);
if (!preview) {
preview = createPreview(canvas);
scenes.set(canvas, preview);
}
resizeRenderer(preview);
updateMachinePose(preview, state);
preview.renderer.render(preview.scene, preview.camera);
if (preview.kind === "fallback") {
renderFallbackPreview(preview, state);
return;
}
try {
resizeRenderer(preview);
updateMachinePose(preview, state);
preview.renderer.render(preview.scene, preview.camera);
} catch (error) {
const fallback = createFallbackPreview(canvas, error);
scenes.set(canvas, fallback);
renderFallbackPreview(fallback, state);
return;
}
const pointCount = preview.pathLine.geometry.getAttribute("position").count;
canvas.dataset.threeReady = "true";
canvas.dataset.threeRevision = THREE.REVISION;
canvas.dataset.threePathPoints = String(pointCount);
canvas.dataset.threeSceneObjects = String(countSceneObjects(preview.scene));
canvas.dataset.threeToolhead = JSON.stringify(toRoundedVector(preview.toolGroup.position));
canvas.dataset.threeToolAxis = JSON.stringify(toRoundedVector(state.toolAxisVector));
canvas.dataset.threeTcpPose = JSON.stringify(toRoundedPose(state.tcpPose));
canvas.dataset.threeRtcpState = state.rtcpState;
canvas.dataset.threeSelectedView = state.preview.selectedView;
canvas.dataset.threeFrameApi = state.rtcpFrame.apiName;
exposePreviewDataset(canvas, state, {
pointCount,
sceneObjectCount: countSceneObjects(preview.scene),
toolhead: preview.toolGroup.position,
renderer: "webgl",
});
}
function createPreview(canvas) {
try {
return createScene(canvas);
} catch (error) {
return createFallbackPreview(canvas, error);
}
}
function createScene(canvas) {
@@ -29,25 +48,36 @@ function createScene(canvas) {
antialias: true,
preserveDrawingBuffer: true,
});
renderer.setClearColor(0x050505, 1);
renderer.setClearColor(0x07100d, 1);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(38, 1, 0.1, 100);
camera.position.set(4.2, -6.4, 4.8);
scene.fog = new THREE.Fog(0x07100d, 7, 15);
const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 100);
camera.position.set(3.4, -5.0, 3.2);
camera.lookAt(0, 0, 0);
const ambient = new THREE.AmbientLight(0xffffff, 0.46);
const key = new THREE.DirectionalLight(0xffffff, 1.1);
const ambient = new THREE.HemisphereLight(0xdffbff, 0x17110d, 0.86);
const key = new THREE.DirectionalLight(0xffffff, 1.35);
key.position.set(3, -5, 7);
scene.add(ambient, key);
const fill = new THREE.DirectionalLight(0x4fd5ff, 0.46);
fill.position.set(-4, 3, 3);
scene.add(ambient, key, fill);
const grid = new THREE.GridHelper(6.8, 12, 0x444444, 0x242424);
const floor = new THREE.Mesh(
new THREE.BoxGeometry(6.2, 4.5, 0.08),
new THREE.MeshStandardMaterial({ color: 0x151b1f, roughness: 0.78, metalness: 0.12 }),
);
floor.position.z = -0.98;
scene.add(floor);
const grid = new THREE.GridHelper(6.2, 14, 0x4b555a, 0x252f32);
grid.rotation.x = Math.PI / 2;
grid.position.z = -0.93;
scene.add(grid);
const axes = new THREE.AxesHelper(1.25);
axes.position.set(-2.7, -2.25, -1.05);
const axes = new THREE.AxesHelper(1.45);
axes.position.set(-2.85, -2.0, -0.86);
scene.add(axes);
const envelope = buildEnvelope();
@@ -55,12 +85,12 @@ function createScene(canvas) {
const tableGroup = new THREE.Group();
const table = new THREE.Mesh(
new THREE.BoxGeometry(2.55, 1.9, 0.16),
new THREE.MeshStandardMaterial({ color: 0x353535, roughness: 0.72, metalness: 0.2 }),
new THREE.BoxGeometry(3.0, 2.15, 0.18),
new THREE.MeshStandardMaterial({ color: 0x42484e, roughness: 0.68, metalness: 0.22 }),
);
const platter = new THREE.Mesh(
new THREE.CylinderGeometry(0.72, 0.72, 0.13, 48),
new THREE.MeshStandardMaterial({ color: 0x4f5960, roughness: 0.62, metalness: 0.35 }),
new THREE.CylinderGeometry(0.84, 0.84, 0.16, 64),
new THREE.MeshStandardMaterial({ color: 0x6d7880, roughness: 0.48, metalness: 0.42 }),
);
platter.rotation.x = Math.PI / 2;
platter.position.z = 0.14;
@@ -72,14 +102,14 @@ function createScene(canvas) {
const toolGroup = new THREE.Group();
const toolBody = new THREE.Mesh(
new THREE.CylinderGeometry(0.035, 0.055, 0.86, 24),
new THREE.MeshStandardMaterial({ color: 0x26d7df, emissive: 0x073a3d, roughness: 0.32 }),
new THREE.CylinderGeometry(0.045, 0.07, 1.05, 28),
new THREE.MeshStandardMaterial({ color: 0x29ecf0, emissive: 0x0a4f52, roughness: 0.28 }),
);
toolBody.rotation.x = Math.PI / 2;
toolBody.position.z = 0.43;
toolBody.position.z = 0.52;
const tcpPoint = new THREE.Mesh(
new THREE.SphereGeometry(0.075, 24, 16),
new THREE.MeshStandardMaterial({ color: 0x1ffff4, emissive: 0x094f4f, roughness: 0.2 }),
new THREE.SphereGeometry(0.095, 28, 18),
new THREE.MeshStandardMaterial({ color: 0x1ffff4, emissive: 0x0b6f6f, roughness: 0.18 }),
);
const toolAxis = new THREE.Line(
new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3(0, 0, 1)]),
@@ -89,6 +119,7 @@ function createScene(canvas) {
scene.add(toolGroup);
const preview = {
kind: "webgl",
renderer,
scene,
camera,
@@ -101,10 +132,136 @@ function createScene(canvas) {
return preview;
}
function createFallbackPreview(canvas, error) {
return {
kind: "fallback",
canvas,
errorMessage: error instanceof Error ? error.message : String(error),
};
}
function renderFallbackPreview(preview, state) {
const { canvas } = preview;
const width = Math.max(canvas.clientWidth, 320);
const height = Math.max(canvas.clientHeight, 240);
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = "#07100d";
ctx.fillRect(0, 0, width, height);
const cx = width * 0.5;
const cy = height * 0.53;
const scale = Math.min(width / 7.2, height / 4.8);
drawFallbackGrid(ctx, cx, cy, scale);
ctx.strokeStyle = "#e43a35";
ctx.lineWidth = 1.5;
ctx.strokeRect(cx - 2.95 * scale, cy - 1.9 * scale, 5.9 * scale, 3.8 * scale);
ctx.fillStyle = "#42484e";
ctx.strokeStyle = "#79838a";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.roundRect(cx - 1.5 * scale, cy - 0.78 * scale, 3.0 * scale, 1.56 * scale, 4);
ctx.fill();
ctx.stroke();
ctx.fillStyle = "#6d7880";
ctx.strokeStyle = "#a3b0b8";
ctx.beginPath();
ctx.ellipse(cx, cy, 0.84 * scale, 0.48 * scale, 0, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.strokeStyle = "#ffffff";
ctx.lineWidth = 2;
ctx.beginPath();
for (let index = 0; index < 72; index += 1) {
const t = index / 71;
const x = cx + (-2.45 + t * 4.9) * scale;
const y = cy + Math.sin(t * Math.PI * 13) * 0.36 * scale;
if (index === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
const tcp = state.tcpPose;
const tool = state.toolAxisVector;
const toolX = cx + clamp(tcp.x * 0.035, -2.7, 2.7) * scale;
const toolY = cy - clamp(tcp.y * 0.035, -2.0, 2.0) * scale;
const axisX = toolX + tool.x * 0.85 * scale;
const axisY = toolY - (tool.y || 0.2) * 0.85 * scale;
ctx.strokeStyle = "#21f2f2";
ctx.fillStyle = "#1ffff4";
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(toolX, toolY);
ctx.lineTo(axisX, axisY);
ctx.stroke();
ctx.beginPath();
ctx.arc(toolX, toolY, 0.09 * scale, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#b7c7b8";
ctx.font = "12px Courier New, monospace";
ctx.fillText("2D RTCP fallback", 12, height - 14);
exposePreviewDataset(canvas, state, {
pointCount: 72,
sceneObjectCount: 12,
toolhead: {
x: clamp(tcp.x * 0.035, -2.7, 2.7),
y: clamp(tcp.y * 0.035, -2.0, 2.0),
z: clamp(tcp.z * 0.04 + 0.35, -1.1, 1.9),
},
renderer: "2d-fallback",
});
canvas.dataset.threeFallbackReason = preview.errorMessage;
}
function drawFallbackGrid(ctx, cx, cy, scale) {
ctx.strokeStyle = "#273236";
ctx.lineWidth = 1;
for (let x = -3; x <= 3; x += 0.5) {
ctx.beginPath();
ctx.moveTo(cx + x * scale, cy - 2.1 * scale);
ctx.lineTo(cx + x * scale, cy + 2.1 * scale);
ctx.stroke();
}
for (let y = -2; y <= 2; y += 0.5) {
ctx.beginPath();
ctx.moveTo(cx - 3.1 * scale, cy + y * scale);
ctx.lineTo(cx + 3.1 * scale, cy + y * scale);
ctx.stroke();
}
}
function exposePreviewDataset(canvas, state, preview) {
canvas.dataset.threeReady = "true";
canvas.dataset.threeRevision = THREE.REVISION;
canvas.dataset.threePathPoints = String(preview.pointCount);
canvas.dataset.threeSceneObjects = String(preview.sceneObjectCount);
canvas.dataset.threeToolhead = JSON.stringify(toRoundedVector(preview.toolhead));
canvas.dataset.threeToolAxis = JSON.stringify(toRoundedVector(state.toolAxisVector));
canvas.dataset.threeTcpPose = JSON.stringify(toRoundedPose(state.tcpPose));
canvas.dataset.threeRtcpState = state.rtcpState;
canvas.dataset.threeSelectedView = state.preview.selectedView;
canvas.dataset.threeFrameApi = state.rtcpFrame.apiName;
canvas.dataset.threeRenderer = preview.renderer;
}
function buildEnvelope() {
const geometry = new THREE.BoxGeometry(5.8, 4.3, 2.6);
const geometry = new THREE.BoxGeometry(5.9, 4.25, 2.7);
const edges = new THREE.EdgesGeometry(geometry);
const line = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0xcc2525 }));
const line = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0xe43a35 }));
line.position.z = 0.2;
return line;
}
@@ -113,13 +270,13 @@ function buildToolpath() {
const points = [];
for (let index = 0; index < 72; index += 1) {
const t = index / 71;
const x = -2.3 + t * 4.6;
const y = Math.sin(t * Math.PI * 13) * 0.28;
const z = -0.75 + Math.sin(t * Math.PI * 2) * 0.36;
const x = -2.45 + t * 4.9;
const y = Math.sin(t * Math.PI * 13) * 0.36;
const z = -0.68 + Math.sin(t * Math.PI * 2) * 0.42;
points.push(new THREE.Vector3(x, y, z));
}
const geometry = new THREE.BufferGeometry().setFromPoints(points);
return new THREE.Line(geometry, new THREE.LineBasicMaterial({ color: 0xf4f4f4 }));
return new THREE.Line(geometry, new THREE.LineBasicMaterial({ color: 0xffffff }));
}
function updateMachinePose(preview, state) {
@@ -145,13 +302,13 @@ function updateMachinePose(preview, state) {
function setCameraView(camera, selectedView) {
if (selectedView === "x") {
camera.position.set(6, 0.02, 0.4);
camera.position.set(6, 0.02, 0.45);
} else if (selectedView === "y") {
camera.position.set(0.02, -6, 0.6);
camera.position.set(0.02, -6, 0.7);
} else if (selectedView === "z") {
camera.position.set(0.01, -0.02, 7);
camera.position.set(0.01, -0.02, 6.6);
} else {
camera.position.set(4.2, -6.4, 4.8);
camera.position.set(3.4, -5.0, 3.2);
}
camera.lookAt(0, 0, 0);
camera.updateProjectionMatrix();