import { fnv1a64 } from "../worker/replay.mjs"; import { SIM_CASE_CATALOG, SIM_FILE_INDEX, SIM_HAL_COMPONENTS } from "./sim-case-catalog.mjs"; export const SIMULATION_CASE_SCHEMA = "simulation-case-bundle.v1"; const RESOURCE_KEYS = new Set([ "HAL", "HALFILE", "TOOLTABLE", "TOOL_TABLE", "VARFILE", "PARAMETER_FILE", "PROGRAM", "PROGRAMS", "PYTHON", "PYTHONFILE", "VCP", "VCPFILE", "CLPFILE", "CLASSICLADDER", "INTRO_GRAPHIC", ]); const MANIFEST = new Set(SIM_FILE_INDEX); const PRELOADED_HAL_COMPONENTS = Object.freeze(["corexykins", "lineardeltakins", "motmod", "pid", "rotarydeltakins", "stepgen", "trivkins", "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" }), }); 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", }), }); 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; 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 libraryCandidates = [`linuxcnc/lib/hallib/${name}`, `linuxcnc/lib/${name}`].map(normalize).filter(Boolean); const indexed = libraryCandidates.filter(inManifest); return indexed.length ? indexed : libraryCandidates; } if (["HAL", "HALFILE"].includes(String(key).toUpperCase()) && !first.includes("/") && !first.startsWith("linuxcnc/")) { const halCandidates = [normalize(`${dirname(source)}/${first}`), `linuxcnc/lib/hallib/${first}`, `linuxcnc/lib/${first}`].filter(Boolean); const indexed = halCandidates.filter(inManifest); return indexed.length ? indexed : halCandidates; } if (first.startsWith("linuxcnc/")) return [normalize(first)].filter(Boolean); return [normalize(`${dirname(source)}/${first}`)].filter(Boolean); } function includeCandidates(source, raw) { const value = stripQuotes(raw); if (value.startsWith("linuxcnc/")) return [normalize(value)].filter(Boolean); return [normalize(`${dirname(source)}/${value}`)].filter(Boolean); } function inManifest(path) { return MANIFEST.has(path); } 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])); continue; } const line = rawLine.split("#", 1)[0].trim(); const sectionMatch = line.match(/^\[([^\]]+)\]$/); if (sectionMatch) { section = sectionMatch[1].trim(); 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)); continue; } const halSource = line.match(/^source\s+(.+)$/i); if (halSource && !halSource[1].trim().startsWith("[")) references.push(...candidates(source, halSource[1], "HAL", section)); } 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 runtimeFixtures = []; const queue = [catalog.path]; const queued = new Set(queue); while (queue.length) { const current = queue.shift(); const content = await fetchText(fetchImpl, baseUrl, current); if (content === null) { missing.push(current); continue; } files[current] = content; fetched.push(current); for (const candidate of resourceReferences(current, content)) { if (!inManifest(candidate)) { 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(), 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, runtime_fixtures: clone(bundle.runtime_fixtures ?? []), ...(CASE_KINEMATICS[bundle.entrypoint] ? { kinematics: clone(CASE_KINEMATICS[bundle.entrypoint]) } : {}), }; } export { CASE_KINEMATICS, PRELOADED_HAL_COMPONENTS, RESOURCE_KEYS, RUNTIME_FIXTURES };