第 6 组:源码链接、构建和文档约束闭环完成
This commit is contained in:
225
web/src/app.js
225
web/src/app.js
@@ -2,6 +2,7 @@ import { createWasmSimulator } from "./wasm-core.js";
|
||||
|
||||
const elements = {
|
||||
wasmState: document.querySelector("#wasmState"),
|
||||
storageState: document.querySelector("#storageState"),
|
||||
modeState: document.querySelector("#modeState"),
|
||||
unitState: document.querySelector("#unitState"),
|
||||
programState: document.querySelector("#programState"),
|
||||
@@ -26,12 +27,82 @@ const elements = {
|
||||
|
||||
let simulatorPromise = null;
|
||||
let lastEvents = [];
|
||||
let isParsing = false;
|
||||
let hasUserEditedProgram = false;
|
||||
let saveProgramTimer = 0;
|
||||
let saveProgramSequence = 0;
|
||||
let saveProgramPromise = Promise.resolve();
|
||||
let startupPromise = Promise.resolve();
|
||||
const programPath = "programs/current.ngc";
|
||||
// LinuxCNC source basis: interp_internal.hh defines RS274NGC_PARAMETER_FILE_NAME_DEFAULT.
|
||||
const linuxCncParameterFileName = "rs274ngc.var";
|
||||
const parameterPath = `parameters/${linuxCncParameterFileName}`;
|
||||
const linuxCncBackend = "linuxcnc-rs274";
|
||||
const opfsOptions = { required: true, mountPoint: "/cnc", workspacePath: "cnc-simulator" };
|
||||
|
||||
function setStatus(node, text, alarm = false) {
|
||||
node.textContent = text;
|
||||
node.classList.toggle("alarm", alarm);
|
||||
}
|
||||
|
||||
function setRuntimeReady(simulator) {
|
||||
setStatus(elements.wasmState, "WASM ONLINE");
|
||||
if (simulator.fs?.opfs) {
|
||||
const storageState = elements.storageState.textContent;
|
||||
if (storageState === "OPFS SAVING" || storageState.startsWith("OPFS SAVED")) {
|
||||
setStatus(elements.storageState, storageState);
|
||||
} else {
|
||||
setStatus(elements.storageState, "OPFS READY");
|
||||
}
|
||||
} else {
|
||||
setStatus(elements.storageState, "OPFS OFF", true);
|
||||
}
|
||||
}
|
||||
|
||||
function setRuntimeUnavailable() {
|
||||
setStatus(elements.wasmState, "WASM OFFLINE", true);
|
||||
setStatus(elements.storageState, "OPFS OFF", true);
|
||||
}
|
||||
|
||||
function setControlsEnabled(enabled) {
|
||||
elements.parseBtn.disabled = !enabled;
|
||||
elements.resetBtn.disabled = !enabled;
|
||||
elements.holdBtn.disabled = !enabled;
|
||||
elements.stopBtn.disabled = !enabled;
|
||||
elements.backendSelect.disabled = !enabled;
|
||||
}
|
||||
|
||||
function setStorageSaved(size) {
|
||||
setStatus(elements.storageState, Number.isFinite(size) ? `OPFS SAVED ${size}B` : "OPFS SAVED");
|
||||
}
|
||||
|
||||
function setProgramReady() {
|
||||
if (elements.programState.textContent !== "ALARM") {
|
||||
setStatus(elements.programState, "READY");
|
||||
}
|
||||
}
|
||||
|
||||
async function statIfExists(opfs, path) {
|
||||
if (!(await opfs.exists(path))) {
|
||||
return null;
|
||||
}
|
||||
return opfs.stat(path);
|
||||
}
|
||||
|
||||
async function logWorkspaceState(opfs) {
|
||||
const programStat = await statIfExists(opfs, programPath);
|
||||
if (programStat?.kind === "file") {
|
||||
log(`OPFS PROGRAM ${programStat.size}B`);
|
||||
}
|
||||
|
||||
const parameterStat = await statIfExists(opfs, parameterPath);
|
||||
if (parameterStat?.kind === "file") {
|
||||
log(`OPFS PARAMETERS ${parameterStat.size}B`);
|
||||
} else {
|
||||
log("OPFS PARAMETERS PENDING");
|
||||
}
|
||||
}
|
||||
|
||||
function log(message, error = false) {
|
||||
const line = document.createElement("div");
|
||||
line.className = `alarm-line${error ? " error" : ""}`;
|
||||
@@ -163,21 +234,63 @@ function updateLineCount() {
|
||||
elements.lineCount.textContent = `${count} LINES`;
|
||||
}
|
||||
|
||||
async function getOpfsWorkspace() {
|
||||
const simulator = await getSimulator();
|
||||
setRuntimeReady(simulator);
|
||||
if (!simulator.fs.opfs) {
|
||||
throw new Error("OPFS workspace is required for browser program parsing");
|
||||
}
|
||||
return simulator.fs.opfs;
|
||||
}
|
||||
|
||||
async function getSimulator() {
|
||||
if (!simulatorPromise) {
|
||||
simulatorPromise = createWasmSimulator();
|
||||
simulatorPromise = createWasmSimulator({
|
||||
opfs: opfsOptions,
|
||||
}).then((simulator) => {
|
||||
setRuntimeReady(simulator);
|
||||
return simulator;
|
||||
}).catch((error) => {
|
||||
simulatorPromise = null;
|
||||
globalThis.__cncSimulatorForTest = null;
|
||||
setRuntimeUnavailable();
|
||||
throw error;
|
||||
});
|
||||
globalThis.__cncSimulatorForTest = simulatorPromise;
|
||||
}
|
||||
return simulatorPromise;
|
||||
}
|
||||
|
||||
async function parseProgram() {
|
||||
if (isParsing) {
|
||||
return;
|
||||
}
|
||||
isParsing = true;
|
||||
setControlsEnabled(false);
|
||||
setStatus(elements.programState, "RUN");
|
||||
let runtimeReady = false;
|
||||
let savedProgramSize = null;
|
||||
try {
|
||||
await startupPromise.catch(() => {});
|
||||
const simulator = await getSimulator();
|
||||
setStatus(elements.wasmState, "WASM ONLINE");
|
||||
const events = simulator.parse(elements.input.value, "linuxcnc", {
|
||||
backend: elements.backendSelect.value,
|
||||
});
|
||||
runtimeReady = true;
|
||||
setRuntimeReady(simulator);
|
||||
const parseOptions = {
|
||||
backend: linuxCncBackend,
|
||||
};
|
||||
if (!simulator.fs.opfs) {
|
||||
throw new Error("OPFS workspace is required for browser program parsing");
|
||||
}
|
||||
elements.backendSelect.value = linuxCncBackend;
|
||||
await saveProgramPromise.catch(() => {});
|
||||
await simulator.fs.opfs.writeFile(programPath, elements.input.value);
|
||||
const programStat = await simulator.fs.opfs.stat(programPath);
|
||||
savedProgramSize = programStat.kind === "file" ? programStat.size : null;
|
||||
setStorageSaved(savedProgramSize);
|
||||
const events = await simulator.parseFileWithParameterFile(programPath, parameterPath, "linuxcnc", parseOptions);
|
||||
const parameterStat = await simulator.fs.opfs.stat(parameterPath);
|
||||
setStorageSaved(parameterStat.size);
|
||||
log(`OPFS PARAMETERS ${parameterStat.size}B`);
|
||||
lastEvents = events;
|
||||
drawToolpath(events);
|
||||
|
||||
@@ -193,9 +306,82 @@ async function parseProgram() {
|
||||
setStatus(elements.programState, "END");
|
||||
log(`OK ${events.length} EVENTS`);
|
||||
} catch (error) {
|
||||
setStatus(elements.wasmState, "WASM OFFLINE", true);
|
||||
if (!runtimeReady) {
|
||||
setRuntimeUnavailable();
|
||||
}
|
||||
if (savedProgramSize !== null) {
|
||||
log(`OPFS PROGRAM SAVED ${savedProgramSize}B`);
|
||||
}
|
||||
setStatus(elements.programState, "ALARM", true);
|
||||
log(error.message, true);
|
||||
} finally {
|
||||
isParsing = false;
|
||||
setControlsEnabled(true);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProgramDraft() {
|
||||
const sequence = ++saveProgramSequence;
|
||||
saveProgramPromise = saveProgramPromise.catch(() => {}).then(async () => {
|
||||
const opfs = await getOpfsWorkspace();
|
||||
const program = elements.input.value;
|
||||
setStatus(elements.storageState, "OPFS SAVING");
|
||||
await opfs.writeFile(programPath, program);
|
||||
if (sequence === saveProgramSequence) {
|
||||
const stat = await opfs.stat(programPath);
|
||||
setStorageSaved(stat.size);
|
||||
setProgramReady();
|
||||
}
|
||||
});
|
||||
try {
|
||||
await saveProgramPromise;
|
||||
} catch (error) {
|
||||
setStatus(elements.storageState, "OPFS ALARM", true);
|
||||
log(error.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleProgramSave() {
|
||||
window.clearTimeout(saveProgramTimer);
|
||||
saveProgramTimer = window.setTimeout(() => {
|
||||
void saveProgramDraft();
|
||||
}, 350);
|
||||
}
|
||||
|
||||
function flushProgramSave() {
|
||||
window.clearTimeout(saveProgramTimer);
|
||||
if (hasUserEditedProgram) {
|
||||
void saveProgramDraft();
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreProgram() {
|
||||
try {
|
||||
const opfs = await getOpfsWorkspace();
|
||||
if (!(await opfs.exists(programPath))) {
|
||||
await opfs.writeFile(programPath, elements.input.value);
|
||||
const stat = await opfs.stat(programPath);
|
||||
setStorageSaved(stat.size);
|
||||
setProgramReady();
|
||||
log(`OPFS PROGRAM CREATED ${stat.size}B`);
|
||||
await logWorkspaceState(opfs);
|
||||
return;
|
||||
}
|
||||
const program = new TextDecoder().decode(await opfs.readFile(programPath));
|
||||
if (hasUserEditedProgram) {
|
||||
return;
|
||||
}
|
||||
elements.input.value = program;
|
||||
updateLineCount();
|
||||
const stat = await opfs.stat(programPath);
|
||||
setStorageSaved(stat.size);
|
||||
setProgramReady();
|
||||
log("OPFS PROGRAM RESTORED");
|
||||
await logWorkspaceState(opfs);
|
||||
} catch (error) {
|
||||
if (error?.name !== "NotFoundError") {
|
||||
log(error.message, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,6 +391,7 @@ document.querySelectorAll("[data-mode]").forEach((button) => {
|
||||
});
|
||||
});
|
||||
|
||||
elements.backendSelect.value = linuxCncBackend;
|
||||
elements.parseBtn.addEventListener("click", parseProgram);
|
||||
elements.resetBtn.addEventListener("click", () => {
|
||||
lastEvents = [];
|
||||
@@ -214,8 +401,32 @@ elements.resetBtn.addEventListener("click", () => {
|
||||
});
|
||||
elements.holdBtn.addEventListener("click", () => setStatus(elements.programState, "HOLD"));
|
||||
elements.stopBtn.addEventListener("click", () => setStatus(elements.programState, "STOP", true));
|
||||
elements.input.addEventListener("input", updateLineCount);
|
||||
elements.input.addEventListener("input", () => {
|
||||
updateLineCount();
|
||||
hasUserEditedProgram = true;
|
||||
setStatus(elements.programState, "EDIT");
|
||||
scheduleProgramSave();
|
||||
});
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (!(event.ctrlKey || event.metaKey) || event.altKey) {
|
||||
return;
|
||||
}
|
||||
if (event.key === "s") {
|
||||
event.preventDefault();
|
||||
void saveProgramDraft();
|
||||
} else if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
void parseProgram();
|
||||
}
|
||||
});
|
||||
window.addEventListener("resize", resizeCanvas);
|
||||
window.addEventListener("pagehide", flushProgramSave);
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "hidden") {
|
||||
flushProgramSave();
|
||||
}
|
||||
});
|
||||
|
||||
updateLineCount();
|
||||
resizeCanvas();
|
||||
startupPromise = restoreProgram();
|
||||
|
||||
@@ -84,8 +84,19 @@ export type CncParseOptions = {
|
||||
config?: string;
|
||||
configPath?: string;
|
||||
config_path?: string;
|
||||
ini?: string;
|
||||
iniFile?: string;
|
||||
iniFileName?: string;
|
||||
ini_file?: string;
|
||||
ini_file_name?: string;
|
||||
INI_FILE_NAME?: string;
|
||||
halFile?: string;
|
||||
halfile?: string;
|
||||
hal_file?: string;
|
||||
postguiHalFile?: string;
|
||||
postgui_halfile?: string;
|
||||
postgui_hal_file?: string;
|
||||
POSTGUI_HALFILE?: string;
|
||||
kinematics?: string;
|
||||
trt?: "xyzbc" | "xyzac" | string;
|
||||
pivotLength?: number;
|
||||
|
||||
52
web/src/wasm-core.d.ts
vendored
52
web/src/wasm-core.d.ts
vendored
@@ -2,13 +2,65 @@ import type { CncDialect, CncEvent, CncParseOptions } from "./index";
|
||||
|
||||
export type WasmSimulator = {
|
||||
parse(program: string, dialect?: CncDialect, options?: CncParseOptions): CncEvent[];
|
||||
parseFile(path: string, dialect?: CncDialect, options?: CncParseOptions): Promise<CncEvent[]>;
|
||||
parseWithParameterFile(
|
||||
program: string,
|
||||
parameterPath: string,
|
||||
dialect?: CncDialect,
|
||||
options?: CncParseOptions,
|
||||
): Promise<CncEvent[]>;
|
||||
parseFileWithParameterFile(
|
||||
path: string,
|
||||
parameterPath: string,
|
||||
dialect?: CncDialect,
|
||||
options?: CncParseOptions,
|
||||
): Promise<CncEvent[]>;
|
||||
dispose(): void;
|
||||
fs: {
|
||||
opfs: WasmOpfsWorkspace | null;
|
||||
module: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export type WasmOpfsWorkspace = {
|
||||
mountPoint: string;
|
||||
workspacePath: string;
|
||||
resolvePath(path: string): string;
|
||||
readFile(path: string): Promise<Uint8Array>;
|
||||
readDirectory(path: string): Promise<string[]>;
|
||||
persistFile(path: string): Promise<Uint8Array>;
|
||||
persistDirectory(path: string): Promise<string[]>;
|
||||
writeFile(path: string, data: Uint8Array | string): Promise<void>;
|
||||
copyFile(fromPath: string, toPath: string): Promise<Uint8Array>;
|
||||
moveFile(fromPath: string, toPath: string): Promise<void>;
|
||||
exists(path: string): Promise<boolean>;
|
||||
stat(path: string): Promise<WasmOpfsEntryStat>;
|
||||
loadParameterFile(path: string): Promise<Uint8Array | null>;
|
||||
persistParameterFile(path: string): Promise<Uint8Array>;
|
||||
removeFile(path: string): Promise<void>;
|
||||
removeDirectory(path: string): Promise<void>;
|
||||
clear(): Promise<void>;
|
||||
};
|
||||
|
||||
export type WasmOpfsEntryStat =
|
||||
| {
|
||||
kind: "file";
|
||||
size: number;
|
||||
}
|
||||
| {
|
||||
kind: "directory";
|
||||
size: null;
|
||||
};
|
||||
|
||||
export type WasmModuleOptions = {
|
||||
locateFile?: (file: string) => string;
|
||||
print?: (text: string) => void;
|
||||
printErr?: (text: string) => void;
|
||||
opfs?: false | {
|
||||
required?: boolean;
|
||||
mountPoint?: string;
|
||||
workspacePath?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function createWasmSimulator(moduleOptions?: WasmModuleOptions): Promise<WasmSimulator>;
|
||||
|
||||
@@ -41,6 +41,486 @@ const EVENT_OFFSETS = {
|
||||
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 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 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);
|
||||
await removeOpfsEntry(workspacePath, `${path}.new`, false);
|
||||
removeWasmPath(module, `${LINUXCNC_DEFAULT_PARAMETER_FILE}.new`, false);
|
||||
removeWasmPath(module, `/${LINUXCNC_DEFAULT_PARAMETER_FILE}.new`, false);
|
||||
const backupPath = `${LINUXCNC_DEFAULT_PARAMETER_FILE}.bak`;
|
||||
const backupData = readFirstExistingWasmFile(module, [backupPath, `/${backupPath}`]);
|
||||
if (backupData !== null) {
|
||||
await writeOpfsFile(workspacePath, `${path}.bak`, backupData);
|
||||
} else {
|
||||
await removeOpfsEntry(workspacePath, `${path}.bak`, false);
|
||||
}
|
||||
return data;
|
||||
},
|
||||
async removeFile(path) {
|
||||
await removeOpfsEntry(workspacePath, path, false);
|
||||
removeWasmPath(module, this.resolvePath(path), false);
|
||||
},
|
||||
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),
|
||||
@@ -82,12 +562,21 @@ async function loadModuleFactory() {
|
||||
}
|
||||
|
||||
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 = await createModule(moduleOptions);
|
||||
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"]);
|
||||
@@ -100,9 +589,9 @@ export async function createWasmSimulator(moduleOptions = {}) {
|
||||
const handle = create();
|
||||
let callbackPtr = 0;
|
||||
|
||||
return {
|
||||
parse(program, dialect = "linuxcnc", options = {}) {
|
||||
const events = [];
|
||||
const parseText = (program, dialect = "linuxcnc", options = {}, parseOptions = {}) => {
|
||||
const events = [];
|
||||
try {
|
||||
reset(handle);
|
||||
setDialect(handle, DIALECT[dialect] ?? DIALECT.linuxcnc);
|
||||
|
||||
@@ -117,8 +606,19 @@ export async function createWasmSimulator(moduleOptions = {}) {
|
||||
...(options.config ? { config: options.config } : {}),
|
||||
...(options.configPath ? { configPath: options.configPath } : {}),
|
||||
...(options.config_path ? { config_path: options.config_path } : {}),
|
||||
...(options.ini ? { ini: options.ini } : {}),
|
||||
...(options.iniFile ? { iniFile: options.iniFile } : {}),
|
||||
...(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.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.kinematics ? { kinematics: options.kinematics } : {}),
|
||||
...(options.trt ? { trt: options.trt } : {}),
|
||||
...(options.pivotLength !== undefined ? { pivotLength: options.pivotLength } : {}),
|
||||
@@ -135,9 +635,13 @@ export async function createWasmSimulator(moduleOptions = {}) {
|
||||
});
|
||||
const configBytes = module.lengthBytesUTF8(config) + 1;
|
||||
const configPtr = module._malloc(configBytes);
|
||||
module.stringToUTF8(config, configPtr, configBytes);
|
||||
const configRc = loadConfig(handle, configPtr, configBytes - 1);
|
||||
module._free(configPtr);
|
||||
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)));
|
||||
}
|
||||
@@ -153,14 +657,59 @@ export async function createWasmSimulator(moduleOptions = {}) {
|
||||
|
||||
const bytes = module.lengthBytesUTF8(program) + 1;
|
||||
const ptr = module._malloc(bytes);
|
||||
module.stringToUTF8(program, ptr, bytes);
|
||||
const rc = parseProgram(handle, ptr, bytes - 1);
|
||||
module._free(ptr);
|
||||
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);
|
||||
await opfs.loadParameterFile(parameterPath);
|
||||
try {
|
||||
const events = parseText(program, dialect, options, { keepLinuxCncParameterFiles: true });
|
||||
await opfs.persistParameterFile(parameterPath);
|
||||
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() {
|
||||
@@ -170,5 +719,10 @@ export async function createWasmSimulator(moduleOptions = {}) {
|
||||
}
|
||||
destroy(handle);
|
||||
},
|
||||
|
||||
fs: {
|
||||
opfs,
|
||||
module,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user