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"; import { MACHINE_DEFINITIONS, machineDefinition } from "./machine-definitions.mjs"; import resources from "./simulation-resources.json" with { type: "json" }; import { parseIni } from "./ini-parser.mjs"; import { WorkspaceRuntime } from "./runtime.mjs"; import { readTrtConfiguration } from "./trt-configuration.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(resources.preloaded_hal_components); const CASE_KINEMATICS = Object.freeze(resources.kinematics); const RUNTIME_FIXTURES = Object.freeze(resources.runtime_fixtures); 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" || path === "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt_cmds.hal"; } function responseUrl(baseUrl, path) { return `${String(baseUrl ?? "").replace(/\/$/, "")}/${path}`.replace(/^\//, "/"); } async function fetchText(fetchImpl, baseUrl, path) { const response = await fetchImpl(responseUrl(baseUrl, path), { cache: "no-store" }); 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) { const registered = machineDefinition(path); return SIM_CASE_CATALOG.find((entry) => entry.path === path) ?? (registered ? { path: registered.entrypoint, coordinates: registered.coordinates, label: registered.label, status: "registered" } : 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); } } // The XYZAC Vismach executable is the authoritative geometry contract for // the Web renderer. It is not referenced as a normal INI resource, so // resolve it explicitly and carry it through the Worker configuration for // source hashing and model provenance. const machine = machineDefinition(catalog.path); if (machine) { const programPath = machine.program; if (!(programPath in files)) { const content = await fetchText(fetchImpl, baseUrl, programPath); if (content === null) missing.push(programPath); else { files[programPath] = content; fetched.push(programPath); } } const visualSource = machine.source_path; if (!(visualSource in files)) { const content = await fetchText(fetchImpl, baseUrl, visualSource); if (content === null) missing.push(visualSource); else { files[visualSource] = content; fetched.push(visualSource); } } } const sourceIni = files[catalog.path]; const fixture = RUNTIME_FIXTURES[catalog.path] ?? RUNTIME_FIXTURES[machine?.runtime_fixture]; 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), source_ini: sourceIni, 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"); const kinematics = CASE_KINEMATICS[bundle.entrypoint] ? clone(CASE_KINEMATICS[bundle.entrypoint]) : null; const trt = machineDefinition(bundle.entrypoint) ? readTrtConfiguration(bundle) : null; let homeLimits = null; if (!trt) { const parsed = parseIni({ workspace: new WorkspaceRuntime({ files: bundle.files }), entrypoint: bundle.entrypoint }); const coordinates = parsed.sections.TRAJ?.COORDINATES?.replace(/\s/g, "").toUpperCase(); if (coordinates && /^[XYZABCUVW]+$/.test(coordinates)) { const limits = {}; for (const [index, axis] of [...coordinates].entries()) { const section = parsed.sections[`JOINT_${index}`] ?? parsed.sections[`AXIS_${axis}`]; if (!section || section.MIN_LIMIT === undefined || section.MAX_LIMIT === undefined) continue; const range = [Number(section.MIN_LIMIT), Number(section.MAX_LIMIT)]; if (!range.every(Number.isFinite) || range[0] >= range[1]) throw new Error(`configuration_joint_limits:${index}`); limits[axis.toLowerCase()] = range; } if (Object.keys(limits).length) homeLimits = { coordinates, joint_count: coordinates.length, ...limits }; } } 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 ?? []), ...(homeLimits ? { home_limits: homeLimits } : {}), ...(trt ?? (kinematics ? { kinematics } : {})), }; } export { CASE_KINEMATICS, PRELOADED_HAL_COMPONENTS, RESOURCE_KEYS, RUNTIME_FIXTURES };