Add INI-driven XYZAC and XYZBC parametric machine design

This commit is contained in:
2026-09-17 19:16:48 +08:00
parent 9232f29027
commit e4600b34e3
31 changed files with 2359 additions and 29 deletions

View File

@@ -3,6 +3,7 @@ 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 { readTrtGeometry } from "./trt-geometry.mjs";
export const SIMULATION_CASE_SCHEMA = "simulation-case-bundle.v1";
const RESOURCE_KEYS = new Set([
@@ -17,8 +18,8 @@ const CASE_KINEMATICS = Object.freeze({
"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, axis_linear_velocity_limit: 20, max_acceleration: 300, max_jerk: 1000000000, servo_cycle_time: 0.001, task_cycle_time: 0.01, cycle_time: 0.001, switchkins: { 0: "identity", 1: "xyzac", 2: "userk" }, offsets: { x_rot_point: 0, y_offset: 20, z_offset: 10, tool_offset: 0 } }),
"linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini": Object.freeze({ type: "xyzbc-trt", path_mode: "wasm", coordinates: "XYZBC", joint_count: 5, max_linear_velocity: 35, axis_linear_velocity_limit: 20, axis_angular_velocity_limit: 30, max_acceleration: 300, max_jerk: 1000000000, servo_cycle_time: 0.001, task_cycle_time: 0.01, cycle_time: 0.001, switchkins: { 0: "identity", 1: "xyzbc", 2: "userk" }, offsets: { x_rot_point: 0, y_rot_point: 0, z_rot_point: 0, x_offset: -20, y_offset: 0, z_offset: -15, tool_offset: 0 } }),
"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, axis_linear_velocity_limit: 20, max_acceleration: 300, max_jerk: 1000000000, servo_cycle_time: 0.001, task_cycle_time: 0.01, cycle_time: 0.001, switchkins: { 0: "identity", 1: "xyzac", 2: "userk" } }),
"linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini": Object.freeze({ type: "xyzbc-trt", path_mode: "wasm", coordinates: "XYZBC", joint_count: 5, max_linear_velocity: 35, axis_linear_velocity_limit: 20, axis_angular_velocity_limit: 30, max_acceleration: 300, max_jerk: 1000000000, servo_cycle_time: 0.001, task_cycle_time: 0.01, cycle_time: 0.001, switchkins: { 0: "identity", 1: "xyzbc", 2: "userk" } }),
});
const RUNTIME_FIXTURES = Object.freeze({
"linuxcnc/configs/sim/axis/minimal_xyz.ini": Object.freeze({
@@ -130,7 +131,7 @@ function inManifest(path) {
function responseUrl(baseUrl, path) { return `${String(baseUrl ?? "").replace(/\/$/, "")}/${path}`.replace(/^\//, "/"); }
async function fetchText(fetchImpl, baseUrl, path) {
const response = await fetchImpl(responseUrl(baseUrl, path));
const response = await fetchImpl(responseUrl(baseUrl, path), { cache: "no-store" });
if (!response?.ok) return null;
return response.text();
}
@@ -241,6 +242,7 @@ export async function loadSimulationCase({ path, fetchImpl = globalThis.fetch, b
else { files[visualSource] = content; fetched.push(visualSource); }
}
}
const sourceIni = files[catalog.path];
const fixture = RUNTIME_FIXTURES[catalog.path];
if (fixture) {
if (!inManifest(fixture.generated_hal)) missing.push(fixture.generated_hal);
@@ -267,6 +269,7 @@ export async function loadSimulationCase({ path, fetchImpl = globalThis.fetch, b
};
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",
@@ -276,6 +279,8 @@ export async function loadSimulationCase({ path, fetchImpl = globalThis.fetch, b
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;
if (machineDefinition(bundle.entrypoint)) kinematics.offsets = readTrtGeometry(bundle).offsets;
return {
entrypoint: bundle.entrypoint,
files: clone(bundle.files),
@@ -285,7 +290,7 @@ export function toWorkerConfiguration(bundle) {
runtime_fixtures: clone(bundle.runtime_fixtures ?? []),
...(bundle.entrypoint === "linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.ini" ? { home_limits: { z: [-240, 100] } } : {}),
...(machineDefinition(bundle.entrypoint) ? { home_limits: { joint_count: 5, kinematics_type: machineDefinition(bundle.entrypoint).kinematics_type, coordinates: machineDefinition(bundle.entrypoint).coordinates, ...machineDefinition(bundle.entrypoint).joint_limits } } : {}),
...(CASE_KINEMATICS[bundle.entrypoint] ? { kinematics: clone(CASE_KINEMATICS[bundle.entrypoint]) } : {}),
...(kinematics ? { kinematics } : {}),
};
}

View File

@@ -35,7 +35,11 @@ assert.equal(runtimeGenerated.status, "ready");
assert.equal(runtimeGenerated.missing.length, 0);
assert.equal(runtimeGenerated.files["linuxcnc/configs/sim/axis/sim-lathe.var"], "");
assert.ok(runtimeGenerated.runtime_fixtures.some((row) => row.kind === "parameter_file"));
const optionalGraphic = await loadSimulationCase({ path: "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini", fetchImpl });
const optionalGraphic = await loadSimulationCase({ path: "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini", fetchImpl: async (url) => {
const response = await fetchImpl(url);
if (!String(url).endsWith("xyzac-trt.ini")) return response;
return { ok: response.ok, text: async () => (await response.text()).replace(/INTRO_GRAPHIC\s*=\s*[^\r\n]+/, "INTRO_GRAPHIC = emc2.gif") };
} });
assert.equal(optionalGraphic.status, "ready");
assert.ok(optionalGraphic.optional_missing.some((row) => row.path.endsWith("/emc2.gif")));
const emptyOpenFile = await loadSimulationCase({ path: "linuxcnc/configs/sim/axis/anglejog/anglejog.ini", fetchImpl });

View File

@@ -0,0 +1,76 @@
import { parseIni } from "./ini-parser.mjs";
import { parseHalLine } from "./hal-configuration.mjs";
import { WorkspaceRuntime } from "./runtime.mjs";
import { machineDefinition } from "./machine-definitions.mjs";
import { fnv1a64 } from "../worker/replay.mjs";
export const TRT_GEOMETRY_KEYS = Object.freeze(["x_rot_point", "y_rot_point", "z_rot_point", "x_offset", "y_offset", "z_offset"]);
const numeric = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i;
function fail(code, detail) { throw Object.assign(new Error(`${code}: ${detail}`), { code }); }
export function readTrtGeometry({ entrypoint, files }) {
const machine = machineDefinition(entrypoint);
if (!machine) fail("trt_geometry_machine", entrypoint);
const parsed = parseIni({ workspace: new WorkspaceRuntime({ files }), entrypoint });
if (parsed.sections.TRAJ?.COORDINATES?.toUpperCase() !== machine.coordinates || parsed.sections.KINS?.KINEMATICS?.split(/\s+/)[0] !== `${machine.id}-kins`) fail("trt_geometry_machine_mismatch", entrypoint);
const commands = parsed.entries.filter((e) => e.section.toUpperCase() === "HAL" && e.key.toUpperCase() === "HALCMD")
.map((entry) => ({ entry, record: parseHalLine(entry.value, entry.line, parsed.sections) }));
const unsupported = machine.coordinates === "XYZAC" ? "x_offset" : "y_offset";
const offsets = { tool_offset: 0 };
const sources = {};
for (const key of TRT_GEOMETRY_KEYS) {
const pin = `${machine.id}-kins.${key.replaceAll("_", "-")}`;
const nets = commands.filter(({ record: r }) => r?.command === "net" && r.tokens.includes(pin));
const direct = commands.filter(({ record: r }) => r?.command === "setp" && r.tokens[0] === pin);
if (nets.length > 1 || nets.length && direct.length) fail("trt_geometry_ambiguous", pin);
const assignments = nets.length ? commands.filter(({ record: r }) => r?.command === "sets" && r.tokens[0] === nets[0].record.tokens[0]) : direct;
if (assignments.length > 1) fail("trt_geometry_duplicate", pin);
if (!assignments.length) {
if (key !== unsupported) fail("trt_geometry_missing", pin);
offsets[key] = 0;
continue;
}
const { entry, record } = assignments[0];
const value = record.tokens[1];
if (record.tokens.length !== 2 || !numeric.test(value) || !Number.isFinite(Number(value))) fail("trt_geometry_number", `${pin} @ ${entry.source}:${entry.line}`);
if (key === unsupported && Number(value) !== 0) fail("trt_geometry_unsupported", pin);
offsets[key] = Number(value);
// Follow INI references so editing updates the original scalar definition.
const rawValue = entry.value.trim().split(/\s+/).at(-1);
const reference = /^\[([^\]]+)\](.+)$/.exec(rawValue);
let source = entry;
if (reference) {
const definitions = parsed.entries.filter((e) => e.section === reference[1] && e.key === reference[2]);
if (definitions.length !== 1) fail("trt_geometry_reference", rawValue);
source = definitions[0];
}
sources[key] = { path: source.source, line: source.line, value: reference ? source.value : rawValue };
if (Object.entries(sources).some(([other, location]) => other !== key && location.path === source.source && location.line === source.line)) fail("trt_geometry_shared_definition", key);
}
return { coordinates: machine.coordinates, offsets, sources, unsupported, state_hash: fnv1a64({ entrypoint, offsets, sources }) };
}
export function writeTrtGeometry(configuration, values) {
const geometry = readTrtGeometry(configuration);
const files = { ...configuration.files };
for (const key of TRT_GEOMETRY_KEYS) {
const raw = String(values[key] ?? geometry.offsets[key]).trim();
if (!numeric.test(raw) || !Number.isFinite(Number(raw))) fail("trt_geometry_number", key);
if (key === geometry.unsupported && Number(raw) !== 0) fail("trt_geometry_unsupported", key);
const source = geometry.sources[key];
if (!source) continue;
const lines = files[source.path].split("\n");
const line = lines[source.line - 1];
const comment = line.search(/[;#]/);
const end = comment < 0 ? line.length : comment;
const body = line.slice(0, end);
const index = body.lastIndexOf(source.value);
if (index < 0) fail("trt_geometry_source_changed", key);
lines[source.line - 1] = body.slice(0, index) + String(Number(raw)) + body.slice(index + source.value.length) + line.slice(end);
files[source.path] = lines.join("\n");
}
const result = { ...configuration, files };
if ("state_hash" in result) result.state_hash = fnv1a64({ entrypoint: configuration.entrypoint, files });
readTrtGeometry(result);
return result;
}