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