Advance simulation parity and 3-axis tutorial coverage

This commit is contained in:
2026-08-23 18:00:56 +08:00
parent 4a11d9692b
commit 938654b19d
107 changed files with 13537 additions and 3565 deletions

View File

@@ -11,19 +11,55 @@ const KEY_MAP = Object.freeze({
});
const SHARED_SIMULATION_ASSETS = Object.freeze({ "linuxcnc.gif": "linuxcnc/linuxcnc.gif" });
export const RUNTIME_RESOURCE_KEYS = Object.freeze(["PARAMETER_FILE", "VARFILE", "TOOL_TABLE", "TOOLTABLE"]);
export const OPTIONAL_RESOURCE_KEYS = Object.freeze(["INTRO_GRAPHIC"]);
function clone(value) { return structuredClone(value); }
function blocked(code, source, line, extra = {}) { return { status: "blocked", code, source, line, ...extra }; }
export function runtimeResourceFixture({ key = null, type = null, path = null } = {}) {
const normalizedKey = String(key ?? "").toUpperCase();
const normalizedType = String(type ?? "").toLowerCase();
const extension = typeof path === "string" ? path.toLowerCase().split(".").pop() : null;
if ((["PARAMETER_FILE", "VARFILE"].includes(normalizedKey) || normalizedType === "parameter_file") && extension === "var") {
return { kind: "parameter_file", content: "", reason: "LinuxCNC restores a missing parameter file as zeroed defaults and creates it on exit" };
}
if ((["TOOL_TABLE", "TOOLTABLE"].includes(normalizedKey) || normalizedType === "tool_table") && extension === "tbl") {
return { kind: "tool_table", content: "", reason: "LinuxCNC keeps an empty tool table when the configured table cannot be opened" };
}
return null;
}
function optionalIntroGraphic({ key = null, section = null, type = null } = {}) {
return String(key ?? "").toUpperCase() === "INTRO_GRAPHIC"
&& String(type ?? "").toLowerCase() === "image"
&& String(section ?? "").toUpperCase() === "DISPLAY";
}
function resolveWorkspaceRelative(basePath, value) {
const parts = basePath.split("/");
parts.pop();
for (const part of value.replaceAll("\\", "/").split("/")) {
if (!part || part === ".") continue;
if (part === "..") {
if (!parts.length) throw new Error("resource_path_escape");
parts.pop();
continue;
}
parts.push(part);
}
return normalizeWorkspacePath(`/workspace/${parts.join("/")}`);
}
function pathOf(base, raw) {
if (typeof raw !== "string" || !raw.trim()) throw new Error("resource_path_empty");
const value = raw.trim().replace(/^['"]|['"]$/g, "");
if (value.startsWith("/") || value.startsWith("~") || value.startsWith("$") || value.includes("\\")) throw new Error("resource_host_path");
const baseParts = base.split("/"); baseParts.pop();
return normalizeWorkspacePath(`/workspace/${[...baseParts, value].join("/")}`);
return resolveWorkspaceRelative(base, value);
}
function pathCandidates(base, raw, key = null, section = null) {
if (typeof raw !== "string") throw new Error("resource_path_empty");
const value = raw.trim().replace(/^['"]|['"]$/g, "");
const value = (raw.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g)?.[0] ?? raw).trim().replace(/^['"]|['"]$/g, "");
const normalizedKey = typeof key === "string" ? key.toUpperCase() : null;
const normalizedSection = typeof section === "string" ? section.toUpperCase() : null;
// LinuxCNC's generator emits this exact DISPLAY entry. Keep the shared asset
@@ -33,14 +69,15 @@ function pathCandidates(base, raw, key = null, section = null) {
if (value.startsWith("LIB:")) {
const libraryName = value.slice("LIB:".length);
if (!libraryName || libraryName.startsWith("/") || libraryName.includes("..") || libraryName.includes("\\")) throw new Error("resource_path_invalid");
return ["linuxcnc/lib/hallib", "linuxcnc/lib"].map((root) => normalizeWorkspacePath(`/workspace/${root}/${libraryName}`));
return [normalizeWorkspacePath(`/workspace/linuxcnc/lib/hallib/${libraryName}`)];
}
if (["HAL", "HALFILE"].includes(normalizedKey) && !value.includes("/") && !value.startsWith("linuxcnc/")) {
return [pathOf(base, raw), `linuxcnc/lib/hallib/${value}`, `linuxcnc/lib/${value}`].map((candidate) => normalizeWorkspacePath(`/workspace/${candidate}`));
return [pathOf(base, value), `linuxcnc/lib/hallib/${value}`].map((candidate) => normalizeWorkspacePath(`/workspace/${candidate}`));
}
return [pathOf(base, raw)];
return [pathOf(base, value)];
}
function inferType(path) {
if (typeof path !== "string" || !path) return null;
const ext = path.toLowerCase().split(".").pop();
return ({ hal: "hal", tbl: "tool_table", var: "parameter_file", ngc: "program", gcode: "program", py: "python", xml: "vcp", clp: "classicladder" })[ext] ?? null;
}
@@ -49,13 +86,15 @@ export function resolveResources({ workspace, entrypoint, ini = null, explicit =
if (!workspace || typeof workspace.read !== "function") throw new Error("resource_workspace_required");
const parsed = ini ?? parseIni({ workspace, entrypoint });
const resources = [];
const generatedFiles = new Map();
const seen = new Map();
const add = (type, raw, source, line, key, section = null) => {
let candidates;
try { candidates = pathCandidates(source, raw, key, section); } catch (cause) { resources.push(blocked(cause.message === "resource_host_path" ? "resource_host_path" : "resource_path_invalid", source, line, { type, key, raw })); return; }
const existing = candidates.filter((candidate) => {
let existing = candidates.filter((candidate) => {
try { workspace.read(`/workspace/${candidate}`); return true; } catch { return false; }
});
if (existing.length > 1 && ["HAL", "HALFILE"].includes(String(key ?? "").toUpperCase())) existing = [existing[0]];
if (existing.length > 1) {
resources.push(blocked("resource_ambiguous", source, line, { type, key, raw, attempted_paths: candidates }));
return;
@@ -66,6 +105,41 @@ export function resolveResources({ workspace, entrypoint, ini = null, explicit =
const id = `${actualType}:${path}`;
if (seen.has(id)) { resources.push(blocked("resource_duplicate", source, line, { type: actualType, path, first: seen.get(id) })); return; }
seen.set(id, { source, line });
if (existing.length === 0) {
const generated = runtimeResourceFixture({ key, type: actualType, path });
if (generated) {
generatedFiles.set(path, generated.content);
resources.push({
status: "resolved",
resource_id: id,
type: actualType,
path,
source,
line,
key: key ?? null,
bytes: new TextEncoder().encode(generated.content).byteLength,
data_hash: fnv1a64(generated.content),
runtime_generated: true,
runtime_kind: generated.kind,
runtime_reason: generated.reason,
});
return;
}
if (optionalIntroGraphic({ key, section, type: actualType })) {
resources.push({
status: "optional",
code: "resource_optional_missing",
resource_id: id,
type: actualType,
path,
source,
line,
key: key ?? null,
reason: "LinuxCNC ignores a missing DISPLAY.INTRO_GRAPHIC during startup",
});
return;
}
}
try {
const data = workspace.read(`/workspace/${path}`);
const record = { status: "resolved", resource_id: id, type: actualType, path, source, line, key: key ?? null, bytes: new TextEncoder().encode(data).byteLength, data_hash: fnv1a64(data), capability: actualType === "python" ? "PY-WASM" : actualType === "vcp" ? "WEB-EQUIV" : "SIM-CORE" };
@@ -74,11 +148,26 @@ export function resolveResources({ workspace, entrypoint, ini = null, explicit =
};
for (const entry of parsed.entries) {
const type = KEY_MAP[entry.key.toUpperCase()];
if (type) for (const raw of entry.value.split(/\s*,\s*|\s+/).filter(Boolean)) add(type, raw, entry.source, entry.line, entry.key, entry.section);
if (!type) continue;
const values = entry.key.toUpperCase() === "PROGRAMS"
? entry.value.split(/\s*,\s*|\s+/).filter(Boolean)
: [entry.value];
for (const raw of values) add(type, raw, entry.source, entry.line, entry.key, entry.section);
}
for (const item of explicit) add(item.type ?? inferType(item.path), item.path, item.source ?? parsed.entrypoint, item.line ?? 0, item.key ?? null);
const sorted = resources.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
const blockedRows = sorted.filter((row) => row.status !== "resolved");
const result = { schema: RESOURCE_GRAPH_SCHEMA, entrypoint: parsed.entrypoint, ini_hash: parsed.state_hash, resources: sorted, resolved_count: sorted.length - blockedRows.length, blocked_count: blockedRows.length, status: blockedRows.length ? "blocked" : "ready" };
const blockedRows = sorted.filter((row) => row.status === "blocked");
const optionalRows = sorted.filter((row) => row.status === "optional");
const result = {
schema: RESOURCE_GRAPH_SCHEMA,
entrypoint: parsed.entrypoint,
ini_hash: parsed.state_hash,
resources: sorted,
generated_files: Object.fromEntries([...generatedFiles.entries()].sort(([left], [right]) => left.localeCompare(right))),
resolved_count: sorted.filter((row) => row.status === "resolved").length,
optional_count: optionalRows.length,
blocked_count: blockedRows.length,
status: blockedRows.length ? "blocked" : "ready",
};
return { ...result, state_hash: fnv1a64(result) };
}