结论:按 align-linuxcnc 约束收窄 OPFS removeFile 和 LinuxCNC 参数 .new/.bak 清理为文件专用语义,避免把同名目录递归删除。依据 ../linuxcnc/src/emc/rs274ngc/rs274ngc_pre.cc save_parameters() 的 fopen(filename.new)、unlink(filename.bak)、link、rename 行为。 验证:./test-native.sh 通过;./test-linuxcnc-source-link.sh 通过。
796 lines
27 KiB
JavaScript
796 lines
27 KiB
JavaScript
const DIALECT = {
|
|
linuxcnc: 0,
|
|
fanuc: 1,
|
|
siemens: 2,
|
|
};
|
|
|
|
const EVENT_TYPE = {
|
|
1: "error",
|
|
2: "comment",
|
|
3: "set-units",
|
|
4: "set-plane",
|
|
5: "set-feed",
|
|
6: "set-spindle",
|
|
7: "tool-change",
|
|
8: "dwell",
|
|
9: "rapid",
|
|
10: "linear-feed",
|
|
11: "arc-feed",
|
|
12: "probe",
|
|
13: "program-end",
|
|
14: "rtcp-pivot",
|
|
15: "kinematics-switch",
|
|
16: "rtcp-state",
|
|
17: "set-g5x-offset",
|
|
18: "set-g92-offset",
|
|
19: "set-xy-rotation",
|
|
};
|
|
|
|
const EVENT_OFFSETS = {
|
|
type: 4,
|
|
line: 8,
|
|
plane: 12,
|
|
tool: 16,
|
|
feed: 24,
|
|
spindle: 32,
|
|
dwellSeconds: 40,
|
|
start: 48,
|
|
end: 120,
|
|
center: 192,
|
|
arcTurns: 264,
|
|
reserved: 268,
|
|
};
|
|
|
|
const DEFAULT_OPFS_MOUNT_POINT = "/cnc";
|
|
const LINUXCNC_DEFAULT_PARAMETER_FILE = "rs274ngc.var";
|
|
const LINUXCNC_PARAMETER_SCRATCH_FILES = [
|
|
LINUXCNC_DEFAULT_PARAMETER_FILE,
|
|
`${LINUXCNC_DEFAULT_PARAMETER_FILE}.new`,
|
|
`${LINUXCNC_DEFAULT_PARAMETER_FILE}.bak`,
|
|
];
|
|
|
|
function isOpfsAvailable() {
|
|
return Boolean(globalThis.navigator?.storage?.getDirectory);
|
|
}
|
|
|
|
function assertRelativePath(path) {
|
|
if (!path || typeof path !== "string") {
|
|
throw new Error("OPFS path must be a non-empty relative path");
|
|
}
|
|
if (
|
|
path.startsWith("/") ||
|
|
path.includes("\\") ||
|
|
path.includes("\0") ||
|
|
path.split("/").some((part) => part === "" || part === "." || part === "..")
|
|
) {
|
|
throw new Error(`OPFS path must stay inside the CNC workspace: ${path}`);
|
|
}
|
|
}
|
|
|
|
function assertWorkspacePath(path) {
|
|
assertRelativePath(path);
|
|
}
|
|
|
|
function assertMountPoint(path) {
|
|
if (!path || typeof path !== "string") {
|
|
throw new Error("OPFS mount point must be a non-empty absolute path");
|
|
}
|
|
const parts = path.split("/").slice(1);
|
|
if (
|
|
!path.startsWith("/") ||
|
|
path.includes("\\") ||
|
|
path.includes("\0") ||
|
|
parts.some((part) => part === "" || part === "." || part === "..")
|
|
) {
|
|
throw new Error(`OPFS mount point must stay inside the Emscripten filesystem: ${path}`);
|
|
}
|
|
}
|
|
|
|
async function getOpfsDirectoryHandle(path, create) {
|
|
if (!isOpfsAvailable()) {
|
|
throw new Error("OPFS is not available in this browser context");
|
|
}
|
|
let directory = await globalThis.navigator.storage.getDirectory();
|
|
for (const part of path.split("/").filter(Boolean)) {
|
|
directory = await directory.getDirectoryHandle(part, { create });
|
|
}
|
|
return directory;
|
|
}
|
|
|
|
async function readOpfsFile(workspacePath, relativePath) {
|
|
assertRelativePath(relativePath);
|
|
const parts = relativePath.split("/");
|
|
const fileName = parts.pop();
|
|
const directory = await getOpfsDirectoryHandle([workspacePath, ...parts].filter(Boolean).join("/"), false);
|
|
const file = await directory.getFileHandle(fileName, { create: false });
|
|
return new Uint8Array(await (await file.getFile()).arrayBuffer());
|
|
}
|
|
|
|
async function writeOpfsFile(workspacePath, relativePath, data) {
|
|
assertRelativePath(relativePath);
|
|
const parts = relativePath.split("/");
|
|
const fileName = parts.pop();
|
|
const directory = await getOpfsDirectoryHandle([workspacePath, ...parts].filter(Boolean).join("/"), true);
|
|
const file = await directory.getFileHandle(fileName, { create: true });
|
|
const writable = await file.createWritable();
|
|
try {
|
|
await writable.truncate(0);
|
|
await writable.write(data);
|
|
await writable.close();
|
|
} catch (error) {
|
|
try {
|
|
await writable.abort();
|
|
} catch (_abortError) {
|
|
// Preserve the original OPFS write failure.
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function removeOpfsEntry(workspacePath, relativePath, recursive) {
|
|
assertRelativePath(relativePath);
|
|
const parts = relativePath.split("/");
|
|
const entryName = parts.pop();
|
|
try {
|
|
const directory = await getOpfsDirectoryHandle([workspacePath, ...parts].filter(Boolean).join("/"), false);
|
|
await directory.removeEntry(entryName, { recursive });
|
|
} catch (error) {
|
|
if (error?.name !== "NotFoundError") {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function removeOpfsFileEntry(workspacePath, relativePath) {
|
|
assertRelativePath(relativePath);
|
|
const parts = relativePath.split("/");
|
|
const entryName = parts.pop();
|
|
try {
|
|
const directory = await getOpfsDirectoryHandle([workspacePath, ...parts].filter(Boolean).join("/"), false);
|
|
await directory.getFileHandle(entryName, { create: false });
|
|
await directory.removeEntry(entryName, { recursive: false });
|
|
} catch (error) {
|
|
if (error?.name !== "NotFoundError" && error?.name !== "TypeMismatchError") {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function opfsEntryExists(workspacePath, relativePath) {
|
|
assertRelativePath(relativePath);
|
|
const parts = relativePath.split("/");
|
|
const entryName = parts.pop();
|
|
try {
|
|
const directory = await getOpfsDirectoryHandle([workspacePath, ...parts].filter(Boolean).join("/"), false);
|
|
try {
|
|
await directory.getFileHandle(entryName, { create: false });
|
|
return true;
|
|
} catch (fileError) {
|
|
if (fileError?.name !== "NotFoundError" && fileError?.name !== "TypeMismatchError") {
|
|
throw fileError;
|
|
}
|
|
}
|
|
try {
|
|
await directory.getDirectoryHandle(entryName, { create: false });
|
|
return true;
|
|
} catch (directoryError) {
|
|
if (directoryError?.name !== "NotFoundError" && directoryError?.name !== "TypeMismatchError") {
|
|
throw directoryError;
|
|
}
|
|
}
|
|
return false;
|
|
} catch (error) {
|
|
if (error?.name === "NotFoundError") {
|
|
return false;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function statOpfsEntry(workspacePath, relativePath) {
|
|
assertRelativePath(relativePath);
|
|
const parts = relativePath.split("/");
|
|
const entryName = parts.pop();
|
|
const directory = await getOpfsDirectoryHandle([workspacePath, ...parts].filter(Boolean).join("/"), false);
|
|
try {
|
|
const file = await directory.getFileHandle(entryName, { create: false });
|
|
return { kind: "file", size: (await file.getFile()).size };
|
|
} catch (fileError) {
|
|
if (fileError?.name !== "TypeMismatchError") {
|
|
throw fileError;
|
|
}
|
|
}
|
|
await directory.getDirectoryHandle(entryName, { create: false });
|
|
return { kind: "directory", size: null };
|
|
}
|
|
|
|
async function clearOpfsWorkspace(workspacePath) {
|
|
const parts = workspacePath.split("/").filter(Boolean);
|
|
if (parts.length === 0) {
|
|
return;
|
|
}
|
|
const entryName = parts.pop();
|
|
const directory = await getOpfsDirectoryHandle(parts.join("/"), true);
|
|
try {
|
|
await directory.removeEntry(entryName, { recursive: true });
|
|
} catch (error) {
|
|
if (error?.name !== "NotFoundError") {
|
|
throw error;
|
|
}
|
|
}
|
|
await getOpfsDirectoryHandle(workspacePath, true);
|
|
}
|
|
|
|
async function replaceOpfsDirectory(workspacePath, relativePath) {
|
|
assertRelativePath(relativePath);
|
|
await removeOpfsEntry(workspacePath, relativePath, true);
|
|
const directory = await getOpfsDirectoryHandle([workspacePath, relativePath].filter(Boolean).join("/"), true);
|
|
for await (const [name] of directory.entries()) {
|
|
await directory.removeEntry(name, { recursive: true });
|
|
}
|
|
}
|
|
|
|
async function readOpfsDirectoryTree(workspacePath, relativePath, relativeRoot = "") {
|
|
assertRelativePath(relativePath);
|
|
const directory = await getOpfsDirectoryHandle([workspacePath, relativePath, relativeRoot].filter(Boolean).join("/"), false);
|
|
const files = [];
|
|
const directories = [];
|
|
for await (const [name, handle] of directory.entries()) {
|
|
const path = relativeRoot ? `${relativeRoot}/${name}` : name;
|
|
if (handle.kind === "directory") {
|
|
directories.push(path);
|
|
const child = await readOpfsDirectoryTree(workspacePath, relativePath, path);
|
|
directories.push(...child.directories);
|
|
files.push(...child.files);
|
|
} else if (handle.kind === "file") {
|
|
files.push(path);
|
|
}
|
|
}
|
|
return {
|
|
directories: directories.sort(),
|
|
files: files.sort(),
|
|
};
|
|
}
|
|
|
|
function ensureWasmDirectory(module, path) {
|
|
const parts = path.split("/").filter(Boolean);
|
|
let current = "";
|
|
for (const part of parts) {
|
|
current += `/${part}`;
|
|
try {
|
|
module.FS.mkdir(current);
|
|
} catch (error) {
|
|
if (error?.errno !== 20) {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function readWasmDirectoryTree(module, rootPath, relativeRoot = "") {
|
|
const files = [];
|
|
const directories = [];
|
|
for (const entry of module.FS.readdir(rootPath)) {
|
|
if (entry === "." || entry === "..") {
|
|
continue;
|
|
}
|
|
const path = `${rootPath}/${entry}`;
|
|
const relativePath = relativeRoot ? `${relativeRoot}/${entry}` : entry;
|
|
const mode = module.FS.stat(path).mode;
|
|
if (module.FS.isDir(mode)) {
|
|
directories.push(relativePath);
|
|
const child = readWasmDirectoryTree(module, path, relativePath);
|
|
directories.push(...child.directories);
|
|
files.push(...child.files);
|
|
} else if (module.FS.isFile(mode)) {
|
|
files.push(relativePath);
|
|
}
|
|
}
|
|
return {
|
|
directories: directories.sort(),
|
|
files: files.sort(),
|
|
};
|
|
}
|
|
|
|
function removeWasmPath(module, path, recursive) {
|
|
let mode;
|
|
try {
|
|
mode = module.FS.stat(path).mode;
|
|
} catch (error) {
|
|
if (error?.errno !== 44) {
|
|
throw error;
|
|
}
|
|
return;
|
|
}
|
|
if (module.FS.isDir(mode)) {
|
|
if (!recursive) {
|
|
module.FS.rmdir(path);
|
|
return;
|
|
}
|
|
for (const entry of module.FS.readdir(path)) {
|
|
if (entry !== "." && entry !== "..") {
|
|
removeWasmPath(module, `${path}/${entry}`, true);
|
|
}
|
|
}
|
|
module.FS.rmdir(path);
|
|
} else {
|
|
module.FS.unlink(path);
|
|
}
|
|
}
|
|
|
|
function removeWasmFilePath(module, path) {
|
|
let mode;
|
|
try {
|
|
mode = module.FS.stat(path).mode;
|
|
} catch (error) {
|
|
if (error?.errno !== 44) {
|
|
throw error;
|
|
}
|
|
return;
|
|
}
|
|
if (module.FS.isFile(mode)) {
|
|
module.FS.unlink(path);
|
|
}
|
|
}
|
|
|
|
function wasmPathExists(module, path) {
|
|
try {
|
|
module.FS.stat(path);
|
|
return true;
|
|
} catch (error) {
|
|
if (error?.errno !== 44) {
|
|
throw error;
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function readFirstExistingWasmFile(module, paths) {
|
|
for (const path of paths) {
|
|
try {
|
|
return module.FS.readFile(path);
|
|
} catch (error) {
|
|
if (error?.errno !== 44) {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function writeWasmFileReplacingPath(module, path, data) {
|
|
removeWasmPath(module, path, true);
|
|
const parentIndex = path.lastIndexOf("/");
|
|
if (parentIndex > 0) {
|
|
ensureWasmDirectory(module, path.slice(0, parentIndex));
|
|
}
|
|
module.FS.writeFile(path, data);
|
|
}
|
|
|
|
function cleanupLinuxCncParameterFiles(module) {
|
|
for (const path of LINUXCNC_PARAMETER_SCRATCH_FILES) {
|
|
removeWasmPath(module, path, true);
|
|
removeWasmPath(module, `/${path}`, true);
|
|
}
|
|
}
|
|
|
|
function installOpfsWorkspace(module, options) {
|
|
const opfsOptions = options.opfs ?? (isOpfsAvailable() ? {} : false);
|
|
if (opfsOptions === false) {
|
|
if (isOpfsAvailable()) {
|
|
throw new Error("OPFS cannot be disabled in browser contexts with OPFS support");
|
|
}
|
|
return null;
|
|
}
|
|
if (!module.FS) {
|
|
throw new Error("cnc_sim.js was not built with Emscripten FS export");
|
|
}
|
|
if (!isOpfsAvailable()) {
|
|
if (opfsOptions.required) {
|
|
throw new Error("OPFS is only available in browser contexts");
|
|
}
|
|
return null;
|
|
}
|
|
const mountPoint = opfsOptions.mountPoint ?? DEFAULT_OPFS_MOUNT_POINT;
|
|
const workspacePath = opfsOptions.workspacePath ?? "cnc-simulator";
|
|
assertMountPoint(mountPoint);
|
|
assertWorkspacePath(workspacePath);
|
|
ensureWasmDirectory(module, mountPoint);
|
|
return {
|
|
mountPoint,
|
|
workspacePath,
|
|
resolvePath(path) {
|
|
assertRelativePath(path);
|
|
return `${mountPoint}/${path}`;
|
|
},
|
|
async readFile(path) {
|
|
const data = await readOpfsFile(workspacePath, path);
|
|
const wasmPath = this.resolvePath(path);
|
|
writeWasmFileReplacingPath(module, wasmPath, data);
|
|
return data;
|
|
},
|
|
async readDirectory(path) {
|
|
assertRelativePath(path);
|
|
let tree;
|
|
try {
|
|
tree = await readOpfsDirectoryTree(workspacePath, path);
|
|
} catch (error) {
|
|
if (error?.name === "NotFoundError") {
|
|
removeWasmPath(module, this.resolvePath(path), true);
|
|
}
|
|
throw error;
|
|
}
|
|
const wasmPath = this.resolvePath(path);
|
|
removeWasmPath(module, wasmPath, true);
|
|
ensureWasmDirectory(module, wasmPath);
|
|
for (const directory of tree.directories) {
|
|
ensureWasmDirectory(module, `${wasmPath}/${directory}`);
|
|
}
|
|
for (const file of tree.files) {
|
|
await this.readFile(`${path}/${file}`);
|
|
}
|
|
return tree.files;
|
|
},
|
|
async persistFile(path) {
|
|
const wasmPath = this.resolvePath(path);
|
|
const data = module.FS.readFile(wasmPath);
|
|
await writeOpfsFile(workspacePath, path, data);
|
|
return data;
|
|
},
|
|
async persistDirectory(path) {
|
|
assertRelativePath(path);
|
|
const wasmPath = this.resolvePath(path);
|
|
const tree = readWasmDirectoryTree(module, wasmPath);
|
|
await replaceOpfsDirectory(workspacePath, path);
|
|
for (const directory of tree.directories) {
|
|
await getOpfsDirectoryHandle([workspacePath, path, directory].filter(Boolean).join("/"), true);
|
|
}
|
|
for (const file of tree.files) {
|
|
await this.persistFile(`${path}/${file}`);
|
|
}
|
|
return tree.files;
|
|
},
|
|
async writeFile(path, data) {
|
|
await writeOpfsFile(workspacePath, path, data);
|
|
const wasmPath = this.resolvePath(path);
|
|
writeWasmFileReplacingPath(module, wasmPath, data);
|
|
},
|
|
// LinuxCNC source basis: rs274ngc_pre.cc save_parameters() links the
|
|
// current parameter file to filename + ".bak" before replacing it.
|
|
async copyFile(fromPath, toPath) {
|
|
assertRelativePath(fromPath);
|
|
assertRelativePath(toPath);
|
|
const data = await readOpfsFile(workspacePath, fromPath);
|
|
await writeOpfsFile(workspacePath, toPath, data);
|
|
|
|
const fromWasmPath = this.resolvePath(fromPath);
|
|
const toWasmPath = this.resolvePath(toPath);
|
|
writeWasmFileReplacingPath(module, fromWasmPath, data);
|
|
writeWasmFileReplacingPath(module, toWasmPath, data);
|
|
return data;
|
|
},
|
|
// LinuxCNC source basis: rs274ngc_pre.cc save_parameters() writes a
|
|
// temporary filename + ".new" and renames it over the parameter file.
|
|
async moveFile(fromPath, toPath) {
|
|
assertRelativePath(fromPath);
|
|
assertRelativePath(toPath);
|
|
if (fromPath === toPath) {
|
|
return;
|
|
}
|
|
const data = await readOpfsFile(workspacePath, fromPath);
|
|
await writeOpfsFile(workspacePath, toPath, data);
|
|
await removeOpfsEntry(workspacePath, fromPath, false);
|
|
|
|
const fromWasmPath = this.resolvePath(fromPath);
|
|
const toWasmPath = this.resolvePath(toPath);
|
|
writeWasmFileReplacingPath(module, toWasmPath, data);
|
|
removeWasmPath(module, fromWasmPath, false);
|
|
},
|
|
async exists(path) {
|
|
return opfsEntryExists(workspacePath, path);
|
|
},
|
|
async stat(path) {
|
|
return statOpfsEntry(workspacePath, path);
|
|
},
|
|
// LinuxCNC source basis: rs274ngc_pre.cc restore_parameters() reads the
|
|
// configured parameter file, and save_parameters() writes filename + ".new"
|
|
// before replacing the main file and managing filename + ".bak".
|
|
async loadParameterFile(path) {
|
|
assertRelativePath(path);
|
|
try {
|
|
const data = await readOpfsFile(workspacePath, path);
|
|
writeWasmFileReplacingPath(module, LINUXCNC_DEFAULT_PARAMETER_FILE, data);
|
|
writeWasmFileReplacingPath(module, `/${LINUXCNC_DEFAULT_PARAMETER_FILE}`, data);
|
|
return data;
|
|
} catch (error) {
|
|
if (error?.name !== "NotFoundError") {
|
|
throw error;
|
|
}
|
|
cleanupLinuxCncParameterFiles(module);
|
|
return null;
|
|
}
|
|
},
|
|
async persistParameterFile(path) {
|
|
assertRelativePath(path);
|
|
const data = readFirstExistingWasmFile(module, [
|
|
LINUXCNC_DEFAULT_PARAMETER_FILE,
|
|
`/${LINUXCNC_DEFAULT_PARAMETER_FILE}`,
|
|
]);
|
|
if (data === null) {
|
|
throw new Error("LinuxCNC parameter file was not produced by the interpreter");
|
|
}
|
|
await writeOpfsFile(workspacePath, path, data);
|
|
// LinuxCNC source basis: rs274ngc_pre.cc save_parameters()
|
|
// creates filename + ".new" with fopen() and later renames that
|
|
// file; OPFS cleanup must not remove a directory at that path.
|
|
await removeOpfsFileEntry(workspacePath, `${path}.new`);
|
|
removeWasmFilePath(module, `${LINUXCNC_DEFAULT_PARAMETER_FILE}.new`);
|
|
removeWasmFilePath(module, `/${LINUXCNC_DEFAULT_PARAMETER_FILE}.new`);
|
|
const backupPath = `${LINUXCNC_DEFAULT_PARAMETER_FILE}.bak`;
|
|
const backupData = readFirstExistingWasmFile(module, [backupPath, `/${backupPath}`]);
|
|
if (backupData !== null) {
|
|
await writeOpfsFile(workspacePath, `${path}.bak`, backupData);
|
|
} else {
|
|
await removeOpfsFileEntry(workspacePath, `${path}.bak`);
|
|
}
|
|
return data;
|
|
},
|
|
async removeFile(path) {
|
|
// LinuxCNC source basis: rs274ngc_pre.cc save_parameters() uses
|
|
// unlink() for backup files, so the browser bridge keeps file-only
|
|
// removal separate from recursive directory cleanup.
|
|
await removeOpfsFileEntry(workspacePath, path);
|
|
removeWasmFilePath(module, this.resolvePath(path));
|
|
},
|
|
async removeDirectory(path) {
|
|
await removeOpfsEntry(workspacePath, path, true);
|
|
removeWasmPath(module, this.resolvePath(path), true);
|
|
},
|
|
async clear() {
|
|
await clearOpfsWorkspace(workspacePath);
|
|
for (const entry of module.FS.readdir(mountPoint)) {
|
|
if (entry !== "." && entry !== "..") {
|
|
removeWasmPath(module, `${mountPoint}/${entry}`, true);
|
|
}
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
function readPose(view, offset) {
|
|
return {
|
|
x: view.getFloat64(offset + 0, true),
|
|
y: view.getFloat64(offset + 8, true),
|
|
z: view.getFloat64(offset + 16, true),
|
|
a: view.getFloat64(offset + 24, true),
|
|
b: view.getFloat64(offset + 32, true),
|
|
c: view.getFloat64(offset + 40, true),
|
|
u: view.getFloat64(offset + 48, true),
|
|
v: view.getFloat64(offset + 56, true),
|
|
w: view.getFloat64(offset + 64, true),
|
|
};
|
|
}
|
|
|
|
function readEvent(module, ptr) {
|
|
const view = new DataView(module.HEAPU8.buffer, ptr, 272);
|
|
return {
|
|
type: EVENT_TYPE[view.getInt32(EVENT_OFFSETS.type, true)] ?? "unknown",
|
|
line: view.getInt32(EVENT_OFFSETS.line, true),
|
|
plane: view.getInt32(EVENT_OFFSETS.plane, true),
|
|
tool: view.getInt32(EVENT_OFFSETS.tool, true),
|
|
feed: view.getFloat64(EVENT_OFFSETS.feed, true),
|
|
spindle: view.getFloat64(EVENT_OFFSETS.spindle, true),
|
|
dwellSeconds: view.getFloat64(EVENT_OFFSETS.dwellSeconds, true),
|
|
start: readPose(view, EVENT_OFFSETS.start),
|
|
end: readPose(view, EVENT_OFFSETS.end),
|
|
center: readPose(view, EVENT_OFFSETS.center),
|
|
arcTurns: view.getInt32(EVENT_OFFSETS.arcTurns, true),
|
|
reserved: view.getInt32(EVENT_OFFSETS.reserved, true),
|
|
};
|
|
}
|
|
|
|
async function loadModuleFactory() {
|
|
if (globalThis.createCncSimModule) {
|
|
return globalThis.createCncSimModule;
|
|
}
|
|
const module = await import("/cnc_sim.js");
|
|
return module.default ?? module.createCncSimModule ?? globalThis.createCncSimModule;
|
|
}
|
|
|
|
export async function createWasmSimulator(moduleOptions = {}) {
|
|
if (moduleOptions.opfs === false && isOpfsAvailable()) {
|
|
throw new Error("OPFS cannot be disabled in browser contexts with OPFS support");
|
|
}
|
|
const hasOpfsOptions = Object.prototype.hasOwnProperty.call(moduleOptions, "opfs");
|
|
const emscriptenModuleOptions = hasOpfsOptions
|
|
? (({ opfs: _opfs, ...rest }) => rest)(moduleOptions)
|
|
: moduleOptions;
|
|
|
|
const createModule = await loadModuleFactory();
|
|
if (!createModule) {
|
|
throw new Error("cnc_sim.js did not export createCncSimModule");
|
|
}
|
|
|
|
const module = hasOpfsOptions ? await createModule(emscriptenModuleOptions) : await createModule(moduleOptions);
|
|
const opfs = installOpfsWorkspace(module, moduleOptions);
|
|
const create = module.cwrap("cnc_sim_create", "number", []);
|
|
const destroy = module.cwrap("cnc_sim_destroy", null, ["number"]);
|
|
const reset = module.cwrap("cnc_sim_reset", null, ["number"]);
|
|
const setDialect = module.cwrap("cnc_sim_set_dialect", "number", ["number", "number"]);
|
|
const setCallback = module.cwrap("cnc_sim_set_event_callback", "number", ["number", "number", "number"]);
|
|
const loadConfig = module.cwrap("cnc_sim_load_config_json", "number", ["number", "number", "number"]);
|
|
const parseProgram = module.cwrap("cnc_sim_parse_program", "number", ["number", "number", "number"]);
|
|
const lastError = module.cwrap("cnc_sim_last_error", "number", ["number"]);
|
|
|
|
const handle = create();
|
|
let callbackPtr = 0;
|
|
|
|
const parseText = (program, dialect = "linuxcnc", options = {}, parseOptions = {}) => {
|
|
const events = [];
|
|
try {
|
|
reset(handle);
|
|
setDialect(handle, DIALECT[dialect] ?? DIALECT.linuxcnc);
|
|
|
|
const config = JSON.stringify({
|
|
backend: options.backend ?? "linuxcnc-rs274",
|
|
...(options.rtcp ? { rtcp: options.rtcp } : {}),
|
|
...(options.blockDelete !== undefined ? { blockDelete: options.blockDelete } : {}),
|
|
...(options.block_delete !== undefined ? { block_delete: options.block_delete } : {}),
|
|
...(options.switchkins ? { switchkins: options.switchkins } : {}),
|
|
...(options.remap ? { remap: options.remap } : {}),
|
|
...(options.machine ? { machine: options.machine } : {}),
|
|
...(options.config ? { config: options.config } : {}),
|
|
...(options.configPath ? { configPath: options.configPath } : {}),
|
|
...(options.configpath ? { configpath: options.configpath } : {}),
|
|
...(options.config_path ? { config_path: options.config_path } : {}),
|
|
...(options.ini ? { ini: options.ini } : {}),
|
|
...(options.iniFile ? { iniFile: options.iniFile } : {}),
|
|
...(options.inifile ? { inifile: options.inifile } : {}),
|
|
...(options.iniFileName ? { iniFileName: options.iniFileName } : {}),
|
|
...(options.inifilename ? { inifilename: options.inifilename } : {}),
|
|
...(options.ini_file ? { ini_file: options.ini_file } : {}),
|
|
...(options.ini_file_name ? { ini_file_name: options.ini_file_name } : {}),
|
|
...(options.INI_FILE_NAME ? { INI_FILE_NAME: options.INI_FILE_NAME } : {}),
|
|
...(options.halFile ? { halFile: options.halFile } : {}),
|
|
...(options.halfile ? { halfile: options.halfile } : {}),
|
|
...(options.hal_file ? { hal_file: options.hal_file } : {}),
|
|
...(options.HALFILE ? { HALFILE: options.HALFILE } : {}),
|
|
...(options.postguiHalFile ? { postguiHalFile: options.postguiHalFile } : {}),
|
|
...(options.postguihalfile ? { postguihalfile: options.postguihalfile } : {}),
|
|
...(options.postgui_halfile ? { postgui_halfile: options.postgui_halfile } : {}),
|
|
...(options.postgui_hal_file ? { postgui_hal_file: options.postgui_hal_file } : {}),
|
|
...(options.POSTGUI_HALFILE ? { POSTGUI_HALFILE: options.POSTGUI_HALFILE } : {}),
|
|
...(options.subroutinePath ? { subroutinePath: options.subroutinePath } : {}),
|
|
...(options.subroutinepath ? { subroutinepath: options.subroutinepath } : {}),
|
|
...(options.subroutine_path ? { subroutine_path: options.subroutine_path } : {}),
|
|
...(options.SUBROUTINE_PATH ? { SUBROUTINE_PATH: options.SUBROUTINE_PATH } : {}),
|
|
...(options.kinematics ? { kinematics: options.kinematics } : {}),
|
|
...(options.trt ? { trt: options.trt } : {}),
|
|
...(options.pivotLength !== undefined ? { pivotLength: options.pivotLength } : {}),
|
|
...(options.pivot_length !== undefined ? { pivot_length: options.pivot_length } : {}),
|
|
...(options.kinematicsType !== undefined ? { kinematicsType: options.kinematicsType } : {}),
|
|
...(options.kinematics_type !== undefined ? { kinematics_type: options.kinematics_type } : {}),
|
|
...(options.fiveaxisKinematicsType !== undefined
|
|
? { fiveaxisKinematicsType: options.fiveaxisKinematicsType }
|
|
: {}),
|
|
...(options.fiveaxis_kinematics_type !== undefined
|
|
? { fiveaxis_kinematics_type: options.fiveaxis_kinematics_type }
|
|
: {}),
|
|
...(options.xyzbcTrt ? { xyzbcTrt: options.xyzbcTrt } : {}),
|
|
});
|
|
const configBytes = module.lengthBytesUTF8(config) + 1;
|
|
const configPtr = module._malloc(configBytes);
|
|
let configRc;
|
|
try {
|
|
module.stringToUTF8(config, configPtr, configBytes);
|
|
configRc = loadConfig(handle, configPtr, configBytes - 1);
|
|
} finally {
|
|
module._free(configPtr);
|
|
}
|
|
if (configRc !== 0) {
|
|
throw new Error(module.UTF8ToString(lastError(handle)));
|
|
}
|
|
|
|
if (callbackPtr) {
|
|
module.removeFunction(callbackPtr);
|
|
}
|
|
callbackPtr = module.addFunction((eventPtr) => {
|
|
events.push(readEvent(module, eventPtr));
|
|
return 0;
|
|
}, "iii");
|
|
setCallback(handle, callbackPtr, 0);
|
|
|
|
const bytes = module.lengthBytesUTF8(program) + 1;
|
|
const ptr = module._malloc(bytes);
|
|
let rc;
|
|
try {
|
|
module.stringToUTF8(program, ptr, bytes);
|
|
rc = parseProgram(handle, ptr, bytes - 1);
|
|
} finally {
|
|
module._free(ptr);
|
|
}
|
|
|
|
if (rc !== 0) {
|
|
throw new Error(module.UTF8ToString(lastError(handle)));
|
|
}
|
|
return events;
|
|
} finally {
|
|
if (opfs && !parseOptions.keepLinuxCncParameterFiles) {
|
|
cleanupLinuxCncParameterFiles(module);
|
|
}
|
|
}
|
|
};
|
|
|
|
return {
|
|
parse(program, dialect = "linuxcnc", options = {}) {
|
|
return parseText(program, dialect, options);
|
|
},
|
|
|
|
async parseFile(path, dialect = "linuxcnc", options = {}) {
|
|
if (!opfs) {
|
|
throw new Error("OPFS workspace is not available for program file parsing");
|
|
}
|
|
const program = new TextDecoder().decode(await opfs.readFile(path));
|
|
return parseText(program, dialect, options);
|
|
},
|
|
|
|
async parseWithParameterFile(program, parameterPath, dialect = "linuxcnc", options = {}) {
|
|
if (!opfs) {
|
|
throw new Error("OPFS workspace is not available for parameter file persistence");
|
|
}
|
|
cleanupLinuxCncParameterFiles(module);
|
|
const previousParameterFile = await opfs.loadParameterFile(parameterPath);
|
|
try {
|
|
const events = parseText(program, dialect, options, { keepLinuxCncParameterFiles: true });
|
|
await opfs.persistParameterFile(parameterPath);
|
|
// LinuxCNC source basis: rs274ngc_pre.cc save_parameters()
|
|
// links the previous parameter file to filename + ".bak" before
|
|
// replacing filename. Browser filesystems may not expose that link
|
|
// through the WASM mirror, so preserve the loaded OPFS file here.
|
|
if (previousParameterFile === null) {
|
|
try {
|
|
const backupStat = await opfs.stat(`${parameterPath}.bak`);
|
|
if (backupStat.kind === "file") {
|
|
await opfs.removeFile(`${parameterPath}.bak`);
|
|
}
|
|
} catch (error) {
|
|
if (error?.name !== "NotFoundError") {
|
|
throw error;
|
|
}
|
|
}
|
|
} else {
|
|
try {
|
|
await opfs.writeFile(`${parameterPath}.bak`, previousParameterFile);
|
|
} catch (_backupError) {
|
|
// LinuxCNC treats link(filename, filename + ".bak") failure as non-fatal.
|
|
}
|
|
}
|
|
return events;
|
|
} finally {
|
|
cleanupLinuxCncParameterFiles(module);
|
|
}
|
|
},
|
|
|
|
async parseFileWithParameterFile(path, parameterPath, dialect = "linuxcnc", options = {}) {
|
|
if (!opfs) {
|
|
throw new Error("OPFS workspace is not available for program and parameter file parsing");
|
|
}
|
|
const program = new TextDecoder().decode(await opfs.readFile(path));
|
|
return this.parseWithParameterFile(program, parameterPath, dialect, options);
|
|
},
|
|
|
|
dispose() {
|
|
if (callbackPtr) {
|
|
module.removeFunction(callbackPtr);
|
|
callbackPtr = 0;
|
|
}
|
|
destroy(handle);
|
|
},
|
|
|
|
fs: {
|
|
opfs,
|
|
module,
|
|
},
|
|
};
|
|
}
|