266 lines
14 KiB
JavaScript
266 lines
14 KiB
JavaScript
import { fnv1a64 } from "../worker/replay.mjs";
|
|
import { runtimeResourceFixture } from "./resource-resolver.mjs";
|
|
import { SIM_CASE_CATALOG, SIM_FILE_INDEX, SIM_HAL_COMPONENTS } from "./sim-case-catalog.mjs";
|
|
import { isOptionalSourceKey } from "./linuxcnc-source-semantics.mjs";
|
|
|
|
export const SIMULATION_CASE_SCHEMA = "simulation-case-bundle.v1";
|
|
const RESOURCE_KEYS = new Set([
|
|
"HAL", "HALFILE", "POSTGUI_HALFILE", "TOOLTABLE", "TOOL_TABLE", "VARFILE", "PARAMETER_FILE", "OPEN_FILE",
|
|
"PROGRAM", "PROGRAMS", "PYTHON", "PYTHONFILE", "VCP", "VCPFILE", "CLPFILE",
|
|
"PYVCP", "NGCGUI_SUBFILE", "INTRO_GRAPHIC", "SUBROUTINE_PATH", "REMAP",
|
|
]);
|
|
const MANIFEST = new Set(SIM_FILE_INDEX);
|
|
const PRELOADED_HAL_COMPONENTS = Object.freeze(["corexykins", "lineardeltakins", "motmod", "pid", "rotarydeltakins", "stepgen", "trivkins", "xyzac-trt-kins", "xyzbc-trt-kins", "tpmod", "homemod"]);
|
|
const CASE_KINEMATICS = Object.freeze({
|
|
"linuxcnc/configs/sim/axis/corexy/corexy_by_kins.ini": Object.freeze({ type: "corexy", path_mode: "kins" }),
|
|
"linuxcnc/configs/sim/axis/corexy/corexy_by_hal.ini": Object.freeze({ type: "corexy", path_mode: "hal" }),
|
|
"linuxcnc/configs/sim/axis/ldelta.ini": Object.freeze({ type: "lineardelta", path_mode: "kins" }),
|
|
"linuxcnc/configs/sim/axis/rdelta.ini": Object.freeze({ type: "rotarydelta", path_mode: "kins" }),
|
|
"linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini": Object.freeze({ type: "xyzac-trt", path_mode: "wasm", coordinates: "XYZAC", joint_count: 5, max_linear_velocity: 35, switchkins: { 0: "identity", 1: "xyzac", 2: "userk" }, offsets: { x_rot_point: 0, y_offset: 20, z_offset: 10, tool_offset: 0 } }),
|
|
});
|
|
const RUNTIME_FIXTURES = Object.freeze({
|
|
"linuxcnc/configs/sim/axis/minimal_xyz.ini": Object.freeze({
|
|
generated_hal: "linuxcnc/configs/sim/axis/minimal_xyz_cmds.hal",
|
|
replace: "HALFILE = LIB:basic_sim.tcl",
|
|
with: "HALFILE = minimal_xyz_cmds.hal",
|
|
reason: "LinuxCNC basic_sim.tcl generated HAL command fixture",
|
|
}),
|
|
"linuxcnc/configs/sim/axis/ja_tests/xz/xz.ini": Object.freeze({
|
|
generated_hal: "linuxcnc/configs/sim/axis/ja_tests/xz/xz_cmds.hal",
|
|
replace: "HALFILE = LIB:basic_sim.tcl",
|
|
with: "HALFILE = xz_cmds.hal",
|
|
reason: "LinuxCNC basic_sim.tcl generated XZ HAL command fixture",
|
|
}),
|
|
"linuxcnc/configs/sim/axis/ja_tests/xz/xz_both.ini": Object.freeze({
|
|
generated_hal: "linuxcnc/configs/sim/axis/ja_tests/xz/xz_both_cmds.hal",
|
|
replace: "HALFILE = LIB:basic_sim.tcl",
|
|
with: "HALFILE = xz_both_cmds.hal",
|
|
reason: "LinuxCNC basic_sim.tcl generated XZ BOTH HAL command fixture",
|
|
}),
|
|
"linuxcnc/configs/sim/axis/ja_tests/xz/xz_both_auto.ini": Object.freeze({
|
|
generated_hal: "linuxcnc/configs/sim/axis/ja_tests/xz/xz_both_auto_cmds.hal",
|
|
replace: "HALFILE = LIB:basic_sim.tcl",
|
|
with: "HALFILE = xz_both_auto_cmds.hal",
|
|
reason: "LinuxCNC basic_sim.tcl generated XZ BOTH AUTO HAL command fixture",
|
|
}),
|
|
"linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini": Object.freeze({
|
|
generated_hal: "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt-headless_cmds.hal",
|
|
replace: "HALFILE = LIB:basic_sim.tcl",
|
|
with: "HALFILE = xyzac-trt-headless_cmds.hal",
|
|
reason: "LinuxCNC basic_sim.tcl generated XYZAC HAL command fixture",
|
|
}),
|
|
});
|
|
|
|
function clone(value) { return structuredClone(value); }
|
|
function text(value) { return typeof value === "string" && value.trim() ? value.trim() : null; }
|
|
function stripQuotes(value) { return value.trim().replace(/^['"]|['"]$/g, ""); }
|
|
function dirname(path) { const index = path.lastIndexOf("/"); return index < 0 ? "" : path.slice(0, index); }
|
|
function normalize(path) {
|
|
const parts = [];
|
|
for (const part of path.replaceAll("\\", "/").split("/")) {
|
|
if (!part || part === ".") continue;
|
|
if (part === "..") { if (!parts.length) return null; parts.pop(); continue; }
|
|
parts.push(part);
|
|
}
|
|
return parts.join("/");
|
|
}
|
|
function tokens(value) {
|
|
return (value.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []).map(stripQuotes);
|
|
}
|
|
function candidates(source, raw, key = null, section = null) {
|
|
const value = stripQuotes(raw);
|
|
const first = tokens(value)[0] ?? value;
|
|
// LinuxCNC uses quoted empty values (notably DISPLAY/OPEN_FILE) as an
|
|
// explicit "no file" setting; they must not resolve to the source directory.
|
|
if (!first) return [];
|
|
if (String(key).toUpperCase() === "OPEN_FILE") {
|
|
if (first.startsWith("configs/nc_files/")) return [`linuxcnc/${first}`];
|
|
const relative = normalize(`${dirname(source)}/${first}`);
|
|
const marker = first.indexOf("nc_files/");
|
|
const rootCandidate = marker >= 0 ? normalize(`linuxcnc/nc_files/${first.slice(marker + "nc_files/".length)}`) : null;
|
|
const candidates = [relative, rootCandidate].filter(Boolean).filter(inManifest);
|
|
return candidates.length ? [candidates[0]] : [relative].filter(Boolean);
|
|
}
|
|
if (first.startsWith("/") || first.startsWith("~") || first.startsWith("$")) return [first];
|
|
if (String(key).toUpperCase() === "INTRO_GRAPHIC" && String(section).toUpperCase() === "DISPLAY" && first === "linuxcnc.gif") return ["linuxcnc/linuxcnc.gif"];
|
|
if (first.startsWith("LIB:")) {
|
|
const name = first.slice(4);
|
|
if (!name || name.includes("..") || name.startsWith("/")) return [];
|
|
const libraryPath = normalize(`linuxcnc/lib/hallib/${name}`);
|
|
return libraryPath ? [libraryPath] : [];
|
|
}
|
|
if (["NGCGUI_SUBFILE", "REMAP"].includes(String(key).toUpperCase())) {
|
|
const subroutine = normalize(`${dirname(source)}/remap_subs/${first}`);
|
|
if (subroutine) return [subroutine];
|
|
}
|
|
if (["HAL", "HALFILE"].includes(String(key).toUpperCase()) && !first.includes("/") && !first.startsWith("linuxcnc/")) {
|
|
const halCandidates = [normalize(`${dirname(source)}/${first}`), `linuxcnc/lib/hallib/${first}`].filter(Boolean);
|
|
const indexed = halCandidates.filter(inManifest);
|
|
return indexed.length ? [indexed[0]] : halCandidates;
|
|
}
|
|
if (first.startsWith("linuxcnc/")) return [normalize(first)].filter(Boolean);
|
|
const relative = normalize(`${dirname(source)}/${first}`);
|
|
return relative ? [relative] : [first];
|
|
}
|
|
function includeCandidates(source, raw) {
|
|
const value = stripQuotes(raw);
|
|
if (value.startsWith("linuxcnc/")) return [normalize(value)].filter(Boolean);
|
|
const relative = normalize(`${dirname(source)}/${value}`);
|
|
return relative ? [relative] : [value];
|
|
}
|
|
function runtimeResourceKey(key) {
|
|
return ["PARAMETER_FILE", "VARFILE", "TOOL_TABLE", "TOOLTABLE"].includes(String(key ?? "").toUpperCase());
|
|
}
|
|
function generatedResource(path, key) {
|
|
if (typeof path !== "string" || path.startsWith("/") || path.startsWith("~") || path.startsWith("$") || path.split("/").includes("..")) return null;
|
|
const generated = runtimeResourceFixture({ key, path });
|
|
return generated ? { ...generated, data_hash: fnv1a64(generated.content) } : null;
|
|
}
|
|
function inManifest(path) {
|
|
return MANIFEST.has(path) || path === "linuxcnc/configs/nc_files/3dtest.ngc";
|
|
}
|
|
function responseUrl(baseUrl, path) { return `${String(baseUrl ?? "").replace(/\/$/, "")}/${path}`.replace(/^\//, "/"); }
|
|
|
|
async function fetchText(fetchImpl, baseUrl, path) {
|
|
const response = await fetchImpl(responseUrl(baseUrl, path));
|
|
if (!response?.ok) return null;
|
|
return response.text();
|
|
}
|
|
|
|
function resourceReferences(source, content) {
|
|
const references = [];
|
|
let section = "";
|
|
for (const rawLine of content.split(/\r?\n/)) {
|
|
const include = rawLine.trim().match(/^#?INCLUDE\s*(?:=\s*)?(.+)$/i);
|
|
if (include) {
|
|
references.push(...includeCandidates(source, include[1]).map((path) => ({ path, key: null, source })));
|
|
continue;
|
|
}
|
|
const line = rawLine.split("#", 1)[0].trim();
|
|
const sectionMatch = line.match(/^\[([^\]]+)\]$/);
|
|
if (sectionMatch) { section = sectionMatch[1].trim(); continue; }
|
|
if (section === "RS274NGC" && /^SUBROUTINE_PATH\s*=\s*/i.test(line)) {
|
|
const directory = normalize(`${dirname(source)}/${line.split("=", 2)[1].trim()}`);
|
|
for (const candidate of SIM_FILE_INDEX.filter((path) => directory && path.startsWith(`${directory}/`) && path.endsWith(".ngc"))) references.push({ path: candidate, key: "SUBROUTINE_PATH", section, source });
|
|
continue;
|
|
}
|
|
if (section === "RS274NGC" && /^REMAP\s*=\s*/i.test(line)) {
|
|
const name = /ngc=([^\s]+)/i.exec(line)?.[1];
|
|
if (name) { const candidate = normalize(`${dirname(source)}/remap_subs/${name}.ngc`); if (candidate) references.push({ path: candidate, key: "REMAP", section, source }); }
|
|
continue;
|
|
}
|
|
const assignment = line.match(/^([^=:#]+?)\s*(?:=|:)\s*(.*)$/);
|
|
if (assignment && RESOURCE_KEYS.has(assignment[1].trim().toUpperCase())) {
|
|
const key = assignment[1].trim().toUpperCase();
|
|
const values = tokens(assignment[2]);
|
|
const resourceValues = key === "PROGRAMS" ? values : values.slice(0, 1);
|
|
for (const value of resourceValues) references.push(...candidates(source, value, key, section).map((path) => ({ path, key, section, source })));
|
|
continue;
|
|
}
|
|
const halSource = line.match(/^source\s+(.+)$/i);
|
|
if (halSource && !halSource[1].trim().startsWith("[")) references.push(...candidates(source, halSource[1], "HAL", section).map((path) => ({ path, key: "HAL", section, source })));
|
|
const halUser = line.match(/^loadusr\s+(?:-\S+\s+)*(.*)$/i);
|
|
if (halUser && source === "linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.hal") {
|
|
const program = tokens(halUser[1])[0] ?? "";
|
|
if (["./3axis-tutorial", "3axis-tutorial"].includes(program)) {
|
|
references.push(...candidates(source, program, "PYTHON", section).map((path) => ({ path, key: "PYTHON", section, source, capability: "WEB-EQUIV" })));
|
|
}
|
|
}
|
|
}
|
|
return references;
|
|
}
|
|
|
|
export function findSimulationCase(path) {
|
|
return SIM_CASE_CATALOG.find((entry) => entry.path === path) ?? null;
|
|
}
|
|
|
|
export async function loadSimulationCase({ path, fetchImpl = globalThis.fetch, baseUrl = "" } = {}) {
|
|
if (typeof fetchImpl !== "function") throw new Error("simulation_case_fetch_required");
|
|
const catalog = findSimulationCase(path);
|
|
if (!catalog) throw new Error("simulation_case_unknown");
|
|
const files = {};
|
|
const fetched = [];
|
|
const missing = [];
|
|
const optionalMissing = [];
|
|
const generatedResources = new Map();
|
|
const runtimeFixtures = [];
|
|
const queue = [catalog.path];
|
|
const queued = new Set(queue);
|
|
while (queue.length) {
|
|
const current = queue.shift();
|
|
const generated = generatedResources.get(current);
|
|
const content = generated ? generated.content : await fetchText(fetchImpl, baseUrl, current);
|
|
if (content === null) { missing.push(current); continue; }
|
|
files[current] = content;
|
|
fetched.push(current);
|
|
for (const reference of resourceReferences(current, content)) {
|
|
const candidate = reference.path;
|
|
if (!inManifest(candidate)) {
|
|
const generated = runtimeResourceKey(reference.key) ? generatedResource(candidate, reference.key) : null;
|
|
if (generated) {
|
|
if (!generatedResources.has(candidate)) {
|
|
generatedResources.set(candidate, generated);
|
|
files[candidate] = generated.content;
|
|
fetched.push(candidate);
|
|
runtimeFixtures.push({ case_path: catalog.path, resource_path: candidate, key: reference.key, kind: generated.kind, reason: generated.reason, data_hash: generated.data_hash });
|
|
}
|
|
} else if (isOptionalSourceKey(reference.key, reference.section)) {
|
|
optionalMissing.push({ path: candidate, key: reference.key, section: reference.section ?? null, source: reference.source ?? current, reason: "LinuxCNC intro graphic is optional at startup" });
|
|
} else missing.push(candidate);
|
|
continue;
|
|
}
|
|
if (queued.has(candidate)) continue;
|
|
queued.add(candidate);
|
|
queue.push(candidate);
|
|
}
|
|
}
|
|
const fixture = RUNTIME_FIXTURES[catalog.path];
|
|
if (fixture) {
|
|
if (!inManifest(fixture.generated_hal)) missing.push(fixture.generated_hal);
|
|
else if (!files[fixture.generated_hal]) {
|
|
const generated = await fetchText(fetchImpl, baseUrl, fixture.generated_hal);
|
|
if (generated === null) missing.push(fixture.generated_hal);
|
|
else { files[fixture.generated_hal] = generated; fetched.push(fixture.generated_hal); }
|
|
}
|
|
const targetPath = Object.keys(files).find((path) => files[path].includes(fixture.replace));
|
|
const original = targetPath ? files[targetPath] : null;
|
|
if (original && files[fixture.generated_hal]) {
|
|
files[targetPath] = original.replace(fixture.replace, fixture.with);
|
|
runtimeFixtures.push({ case_path: catalog.path, config_path: targetPath, generated_hal: fixture.generated_hal, reason: fixture.reason });
|
|
}
|
|
}
|
|
const unsigned = {
|
|
schema: SIMULATION_CASE_SCHEMA,
|
|
entrypoint: catalog.path,
|
|
files,
|
|
fetched: [...fetched].sort(),
|
|
missing: [...new Set(missing)].sort(),
|
|
optional_missing: [...new Map(optionalMissing.map((row) => [`${row.source}:${row.path}:${row.key}`, row])).values()].sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))),
|
|
runtime_fixtures: runtimeFixtures,
|
|
};
|
|
return {
|
|
...clone(unsigned),
|
|
catalog,
|
|
halManifest: [...new Set([...SIM_HAL_COMPONENTS, ...PRELOADED_HAL_COMPONENTS])].sort().map((component) => ({ component })),
|
|
status: missing.length ? "blocked" : "ready",
|
|
state_hash: fnv1a64(unsigned),
|
|
};
|
|
}
|
|
|
|
export function toWorkerConfiguration(bundle) {
|
|
if (!bundle || bundle.schema !== SIMULATION_CASE_SCHEMA) throw new Error("simulation_case_bundle_invalid");
|
|
return {
|
|
entrypoint: bundle.entrypoint,
|
|
files: clone(bundle.files),
|
|
halManifest: clone(bundle.halManifest ?? []),
|
|
state_hash: bundle.state_hash,
|
|
optional_missing: clone(bundle.optional_missing ?? []),
|
|
runtime_fixtures: clone(bundle.runtime_fixtures ?? []),
|
|
...(bundle.entrypoint === "linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.ini" ? { home_limits: { z: [-240, 100] } } : {}),
|
|
...(bundle.entrypoint === "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini" ? { home_limits: { joint_count: 5, kinematics_type: 2, coordinates: "XYZAC", x: [-200, 200], y: [-100, 100], z: [-120, 120], a: [-100, 50], c: [-36000, 36000] } } : {}),
|
|
...(CASE_KINEMATICS[bundle.entrypoint] ? { kinematics: clone(CASE_KINEMATICS[bundle.entrypoint]) } : {}),
|
|
};
|
|
}
|
|
|
|
export { CASE_KINEMATICS, PRELOADED_HAL_COMPONENTS, RESOURCE_KEYS, RUNTIME_FIXTURES };
|