From 02dcc573b00c9332ea8bcc25a5e0f5f082371d85 Mon Sep 17 00:00:00 2001 From: wangdequan Date: Fri, 11 Sep 2026 06:25:13 +0800 Subject: [PATCH] Support XYZAC and XYZBC in shared web machine UI --- docs/linuxcnc-web-wasm-dual-canvas-design.md | 10 +-- scripts/test_xyzbc_panel_browser.mjs | 38 +++++++++ web/app/axis-contract.mjs | 2 +- web/react/src/App.tsx | 82 ++++++++++--------- web/react/src/XyzacMachineView.tsx | 17 ++-- web/react/src/XyzacToolPreview.tsx | 13 ++- web/react/src/XyzacTrtPanel.tsx | 39 +++++---- web/react/src/legacy.d.ts | 4 + web/react/src/protocol.ts | 12 +-- web/react/src/store.ts | 16 ++-- .../src/test_xyzac_motion_projection.mjs | 5 ++ web/react/src/xyzac-machine-model.ts | 43 +++++++--- web/react/src/xyzac-panel-contract.ts | 21 +++-- web/react/src/xyzac-visual-adapter.ts | 4 +- web/react/vite.config.ts | 1 + web/worker/sim-session.mjs | 63 ++++++++------ web/worker/wasm-linuxcnc-tp.mjs | 8 +- web/worker/wasm-xyzac-kinematics.mjs | 41 ++++++---- web/workspace/machine-definitions.mjs | 28 +++++++ web/workspace/sim-case-loader.mjs | 17 +++- 20 files changed, 318 insertions(+), 146 deletions(-) create mode 100644 scripts/test_xyzbc_panel_browser.mjs create mode 100644 web/workspace/machine-definitions.mjs diff --git a/docs/linuxcnc-web-wasm-dual-canvas-design.md b/docs/linuxcnc-web-wasm-dual-canvas-design.md index 6a14345..03efc15 100644 --- a/docs/linuxcnc-web-wasm-dual-canvas-design.md +++ b/docs/linuxcnc-web-wasm-dual-canvas-design.md @@ -10,7 +10,7 @@ 当前 AXIS 风格面板的 Preview 区域需要同时表达两种不同的信息: -1. 刀具在工件坐标中的 canonical 程序轨迹,以及执行过程中已经走过和正在执行的实时刀具轨迹。 +1. 刀具在工件坐标中的 canonical 程序轨迹,以及执行过程中已经走过和正在执行的实时刀具轨迹;XYZAC 使用 A 轴,XYZBC 使用 B 轴。 2. 由五个关节(table-x、saddle-y、spindle-z、tilt-a、rotate-c)驱动的完整机床三维模型运动。 本方案将 Preview 明确拆为两个相互独立的 ``: @@ -93,9 +93,9 @@ Worker 每个 logical tick 依次执行 interpreter、TP、motion、kinematics ### 5.2 Canvas B:机床三维模型运动 -* 场景节点必须按 `XYZAC_MACHINE_GROUPS` 建立:`xyzac-machine`、`table-x`、`saddle-y`、`spindle-z`、`tilt-a`、`rotate-c`、`tool-offset`。 +* 场景节点按当前机床定义建立:`xyzac-machine`/`xyzbc-machine`、`table-x`、`saddle-y`、`spindle-z`、`tilt-a` 或 `tilt-b`、`rotate-c`、`tool-offset`。 * 模型描述来自 `visual.machine_model`:`id`、`source_path`、`source_hash`、`components`、`joints`、`coordinate_system`。模型缺失或哈希不匹配时显示结构化错误并保留轴姿态 HUD。 -* 关节变换顺序固定为 table-x -> saddle-y -> spindle-z -> tilt-a -> rotate-c -> tool-offset;方向和单位从模型 manifest 定义,禁止组件自行猜测。 +* 关节变换顺序固定为 table-x -> saddle-y -> spindle-z -> tilt-a/tilt-b -> rotate-c -> tool-offset;方向和单位从模型 manifest 定义,禁止组件自行猜测。 * 使用 `visual.joints` 驱动机械层级,使用 `visual.pose` 驱动刀尖/姿态 HUD;二者必须在同一 tick 通过一致性检查。 * 默认不绘制 Canvas A 的完整路径;`showPath` 仅作为明确的诊断层,且数据仍来自同一 snapshot。 @@ -121,7 +121,7 @@ type DualCanvasSnapshot = { visual: { schema: "axis-visual.v1"; source: "wasm_canonical_motion"; - pose: Record<"x"|"y"|"z"|"a"|"c", number>; + pose: Record<"x"|"y"|"z"|"a"|"b"|"c", number>; joints: number[]; // exactly 5: mm, mm, mm, deg, deg toolpath?: ToolpathSegment[]; // omitted in motion_delta motion_trace?: MotionSample[]; @@ -143,7 +143,7 @@ type DualCanvasSnapshot = { }; ``` -`ToolpathSegment` 的数值单位是 mm/deg,坐标顺序为 XYZAC;`MotionSample` 必须含 `tick`、五关节、pose、活动段和进度。`machine_model.source` 必须为 `linuxcnc-wasm-worker`,模型 `source_hash` 用于恢复和 native 对账。`projectXyzacVisual()` 负责默认值、有限值和长度校验;校验失败时 `authoritative=false`。 + `ToolpathSegment` 的数值单位是 mm/deg,坐标顺序为当前机床定义的 XYZAC 或 XYZBC;`MotionSample` 必须含 `tick`、五关节、pose、活动段和进度。`machine_model.source` 必须为 `linuxcnc-wasm-worker`,模型 `source_hash` 用于恢复和 native 对账。`projectXyzacVisual()` 负责默认值、有限值和长度校验;校验失败时 `authoritative=false`。 ### 6.2 消息时序与一致性 diff --git a/scripts/test_xyzbc_panel_browser.mjs b/scripts/test_xyzbc_panel_browser.mjs new file mode 100644 index 0000000..73b54a8 --- /dev/null +++ b/scripts/test_xyzbc_panel_browser.mjs @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import { pathToFileURL } from "node:url"; + +const modulePath = process.env.PLAYWRIGHT_MODULE; +if (!modulePath) { console.log(JSON.stringify({ schema: "xyzbc-panel-browser.v1", status: "blocked", code: "playwright_unavailable" })); process.exit(0); } +const { chromium } = await import(pathToFileURL(modulePath).href); +const browser = await chromium.launch({ headless: true, executablePath: process.env.BROWSER_EXECUTABLE_PATH ?? "/usr/bin/google-chrome", args: ["--no-sandbox", "--use-angle=swiftshader", "--enable-unsafe-swiftshader"] }); +const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); +const errors = []; +page.on("pageerror", (error) => errors.push(error.message)); +const control = (id) => page.locator(`[data-axis-control="${id}"]`); +try { + await page.goto(process.env.XYZAC_PANEL_URL ?? "http://127.0.0.1:5174/"); + await page.waitForFunction(() => document.querySelector("main")?.dataset.axisProgramLoading === "false"); + await control("toolbar-power").click(); + await page.waitForTimeout(300); + await control("machine-type-select").selectOption({ label: "XYZBC TRT" }); + assert.equal(await control("machine-type-select").inputValue(), "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini"); + await page.waitForFunction(() => document.querySelector("main")?.dataset.axisProgramPath?.includes("xyzbc_switchkins.ngc") && document.querySelector("main")?.dataset.axisProgramLoading === "false", null, { timeout: 45000 }); + const result = await page.evaluate(() => ({ + machine: document.querySelector(".xyzac-manual-summary")?.getAttribute("aria-label"), + axes: [...document.querySelectorAll(".manual-axis-block .axis-radio span")].map((node) => node.textContent), + summary: document.querySelector(".xyzac-manual-summary")?.textContent, + toolPreview: document.querySelector(".xyzac-tool-preview")?.getAttribute("aria-label"), + model: document.querySelector(".xyzac-machine-view")?.dataset.xyzacModelId, + coordinates: document.querySelector(".xyzac-machine-view")?.dataset.xyzacModelSourcePath, + canvases: document.querySelectorAll(".axis-preview-split canvas[data-engine]").length, + })); + assert.equal(result.machine, "XYZBC authoritative position"); + assert.deepEqual(result.axes, ["X", "Y", "Z", "B", "C"]); + assert.match(result.summary, /XYZBC authority/); + assert.equal(result.toolPreview, "XYZBC authoritative tool preview"); + assert.equal(result.model, "xyzbc-trt"); + assert.equal(result.coordinates, "linuxcnc/src/hal/user_comps/vismach/xyzbc-trt-gui.py"); + assert.equal(result.canvases, 2); + assert.deepEqual(errors, []); + console.log(JSON.stringify({ schema: "xyzbc-panel-browser.v1", status: "verified", program: "xyzbc_switchkins.ngc", machine: result.model, axes: result.axes, canvases: result.canvases, browser_errors: errors })); +} finally { await browser.close(); } diff --git a/web/app/axis-contract.mjs b/web/app/axis-contract.mjs index cc7e029..1bf6675 100644 --- a/web/app/axis-contract.mjs +++ b/web/app/axis-contract.mjs @@ -123,7 +123,7 @@ export function xyzacGate(action, input = {}) { const offsets = xyzac.finite_offsets !== false; const pinVars = xyzac.hal_pin_vars === true; const net = xyzac.switchkins_net === true; - const coordinates = xyzac.coordinates === "XYZAC" && xyzac.joint_count === 5; + const coordinates = ["XYZAC", "XYZBC"].includes(String(xyzac.coordinates ?? "").toUpperCase()) && xyzac.joint_count === 5; if (action === "vismachClear") return Object.freeze({ action, enabled: true, reason: "axis_visual_only", visual_only: true, taskState: state.taskState, interpState: state.interpState }); const common = idle && pinVars && net && offsets; const commonReason = state.taskState === "ESTOP" ? "xyzac_estop_active" : state.taskState !== "ON" ? "xyzac_machine_off" : !idle ? "xyzac_interp_not_idle" : !pinVars ? "xyzac_hal_pin_vars_missing" : !net ? "xyzac_switchkins_net_missing" : !offsets ? "xyzac_offsets_nonfinite" : "xyzac_switchkins_common_gate"; diff --git a/web/react/src/App.tsx b/web/react/src/App.tsx index 90cf670..d955c9b 100644 --- a/web/react/src/App.tsx +++ b/web/react/src/App.tsx @@ -11,10 +11,10 @@ import { MachineStatus } from "./MachineStatus"; import { AxisOperationController } from "../../app/axis-operation-controller.mjs"; import { XyzacDiagnostics, XyzacLiveParameters, XyzacProgramPane, XyzacTrtPanel } from "./XyzacTrtPanel"; import { XYZAC_CASE, projectXyzacPanel, type XyzacOperationTrace } from "./xyzac-panel-contract"; +import { DEFAULT_MACHINE, MACHINE_DEFINITIONS, machineDefinition } from "../../workspace/machine-definitions.mjs"; -export const SELECTED_XYZAC_CASE = XYZAC_CASE; -const DEFAULT_CASE = SELECTED_XYZAC_CASE; -const PROGRAM_PATH = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins.ngc"; +export const SELECTED_XYZAC_CASE = DEFAULT_MACHINE.entrypoint; +const DEFAULT_CASE = DEFAULT_MACHINE.entrypoint; const normalizeProgramSource = (source: string) => source.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); // Button-to-command sequencing is defined in // docs/linuxcnc-axis-web-control-panel-source-logic.md; React only projects @@ -62,7 +62,7 @@ function MachineSequence({ wasmReady, allHomed, canHome, homeReason, onHomeAll, ; } -function ManualControl({ action, canJog, canHome, canSpindle, canCoolant, canTouchOff, canToolTouchOff, selectedAxis, setSelectedAxis, spindleDirection, spindleBrake, coolant, onSpindle, onCoolant, onHomeAxis, onTouchOff, onToolTouchOff, active, metric, reasons, devices }: { action: Action; canJog: boolean; canHome: boolean; canSpindle: boolean; canCoolant: boolean; canTouchOff: boolean; canToolTouchOff: boolean; selectedAxis: string; setSelectedAxis: (axis: string) => void; spindleDirection: number; spindleBrake: boolean; coolant: { mist: boolean; flood: boolean }; onSpindle: (direction: number) => void; onCoolant: (key: "mist" | "flood") => void; onHomeAxis: () => void; onTouchOff: () => void; onToolTouchOff: () => void; active: boolean; metric: boolean; reasons: Record; devices: Record }) { +function ManualControl({ action, axes = DEFAULT_MACHINE.axes, canJog, canHome, canSpindle, canCoolant, canTouchOff, canToolTouchOff, selectedAxis, setSelectedAxis, spindleDirection, spindleBrake, coolant, onSpindle, onCoolant, onHomeAxis, onTouchOff, onToolTouchOff, active, metric, reasons, devices }: { action: Action; axes?: readonly string[]; canJog: boolean; canHome: boolean; canSpindle: boolean; canCoolant: boolean; canTouchOff: boolean; canToolTouchOff: boolean; selectedAxis: string; setSelectedAxis: (axis: string) => void; spindleDirection: number; spindleBrake: boolean; coolant: { mist: boolean; flood: boolean }; onSpindle: (direction: number) => void; onCoolant: (key: "mist" | "flood") => void; onHomeAxis: () => void; onTouchOff: () => void; onToolTouchOff: () => void; active: boolean; metric: boolean; reasons: Record; devices: Record }) { const [increment, setIncrement] = useState("Continuous"); const lastIncrement = useRef(1); useEffect(() => { @@ -82,13 +82,14 @@ function ManualControl({ action, canJog, canHome, canSpindle, canCoolant, canTou window.addEventListener("keydown", keydown); return () => window.removeEventListener("keydown", keydown); }, [active, increment]); + const displayedAxes = axes; const jogging = useRef(false); const spindleAdjusting = useRef(false); const canSpindleAdjust = canSpindle && spindleDirection !== 0; const jog = (direction: number) => action("JOG_START", { axis: selectedAxis, direction, increment: increment === "Continuous" ? null : Number(increment), continuous: increment === "Continuous", units: metric ? "mm" : "inch" }); const jogRelease = increment === "Continuous" ? () => action("JOG_STOP") : undefined; return
-

Axis:

{["x", "y", "z", "a", "c"].map((axis) => )}
+

Axis:

{displayedAxes.map((axis) => )}

Jog:

jog(-1)} onRelease={jogRelease} label={`Jog ${selectedAxis.toUpperCase()} negative`}>- jog(1)} onRelease={jogRelease} label={`Jog ${selectedAxis.toUpperCase()} positive`}>+ @@ -133,14 +134,15 @@ function MdiControl({ action, ready, canSubmit, reason }: { action: Action; read } function DroView({ axes, state, coordType, displayType, metric, setCoordType, setDisplayType, setMetric }: { axes: number[]; state: typeof INITIAL_STATE; coordType: "machine" | "relative"; displayType: "actual" | "commanded"; metric: boolean; setCoordType: (value: "machine" | "relative") => void; setDisplayType: (value: "actual" | "commanded") => void; setMetric: (value: boolean) => void }) { - const labels = ["X", "Y", "Z", "A", "C"]; + const machine = machineDefinition((state.snapshot?.kinematics as { coordinates?: string } | undefined)?.coordinates) ?? DEFAULT_MACHINE; + const labels = machine.axes.map((axis) => axis.toUpperCase()); const scale = metric ? 1 : 1 / 25.4; const format = (value: number, index: number) => Number.isFinite(value) ? (value * (index < 3 ? scale : 1)).toFixed(4) : "-"; const sourceReady = state.snapshot?.motion_authority === "wasm" && Boolean(state.snapshot?.task_authority); const pose = (state.snapshot?.visual?.pose ?? state.snapshot?.authoritative?.kinematics?.pose) as Record | undefined; - const commanded = [pose?.x, pose?.y, pose?.z, pose?.a, pose?.c].map((value, index) => sourceReady && Number.isFinite(value) ? Number(value) : sourceReady ? Number(axes[index] ?? Number.NaN) : Number.NaN); + const commanded = machine.axes.map((axis, index) => { const value = pose?.[axis]; return sourceReady && Number.isFinite(value) ? Number(value) : sourceReady ? Number(axes[index] ?? Number.NaN) : Number.NaN; }); const actualPose = (state.snapshot?.visual?.pose ?? state.snapshot?.kinematics?.pose ?? {}) as Record; - const actualAxes = [actualPose.x, actualPose.y, actualPose.z, actualPose.a, actualPose.c].map((value, index) => sourceReady && Number.isFinite(value) ? Number(value) : sourceReady ? Number(axes[index] ?? Number.NaN) : Number.NaN); + const actualAxes = machine.axes.map((axis, index) => { const value = actualPose[axis]; return sourceReady && Number.isFinite(value) ? Number(value) : sourceReady ? Number(axes[index] ?? Number.NaN) : Number.NaN; }); const relative = sourceReady ? state.snapshot?.coordinates?.relative ?? [Number.NaN, Number.NaN, Number.NaN, Number.NaN, Number.NaN] : [Number.NaN, Number.NaN, Number.NaN, Number.NaN, Number.NaN]; return
Position
DisplayedActualRelative
{labels.map((label, index) => { const actual = actualAxes[index]; const target = Number(commanded[index] ?? actual); const relativeValue = Number(relative[index] ?? actual); const displayed = displayType === "commanded" ? target : actual; return
{label}{format(coordType === "relative" ? relativeValue : displayed, index)}{format(actual, index)}{format(relativeValue, index)}
; })}
Units: {metric ? "mm" : "inch"} / deg{coordType} / {displayType}Tick: {state.tick}Hash: {state.snapshot?.state_hash?.slice(0, 12) || "-"}
; } @@ -223,7 +225,7 @@ export function App() { const [caseStatus, setCaseStatus] = useState("loading configured program..."); const [commandStatus, setCommandStatus] = useState("Idle"); const [programText, setProgramTextState] = useState(""); - const [programPath, setProgramPath] = useState(PROGRAM_PATH); + const [programPath, setProgramPath] = useState(DEFAULT_MACHINE.program); const [programLoading, setProgramLoading] = useState(true); const [selectedAxis, setSelectedAxis] = useState("x"); const [selectedHomeJoint, setSelectedHomeJoint] = useState(0); @@ -255,7 +257,11 @@ export function App() { const lastSourceState = useRef({ on: false, idle: true }); const modeRestorePending = useRef(false); const uiCacheSaveTimer = useRef | null>(null); - const UI_CACHE_KEY = "xyzac-trt"; + const selectedMachine = machineDefinition(casePath) ?? DEFAULT_MACHINE; + const UI_CACHE_KEY = selectedMachine.id; + useEffect(() => { + setSelectedAxis(selectedMachine.axes[3] ?? "a"); + }, [selectedMachine.id]); const [previewProgress, setPreviewProgress] = useState<{ percent: number; phase: "reading" | "wasm" | "ready" | "error"; detail: string } | null>(null); const previewProgressTimer = useRef | null>(null); useEffect(() => { @@ -285,7 +291,7 @@ export function App() { if (cached) { selectedModeTab.current = cached.leftTab === "mdi" ? "MDI" : "MANUAL"; if (cached.casePath === DEFAULT_CASE) setCasePath(DEFAULT_CASE); - if (["x", "y", "z", "a", "c"].includes(String(cached.selectedAxis))) setSelectedAxis(String(cached.selectedAxis)); + if (["x", "y", "z", "a", "b", "c"].includes(String(cached.selectedAxis)) && selectedMachine.axes.includes(String(cached.selectedAxis))) setSelectedAxis(String(cached.selectedAxis)); if (["manual", "mdi", "diagnostics"].includes(String(cached.leftTab))) setLeftTab(cached.leftTab as "manual" | "mdi" | "diagnostics"); if (["preview", "dro"].includes(String(cached.rightTab))) setRightTab(cached.rightTab as "preview" | "dro"); if (["z", "z2", "x", "y", "y2", "p"].includes(String(cached.view))) setView(cached.view as "z" | "z2" | "x" | "y" | "y2" | "p"); @@ -306,17 +312,17 @@ export function App() { } const bundle = await loadSimulationCase({ path: DEFAULT_CASE, baseUrl: "." }); if (bundle.status !== "ready") throw new Error(`xyzac_case_blocked:${bundle.missing.join(",")}`); - const source = bundle.files?.[PROGRAM_PATH]; + const source = bundle.files?.[DEFAULT_MACHINE.program]; if (typeof source !== "string" || !source.trim()) throw new Error("xyzac_demo_program_missing"); const normalizedSource = normalizeProgramSource(source); - setProgramTextState(normalizedSource); setProgramPath(PROGRAM_PATH); + setProgramTextState(normalizedSource); setProgramPath(DEFAULT_MACHINE.program); const configuration = toWorkerConfiguration(bundle); await operationController.execute("LOAD_WORKSPACE", { root_path: "/workspace", configuration }, { state: {}, gate: false }); - await operationController.execute("PROGRAM_LOAD", { source: normalizedSource, path: PROGRAM_PATH }, { state: {}, gate: false }); + await operationController.execute("PROGRAM_LOAD", { source: normalizedSource, path: DEFAULT_MACHINE.program }, { state: {}, gate: false }); uiCacheHydrated.current = true; setOperationTrace(operationController.exportTrace()); setCommandStatus("PROGRAM_LOAD acknowledged"); - setCaseStatus(`${DEFAULT_CASE} + ${PROGRAM_PATH} loaded`); + setCaseStatus(`${DEFAULT_CASE} + ${DEFAULT_MACHINE.program} loaded`); }).catch((error: Error) => { setOperationTrace(operationController.exportTrace()); setCommandStatus(`Error: ${error.message}`); setCaseStatus(`blocked: ${error.message}`); }).finally(() => setProgramLoading(false)); return () => { unsubscribe(); if (runTimer.current) clearInterval(runTimer.current); if (homeTimer.current) clearInterval(homeTimer.current); if (jogTimer.current) clearInterval(jogTimer.current); if (previewProgressTimer.current) clearInterval(previewProgressTimer.current); if (feedOverrideTimer.current) clearTimeout(feedOverrideTimer.current); if (uiCacheSaveTimer.current) clearTimeout(uiCacheSaveTimer.current); client.close(); }; }, [client, operationController]); @@ -464,18 +470,20 @@ export function App() { stopPreviewProgress(); setPreviewProgress({ percent: 100, phase: "error", detail: `刀具路径加载失败: ${message}` }); }; - const loadCase = async () => { + const loadCase = async (requestedCasePath = casePath) => { if (!canLoad) return; + const requestedMachine = machineDefinition(requestedCasePath) ?? DEFAULT_MACHINE; setProgramLoading(true); - startPreviewProgress("XYZAC 程序"); + startPreviewProgress(`${requestedMachine.coordinates} 程序`); setCommandStatus("PROGRAM_LOAD pending: waiting for LinuxCNC WASM preview"); setCaseStatus("loading session..."); try { await client.ensureSession(); - setCaseStatus("loading exact XYZAC case..."); - const bundle = await loadSimulationCase({ path: casePath, baseUrl: "." }); + setCaseStatus(`loading exact ${requestedMachine.coordinates} case...`); + const bundle = await loadSimulationCase({ path: requestedCasePath, baseUrl: "." }); if (bundle.status !== "ready") throw new Error(`xyzac_case_blocked:${bundle.missing.join(",")}`); - const source = bundle.files?.[PROGRAM_PATH]; + const programPathForCase = requestedMachine.program; + const source = bundle.files?.[programPathForCase]; if (typeof source !== "string" || !source.trim()) throw new Error("xyzac_demo_program_missing"); const normalizedSource = normalizeProgramSource(source); setCaseStatus("starting exact case..."); @@ -483,13 +491,13 @@ export function App() { const operation = await operationController.execute("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }, { state: axisState, gate: false }); const resultPayload = (operation.response as { payload?: { configuration?: { state?: string } } }).payload; markPreviewProgress(28, "wasm", "LinuxCNC WASM 配置已加载,准备解析刀具路径"); - setProgramText(normalizedSource); setProgramPath(PROGRAM_PATH); + setProgramText(normalizedSource); setProgramPath(programPathForCase); markPreviewProgress(38, "wasm", "LinuxCNC WASM 正在解析刀具路径"); - const programLoad = await operationController.execute("PROGRAM_LOAD", { source: normalizedSource, path: PROGRAM_PATH }, { state: axisState, gate: false }); - finishPreviewProgress(PROGRAM_PATH, programLoad.response); + const programLoad = await operationController.execute("PROGRAM_LOAD", { source: normalizedSource, path: programPathForCase }, { state: axisState, gate: false }); + finishPreviewProgress(programPathForCase, programLoad.response); setOperationTrace(operationController.exportTrace()); setCommandStatus("PROGRAM_LOAD acknowledged"); - setCaseStatus(`${bundle.entrypoint} + ${PROGRAM_PATH} -> ${resultPayload?.configuration?.state ?? "READY"}`); + setCaseStatus(`${bundle.entrypoint} + ${programPathForCase} -> ${resultPayload?.configuration?.state ?? "READY"}`); } catch (error) { setOperationTrace(operationController.exportTrace()); failPreviewProgress(error instanceof Error ? error.message : "case_load_failed"); @@ -499,15 +507,15 @@ export function App() { const setProgramText = (value: string) => { setProgramTextState(value); }; const loadPackagedProgram = async () => { if (!canLoad) return; - const path = "linuxcnc/nc_files/3D_Chips.ngc"; + const path = selectedMachine.coordinates === "XYZAC" ? "linuxcnc/nc_files/3D_Chips.ngc" : selectedMachine.program; setProgramLoading(true); - startPreviewProgress("3D_Chips.ngc"); + startPreviewProgress(`${selectedMachine.coordinates} demo`); setCommandStatus("PROGRAM_LOAD pending: waiting for LinuxCNC WASM preview"); setCaseStatus(`${path} loading from published workspace...`); try { - const response = await fetch(`./${path}`); - if (!response.ok) throw new Error(`program_asset_http_${response.status}`); - const source = normalizeProgramSource(await response.text()); + const source = path === selectedMachine.program + ? normalizeProgramSource((await loadSimulationCase({ path: casePath, baseUrl: "." })).files?.[path] ?? "") + : normalizeProgramSource(await (async () => { const response = await fetch(`./${path}`); if (!response.ok) throw new Error(`program_asset_http_${response.status}`); return response.text(); })()); if (!source.trim()) throw new Error("program_asset_empty"); if (!latestLoadGate.current.enabled) throw new Error(latestLoadGate.current.reason); setProgramText(source); setProgramPath(path); @@ -516,7 +524,7 @@ export function App() { finishPreviewProgress(path, programLoad.response); setOperationTrace(operationController.exportTrace()); setCommandStatus("PROGRAM_LOAD acknowledged"); - setCaseStatus(`${path} loaded through linuxcnc-wasm`); + setCaseStatus(`${selectedMachine.coordinates}: ${path} loaded through linuxcnc-wasm`); } catch (error) { setOperationTrace(operationController.exportTrace()); failPreviewProgress(error instanceof Error ? error.message : "program_asset_load_failed"); @@ -629,7 +637,7 @@ export function App() { if (key === "F10") { event.preventDefault(); onSpindle(spindleDirection !== 0 ? 0 : -1, true); return; } if (key === "F11") { event.preventDefault(); execute("SPINDLE_COMMAND", { spindle: 0, delta: -100 }); return; } if (key === "F12") { event.preventDefault(); execute("SPINDLE_COMMAND", { spindle: 0, delta: 100 }); return; } - if (["x", "y", "z", "a"].includes(key)) { event.preventDefault(); if (gates.jog.enabled) setSelectedAxis(key); return; } + if (["x", "y", "z", "a", "b", "c"].includes(key)) { event.preventDefault(); if (gates.jog.enabled && selectedMachine.axes.includes(key)) setSelectedAxis(key); return; } if (key === "o") { event.preventDefault(); openProgramFile(); return; } if (key === "v") { event.preventDefault(); setView((current) => current === "x" ? "y" : current === "y" ? "p" : current === "z" ? "z2" : current === "z2" ? "x" : "z"); setRightTab("preview"); return; } if (key === "d") { event.preventDefault(); setRotateMode((current) => !current); return; } @@ -647,7 +655,7 @@ export function App() { }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [axisState, enterManualMode, powerOn, runProgram, stopProgram, toggleEstop, aboutOpen]); + }, [axisState, enterManualMode, powerOn, runProgram, stopProgram, toggleEstop, aboutOpen, selectedMachine.id]); useEffect(() => { if (!aboutOpen) return; const previous = document.activeElement; @@ -752,15 +760,15 @@ export function App() { return
AXIS AXISLinuxCNC Web/WASM
Connection: {state.status.toUpperCase()}
-
execute("PROGRAM_STEP")} /> execute(programPaused ? "PROGRAM_RESUME" : "PROGRAM_PAUSE")} pressed={relief.pauseResume} /> stopProgram()} pressed={relief.abort} /> setZoom((value) => Math.min(2, value + .2))} /> setZoom((value) => Math.max(.5, value - .2))} /> { setView("z"); setRightTab("preview"); }} /> { setView("z2"); setRightTab("preview"); }} /> { setView("x"); setRightTab("preview"); }} /> { setView("y"); setRightTab("preview"); }} /> { setView("y2"); setRightTab("preview"); }} /> { setView("p"); setRightTab("preview"); }} /> setRotateMode((value) => !value)} /> xyzacCommand("VISUAL_CLEAR", {}, "vismachClear")} />
-
Load 3D_Chips.ngc{programLoading ? "Loading program..." : caseStatus.startsWith("blocked:") ? caseStatus : `Program: ${loadedProgramName}`}{previewProgress &&
{previewProgress.detail}{previewProgress.percent}%{previewProgress.percent}%
}
+
execute("PROGRAM_STEP")} /> execute(programPaused ? "PROGRAM_RESUME" : "PROGRAM_PAUSE")} pressed={relief.pauseResume} /> stopProgram()} pressed={relief.abort} /> setZoom((value) => Math.min(2, value + .2))} /> setZoom((value) => Math.max(.5, value - .2))} /> { setView("z"); setRightTab("preview"); }} /> { setView("z2"); setRightTab("preview"); }} /> { setView("x"); setRightTab("preview"); }} /> { setView("y"); setRightTab("preview"); }} /> { setView("y2"); setRightTab("preview"); }} /> { setView("p"); setRightTab("preview"); }} /> setRotateMode((value) => !value)} /> xyzacCommand("VISUAL_CLEAR", {}, "vismachClear")} />
+
Load 3D_Chips.ngc{programLoading ? "Loading program..." : caseStatus.startsWith("blocked:") ? caseStatus : `Program: ${loadedProgramName}`}{previewProgress &&
{previewProgress.detail}{previewProgress.percent}%{previewProgress.percent}%
}
- + { setCasePath(path); void loadCase(path); }} /> -
+
{(["path", "live", "limits", "velocity", "offsets", "extents"] as const).map((layer) => )}
- {aboutOpen &&
{ if (event.target === event.currentTarget) setAboutOpen(false); }}>

LinuxCNC AXIS Web/WASM

XYZAC TRT

setAboutOpen(false)}>OK
} + {aboutOpen &&
{ if (event.target === event.currentTarget) setAboutOpen(false); }}>

LinuxCNC AXIS Web/WASM

{selectedMachine.label}

setAboutOpen(false)}>OK
}
; } diff --git a/web/react/src/XyzacMachineView.tsx b/web/react/src/XyzacMachineView.tsx index 4c90dbb..74fe854 100644 --- a/web/react/src/XyzacMachineView.tsx +++ b/web/react/src/XyzacMachineView.tsx @@ -46,14 +46,15 @@ export function XyzacMachineView({ visual, toolpath, view = "p", zoom = 1, rotat const floor = new THREE.Mesh(new THREE.PlaneGeometry(18, 18), new THREE.ShadowMaterial({ opacity: .24 })); floor.rotation.x = -Math.PI / 2; floor.position.y = -2.64; floor.receiveShadow = true; scene.add(floor); - const assembly = buildXyzacMachine(); scene.add(assembly.machine); + const coordinates = visualRef.current.machineModel?.coordinate_system ?? "XYZAC"; + const assembly = buildXyzacMachine(coordinates); scene.add(assembly.machine); assembly.update(visualRef.current); const nameplateCanvas = document.createElement("canvas"); nameplateCanvas.width = 512; nameplateCanvas.height = 128; const label = nameplateCanvas.getContext("2d"); if (label) { label.fillStyle = "#176b67"; label.fillRect(0, 0, 512, 128); label.fillStyle = "#f0f4f3"; label.font = "bold 68px sans-serif"; label.fillText("AXIS", 18, 82); - label.font = "24px sans-serif"; label.fillText("XYZAC / TRT", 235, 76); + label.font = "24px sans-serif"; label.fillText(`${coordinates} / TRT`, 235, 76); } const nameplateTexture = new THREE.CanvasTexture(nameplateCanvas); nameplateTexture.colorSpace = THREE.SRGBColorSpace; const nameplate = new THREE.Mesh(new THREE.PlaneGeometry(.84, .21), new THREE.MeshBasicMaterial({ map: nameplateTexture })); @@ -158,7 +159,8 @@ export function XyzacMachineView({ visual, toolpath, view = "p", zoom = 1, rotat limits.box.setFromPoints([toView({ x: x[0], y: y[0], z: z[0] }), toView({ x: x[1], y: y[1], z: z[1] })]); } offsets.visible = config.showOffsets; - offsets.position.set(0, state.offsets.z_offset * .01, state.offsets.y_offset * .01); + const coordinates = state.machineModel?.coordinate_system ?? "XYZAC"; + offsets.position.set(coordinates === "XYZBC" ? Number(state.offsets.x_offset ?? 0) * .01 : 0, state.offsets.z_offset * .01, state.offsets.y_offset * .01); extents.visible = config.showExtents; const preset = `${config.view}:${config.zoom}`; if (preset !== lastPreset) { presetCamera(); lastPreset = preset; } @@ -179,8 +181,10 @@ export function XyzacMachineView({ visual, toolpath, view = "p", zoom = 1, rotat }, []); const model = visual.machineModel; + const coordinates = model?.coordinate_system ?? "XYZAC"; + const axisLabels = [...coordinates]; const controllerButton = (action: string, label: string, asset?: string, glyph?: string) => ; - return
+ return
{controllerButton("zoomIn", "Zoom in", "tool_zoomin.gif")}{controllerButton("zoomOut", "Zoom out", "tool_zoomout.gif")} {controllerButton("panLeft", "Pan left", undefined, "\u2190")}{controllerButton("panRight", "Pan right", undefined, "\u2192")} @@ -189,10 +193,11 @@ export function XyzacMachineView({ visual, toolpath, view = "p", zoom = 1, rotat {controllerButton("rotateUp", "Rotate upward", undefined, "\u2b06")}{controllerButton("rotateDown", "Rotate downward", undefined, "\u2b07")} {controllerButton("reset", "Reset machine view", undefined, "\u21ba")}{controllerButton("tool", "Tool close-up", "tool_axis_z.gif")}
-
{visual.authoritative ? `XYZAC TRT\n${visual.joints.map((value, index) => `${["X", "Y", "Z", "A", "C"][index]} ${value.toFixed(3)}`).join("\n")}\n${visual.joints.map((value, index) => `J${index} ${value.toFixed(3)}`).join("  ")}${showVelocity ? `\nV ${Number(visual.velocityMmMin ?? 0).toFixed(1)}` : ""}` : "XYZAC TRT\nWASM state pending"}
+
{visual.authoritative ? `${coordinates} TRT\n${visual.joints.map((value, index) => `${axisLabels[index] ?? `J${index}`} ${value.toFixed(3)}`).join("\n")}\n${visual.joints.map((value, index) => `J${index} ${value.toFixed(3)}`).join("  ")}${showVelocity ? `\nV ${Number(visual.velocityMmMin ?? 0).toFixed(1)}` : ""}` : `${coordinates} TRT\nWASM state pending`}
; } export function XyzacMachineViewFromSnapshot({ snapshot, ...props }: Omit & { snapshot: XyzacVisualSource | null | undefined }) { - return ; + const visual = projectXyzacVisual(snapshot); + return ; } diff --git a/web/react/src/XyzacToolPreview.tsx b/web/react/src/XyzacToolPreview.tsx index 7abd252..b944ee3 100644 --- a/web/react/src/XyzacToolPreview.tsx +++ b/web/react/src/XyzacToolPreview.tsx @@ -385,7 +385,10 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa originGroup.scale.setScalar(Math.max(.015, bounds.radius * .35)); const pose = sourcePoint(renderedPose); toolGroup.position.copy(pose); - toolGroup.rotation.set(-Number(renderedPose.a ?? 0) * DEG, -Number(renderedPose.c ?? 0) * DEG, 0); + const isXyzbc = String(state.machineModel?.coordinate_system ?? "XYZAC") === "XYZBC"; + const tilt = -Number(renderedPose[isXyzbc ? "b" : "a"] ?? 0) * DEG; + const rotary = -Number(renderedPose.c ?? 0) * DEG; + toolGroup.rotation.set(isXyzbc ? 0 : tilt, rotary, isXyzbc ? tilt : 0); const toolScale = Math.min(1, Math.max(0.015, bounds.radius * 0.35)); toolGroup.scale.setScalar(toolScale); tool.position.set(0, .57, 0); tip.position.set(0, 0, 0); @@ -404,7 +407,8 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa element.dataset.xyzacLimitsVisible = String(limitGrid.visible); element.dataset.xyzacExtentsVisible = String(extents.visible); element.dataset.xyzacOffset = JSON.stringify(offset); - if (hudRef.current) hudRef.current.textContent = `TOOL PREVIEW\nX ${Number(renderedPose.x).toFixed(3)} Y ${Number(renderedPose.y).toFixed(3)} Z ${Number(renderedPose.z).toFixed(3)}\nA ${Number(renderedPose.a).toFixed(3)} C ${Number(renderedPose.c).toFixed(3)}\nJ0 ${Number(renderedJoints[0] ?? 0).toFixed(3)} J1 ${Number(renderedJoints[1] ?? 0).toFixed(3)} J2 ${Number(renderedJoints[2] ?? 0).toFixed(3)}\nJ3 ${Number(renderedJoints[3] ?? 0).toFixed(3)} J4 ${Number(renderedJoints[4] ?? 0).toFixed(3)}`; + const rotaryTiltAxis = String(state.machineModel?.coordinate_system ?? "XYZAC") === "XYZBC" ? "b" : "a"; + if (hudRef.current) hudRef.current.textContent = `TOOL PREVIEW\nX ${Number(renderedPose.x).toFixed(3)} Y ${Number(renderedPose.y).toFixed(3)} Z ${Number(renderedPose.z).toFixed(3)}\n${rotaryTiltAxis.toUpperCase()} ${Number(renderedPose[rotaryTiltAxis]).toFixed(3)} C ${Number(renderedPose.c).toFixed(3)}\nJ0 ${Number(renderedJoints[0] ?? 0).toFixed(3)} J1 ${Number(renderedJoints[1] ?? 0).toFixed(3)} J2 ${Number(renderedJoints[2] ?? 0).toFixed(3)}\nJ3 ${Number(renderedJoints[3] ?? 0).toFixed(3)} J4 ${Number(renderedJoints[4] ?? 0).toFixed(3)}`; if (layer.showVelocity && hudRef.current) hudRef.current.textContent += `\nV ${Number(state.velocityMmMin ?? 0).toFixed(1)} mm/min`; controls.mouseButtons.LEFT = rotateRef.current ? THREE.MOUSE.ROTATE : THREE.MOUSE.PAN; controls.update(); renderer.render(scene, camera); frame = requestAnimationFrame(render); @@ -417,7 +421,10 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa const helixCount = visual.toolpath.filter((segment) => (segment as { kind?: string }).kind === "helix").length; const sampleCount = visual.toolpath.reduce((count, segment) => count + (Array.isArray((segment as { samples?: unknown[] }).samples) ? (segment as { samples: unknown[] }).samples.length : 0), 0); const controllerButton = (action: string, label: string, glyph: string) => ["zoomIn", "zoomOut"].includes(action) ? null : ; - return
{controllerButton("zoomIn", "Zoom in", "+")}{controllerButton("zoomOut", "Zoom out", "-")}
{visual.authoritative ? `TOOL PREVIEW\nX ${Number(pose.x).toFixed(3)}  Y ${Number(pose.y).toFixed(3)}  Z ${Number(pose.z).toFixed(3)}\nA ${Number(pose.a).toFixed(3)}  C ${Number(pose.c).toFixed(3)}\nJ0 ${Number(visual.joints[0] ?? 0).toFixed(3)}  J1 ${Number(visual.joints[1] ?? 0).toFixed(3)}  J2 ${Number(visual.joints[2] ?? 0).toFixed(3)}\nJ3 ${Number(visual.joints[3] ?? 0).toFixed(3)}  J4 ${Number(visual.joints[4] ?? 0).toFixed(3)}` : "TOOL PREVIEW\nWASM state pending"}
; + const coordinates = String(visual.machineModel?.coordinate_system ?? "XYZAC"); + const tiltAxis = coordinates === "XYZBC" ? "b" : "a"; + const tiltLabel = tiltAxis.toUpperCase(); + return
{controllerButton("zoomIn", "Zoom in", "+")}{controllerButton("zoomOut", "Zoom out", "-")}
{visual.authoritative ? `TOOL PREVIEW\nX ${Number(pose.x).toFixed(3)}  Y ${Number(pose.y).toFixed(3)}  Z ${Number(pose.z).toFixed(3)}\n${tiltLabel} ${Number(pose[tiltAxis] ?? 0).toFixed(3)}  C ${Number(pose.c).toFixed(3)}\nJ0 ${Number(visual.joints[0] ?? 0).toFixed(3)}  J1 ${Number(visual.joints[1] ?? 0).toFixed(3)}  J2 ${Number(visual.joints[2] ?? 0).toFixed(3)}\nJ3 ${Number(visual.joints[3] ?? 0).toFixed(3)}  J4 ${Number(visual.joints[4] ?? 0).toFixed(3)}` : "TOOL PREVIEW\nWASM state pending"}
; } export function XyzacToolPreviewFromSnapshot({ snapshot, ...props }: Omit & { snapshot: XyzacVisualSource | null | undefined }) { diff --git a/web/react/src/XyzacTrtPanel.tsx b/web/react/src/XyzacTrtPanel.tsx index 0bb2009..6117cfc 100644 --- a/web/react/src/XyzacTrtPanel.tsx +++ b/web/react/src/XyzacTrtPanel.tsx @@ -1,15 +1,18 @@ import type { ReactNode } from "react"; -import { XYZAC_KINEMATICS, type XyzacOperationTrace, type XyzacPanelView } from "./xyzac-panel-contract"; +import { type XyzacOperationTrace, type XyzacPanelView } from "./xyzac-panel-contract"; import type { SimSnapshotV1 } from "./protocol"; +import { DEFAULT_MACHINE } from "../../workspace/machine-definitions.mjs"; type Command = (kind: string, payload?: Record, action?: string) => void; export function XyzacKinematicsControl({ view, command, loading = false }: { view: XyzacPanelView; command: Command; loading?: boolean }) { const active = view.kinematics?.active_type; - return
-
Switchkins{view.kinematics?.native_type ?? "Unavailable"}
+ const machine = view.machine ?? DEFAULT_MACHINE; + const modes = [{ type: 0, action: "switchkinsIdentity", label: "Identity", mcode: "M429" }, { type: 1, action: "switchkinsXyzac", label: `TCP: ${machine.coordinates}`, mcode: "M428" }, { type: 2, action: "switchkinsUserk", label: "User K", mcode: "M430" }]; + return
+
{machine.label}{view.kinematics?.native_type ?? "Unavailable"}
- {XYZAC_KINEMATICS.map((item) => { + {modes.map((item) => { const gate = view.gates[item.action]; const disabled = loading || view.status !== "ready" || gate?.enabled === false; return ; @@ -20,18 +23,23 @@ export function XyzacKinematicsControl({ view, command, loading = false }: { vie export function XyzacManualPanel({ view }: { view: XyzacPanelView }) { const pose = view.pose; - const axes = ["x", "y", "z", "a", "c"] as const; - return
-
XYZAC authority{view.kinematics?.linear_units ?? "mm"} / {view.kinematics?.angular_units ?? "deg"}
+ const machine = view.machine ?? DEFAULT_MACHINE; + const axes = machine.axes; + const offsets = view.kinematics?.offsets; + const offsetRows = machine.coordinates === "XYZBC" + ? [`X offset ${offsets ? Number(offsets.x_offset ?? 0).toFixed(3) : "-"}`, `Z offset ${offsets ? Number(offsets.z_offset).toFixed(3) : "-"}`, `Tool ${offsets ? Number(offsets.tool_offset).toFixed(3) : "-"}`] + : [`Y offset ${offsets ? Number(offsets.y_offset).toFixed(3) : "-"}`, `Z offset ${offsets ? Number(offsets.z_offset).toFixed(3) : "-"}`, `Tool ${offsets ? Number(offsets.tool_offset).toFixed(3) : "-"}`]; + return
+
{machine.coordinates} authority{view.kinematics?.linear_units ?? "mm"} / {view.kinematics?.angular_units ?? "deg"}
- {axes.map((axis, index) =>
{axis.toUpperCase()}{pose ? Number(pose[axis] ?? Number.NaN).toFixed(axis === "a" || axis === "c" ? 3 : 4) : "-"}J{index} {view.joints.length === 5 ? Number(view.joints[index]).toFixed(4) : "-"}
)} + {axes.map((axis, index) =>
{axis.toUpperCase()}{pose ? Number(pose[axis as keyof typeof pose] ?? Number.NaN).toFixed(["a", "b", "c"].includes(axis) ? 3 : 4) : "-"}J{index} {view.joints.length === machine.joint_count ? Number(view.joints[index]).toFixed(4) : "-"}
)}
-
Tool {view.kinematics ? Number(view.kinematics.offsets.tool_offset).toFixed(3) : "-"}Y offset {view.kinematics ? Number(view.kinematics.offsets.y_offset).toFixed(3) : "-"}Z offset {view.kinematics ? Number(view.kinematics.offsets.z_offset).toFixed(3) : "-"}
+
{offsetRows.map((row) => {row})}
; } export function XyzacProgramPane({ status, currentLine, sourceHash, children }: { status: string; currentLine: number | null; sourceHash?: string | null; children: ReactNode }) { - return
XYZAC Program{status}Line {currentLine ?? "-"}{sourceHash?.slice(0, 18) ?? "no source"}
{children}
; + return
TRT Program{status}Line {currentLine ?? "-"}{sourceHash?.slice(0, 18) ?? "no source"}
{children}
; } export function XyzacLiveParameters({ snapshot }: { snapshot: SimSnapshotV1 | null }) { @@ -56,15 +64,18 @@ export function XyzacLiveParameters({ snapshot }: { snapshot: SimSnapshotV1 | nu export function XyzacDiagnostics({ view, operations, alarms, error }: { view: XyzacPanelView; operations: XyzacOperationTrace[]; alarms: Array>; error: { code: string; message?: string } | null }) { const disabled = Object.entries(view.gates).filter(([, gate]) => gate.enabled === false); - return
+ return
{view.status.toUpperCase()}Tick {view.tick}{view.stateHash || "no hash"}
{error &&
{error.code}{error.message}
}

Operation trace

{operations.length ? operations.slice(-8).reverse().map((item, index) =>
{item.operation_id ?? "-"}{item.kind}{item.phase}{item.reason ?? item.response_kind}
) : No operations}

Alarms / recovery

{alarms.length ? alarms.slice(-5).map((alarm, index) =>
{String(alarm.code ?? "alarm")}{String(alarm.severity ?? "error")}
) : No alarms}
recovery{view.recovery ? JSON.stringify(view.recovery) : "idle"}
-

Gate reasons

{disabled.length ? disabled.slice(0, 10).map(([name, gate]) =>
{name}{gate.reason}
) : All XYZAC gates enabled}
+

Gate reasons

{disabled.length ? disabled.slice(0, 10).map(([name, gate]) =>
{name}{gate.reason}
) : All {view.machine.coordinates} gates enabled}
; } -export function XyzacTrtPanel({ view, command, loading = false }: { view: XyzacPanelView; command: Command; loading?: boolean }) { - return
; +export function XyzacTrtPanel({ view, command, loading = false, machineOptions = [], selectedMachine, onMachineChange }: { view: XyzacPanelView; command: Command; loading?: boolean; machineOptions?: readonly { entrypoint: string; label: string }[]; selectedMachine?: string; onMachineChange?: (entrypoint: string) => void }) { + return
+ {machineOptions.length > 1 && } + +
; } diff --git a/web/react/src/legacy.d.ts b/web/react/src/legacy.d.ts index 82b3bbb..8436592 100644 --- a/web/react/src/legacy.d.ts +++ b/web/react/src/legacy.d.ts @@ -1,4 +1,8 @@ declare module "*.mjs" { + export type MachineDefinition = { id: string; label: string; coordinates: string; axes: readonly string[]; joint_count: number; entrypoint: string; program: string; kinematics_type: number; runtime: string; model: string; source_path: string; offsets: Record; axis_limits: Record }; + export const MACHINE_DEFINITIONS: readonly MachineDefinition[]; + export const DEFAULT_MACHINE: MachineDefinition; + export function machineDefinition(value?: string): MachineDefinition | null; export const SIM_CASE_CATALOG: Array<{ path: string; label?: string; category?: string; status?: string }>; export function loadSimulationCase(options: { path: string; baseUrl?: string }): Promise<{ status: string; entrypoint: string; files: Record; missing: string[]; fetched: string[]; state_hash: string; halManifest: unknown[] }>; export function toWorkerConfiguration(bundle: unknown): Record; diff --git a/web/react/src/protocol.ts b/web/react/src/protocol.ts index f94c885..97f9d22 100644 --- a/web/react/src/protocol.ts +++ b/web/react/src/protocol.ts @@ -99,26 +99,28 @@ export type SimSnapshotV1 = { task_authority?: string; }; -export type XyzacKinematicsSnapshot = { +export type TrtKinematicsSnapshot = { schema: "wasm-xyzac-kinematics.v1"; status: "verified" | "error" | "blocked"; active_type: 0 | 1 | 2; native_type: string; - coordinates: "XYZAC"; + coordinates: "XYZAC" | "XYZBC"; joint_count: 5; linear_units: "mm"; angular_units: "deg"; pose: Record<"x" | "y" | "z" | "a" | "b" | "c" | "u" | "v" | "w", number>; joints: [number, number, number, number, number]; - offsets: { x_rot_point: number; y_offset: number; z_offset: number; tool_offset: number }; + offsets: { x_rot_point: number; y_offset: number; z_offset: number; tool_offset: number; y_rot_point?: number; z_rot_point?: number; x_offset?: number }; generation: number; switch_count: number; state_hash: string; wasm_state_hash: string; }; +export type XyzacKinematicsSnapshot = TrtKinematicsSnapshot; + export type XyzacCapabilitySnapshot = { - coordinates: "XYZAC"; + coordinates: "XYZAC" | "XYZBC"; joint_count: 5; active_type: 0 | 1 | 2; hal_pin_vars: boolean; @@ -176,7 +178,7 @@ export type XyzacMachineModel = { source: "linuxcnc-wasm-worker"; source_path: string; source_hash: string | null; - coordinate_system: "XYZAC"; + coordinate_system: "XYZAC" | "XYZBC"; joints: string[]; components: string[]; }; diff --git a/web/react/src/store.ts b/web/react/src/store.ts index 4ac0c55..61b381c 100644 --- a/web/react/src/store.ts +++ b/web/react/src/store.ts @@ -71,6 +71,7 @@ function projection(result: Record, message: MessageEnvelope, p const executionTrace = resetExecutionTrace ? trace.slice(-EXECUTION_TRACE_CACHE_LIMIT) : trace.length ? [...previous.executionTrace, ...trace].slice(-EXECUTION_TRACE_CACHE_LIMIT) : previous.executionTrace; const delta = result.motion_sample as { tick?: number; pose?: Record; joints?: number[]; active_segment_id?: number; active_progress?: number } | undefined; if (!panel && delta && previous.snapshot) { + const previousSnapshot = previous.snapshot; const pose = delta.pose ?? {}; const joints = Array.isArray(delta.joints) ? [...delta.joints] : previous.snapshot.motion?.joints ?? []; const visual = previous.snapshot.visual ? { @@ -97,16 +98,8 @@ function projection(result: Record, message: MessageEnvelope, p motion: { ...previous.snapshot.motion, ...(result.motion as SimSnapshotV1["motion"] | undefined), - // Keep the XYZAC axis vector intact on every motion delta. Older - // projection code rebuilt only XYZ, which made A/C disappear from - // consumers that read the canonical motion array. - axes: [ - Number(pose.x ?? previous.snapshot.motion?.axes?.[0] ?? 0), - Number(pose.y ?? previous.snapshot.motion?.axes?.[1] ?? 0), - Number(pose.z ?? previous.snapshot.motion?.axes?.[2] ?? 0), - Number(pose.a ?? previous.snapshot.motion?.axes?.[3] ?? 0), - Number(pose.c ?? previous.snapshot.motion?.axes?.[4] ?? 0), - ], + // Keep the active machine's axis vector intact on every motion delta. + axes: [...String(previousSnapshot.kinematics?.coordinates ?? "XYZAC").toLowerCase()].map((axis, index) => Number(pose[axis] ?? previousSnapshot.motion?.axes?.[index] ?? 0)), joints, }, wasm_motion: (result.wasm_motion as Record) ?? previous.snapshot.wasm_motion, @@ -150,7 +143,8 @@ function projection(result: Record, message: MessageEnvelope, p }) : previous.toolpath; const pose = panel.visual.pose; - const axes = [pose.x, pose.y, pose.z, pose.a, pose.c].map((value) => Number.isFinite(value) ? value : 0); + const coordinateSystem = String(panel.kinematics?.coordinates ?? panel.xyzac?.coordinates ?? "XYZAC"); + const axes = [...coordinateSystem.toLowerCase()].map((axis) => Number.isFinite(pose?.[axis]) ? Number(pose[axis]) : 0); const snapshot = { schema: panel.schema, source_status: panel.source_status, diff --git a/web/react/src/test_xyzac_motion_projection.mjs b/web/react/src/test_xyzac_motion_projection.mjs index 42f2913..3e3b158 100644 --- a/web/react/src/test_xyzac_motion_projection.mjs +++ b/web/react/src/test_xyzac_motion_projection.mjs @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { INITIAL_STATE, reduceFrontendState } from "./store.ts"; +import { projectXyzacPanel } from "./xyzac-panel-contract.ts"; const envelope = (kind, seq, payload, tick) => ({ schema_version: 1, kind, context_id: INITIAL_STATE.contextId, session_id: "motion-projection", seq, simulation_tick: tick, payload }); const panel = { @@ -36,4 +37,8 @@ const next = reduceFrontendState(initial, envelope("COMMAND_RESULT", 2, { assert.deepEqual(next.snapshot.motion.axes, [11, 21, 31, 41, 51]); assert.equal(next.snapshot.visual.pose.a, 41); assert.equal(next.snapshot.visual.pose.c, 51); +const xyzbcPanel = projectXyzacPanel({ simulation_tick: 3, state_hash: "bc", kinematics: { schema: "wasm-xyzac-kinematics.v1", status: "verified", active_type: 1, native_type: "xyzbc-trt-kins", coordinates: "XYZBC", joint_count: 5, linear_units: "mm", angular_units: "deg", pose: { x: 0, y: 0, z: 0, a: 0, b: 12, c: 20, u: 0, v: 0, w: 0 }, joints: [1, 2, 3, 12, 20], 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 }, generation: 1, switch_count: 1, state_hash: "k", wasm_state_hash: "w" }, xyzac: { coordinates: "XYZBC", joint_count: 5, active_type: 1, hal_pin_vars: true, switchkins_net: true, finite_offsets: true, userk_capability: true }, visual: { pose: { x: 0, y: 0, z: 0, a: 0, b: 12, c: 20 }, joints: [1, 2, 3, 12, 20] } }); +assert.equal(xyzbcPanel.machine.coordinates, "XYZBC"); +assert.deepEqual(xyzbcPanel.machine.axes, ["x", "y", "z", "b", "c"]); +assert.deepEqual(xyzbcPanel.joints, [1, 2, 3, 12, 20]); console.log(JSON.stringify({ schema: "xyzac-motion-projection.v1", status: "verified", axes: next.snapshot.motion.axes, pose: { a: next.snapshot.visual.pose.a, c: next.snapshot.visual.pose.c } })); diff --git a/web/react/src/xyzac-machine-model.ts b/web/react/src/xyzac-machine-model.ts index 8488164..70b4412 100644 --- a/web/react/src/xyzac-machine-model.ts +++ b/web/react/src/xyzac-machine-model.ts @@ -6,7 +6,9 @@ const MM = .01; const DEG = Math.PI / 180; // Source coordinates remain millimetres, Z up. The existing viewer uses Y up. -export function buildXyzacMachine() { +export function buildXyzacMachine(coordinates = "XYZAC") { + // XYZAC retains the established tilt-a group identity; XYZBC uses tilt-b. + const rotaryAxis = coordinates[3]?.toLowerCase() ?? "a"; const geometries = new Map(); const materials = { enamel: new THREE.MeshStandardMaterial({ color: 0xd9e1e1, roughness: .32, metalness: .25 }), @@ -51,7 +53,7 @@ export function buildXyzacMachine() { const lathe = (parent: THREE.Object3D, name: string, profile: number[][], finish: Finish) => mesh(parent, name, geometry(name, () => new THREE.LatheGeometry(profile.map(([r, z]) => new THREE.Vector2(r * MM, z * MM)), 64)), finish, 0, 0, 0); - const machine = group(null, "xyzac-machine"); + const machine = group(null, coordinates === "XYZBC" ? "xyzbc-machine" : "xyzac-machine"); for (const x of [-95, 95]) for (const y of [-72, 135]) { cylinder(machine, "levelling-pad", x, y, -259, 17, 9, "rubber"); cylinder(machine, "levelling-stud", x, y, -249, 8, 15, "steel"); @@ -141,14 +143,14 @@ export function buildXyzacMachine() { cylinder(brackets, "bearing-seal", x < 0 ? -82 : 82, 0, 0, 12, 3, "dark", "x"); for (const y of [-27, 27]) bolt(brackets, x, y, -38); } - box(brackets, "a-axis-servo", -94, 0, 0, 23, 48, 46, "teal", 4); - cylinder(brackets, "a-axis-end-cap", -107, 0, 0, 16, 3, "dark", "x"); + box(brackets, `${rotaryAxis}-axis-servo`, -94, 0, 0, 23, 48, 46, "teal", 4); + cylinder(brackets, `${rotaryAxis}-axis-end-cap`, -107, 0, 0, 16, 3, "dark", "x"); - const tilt = group(tableX, "tilt-a"); + const tilt = group(tableX, `tilt-${rotaryAxis}`); box(tilt, "yoke-plate", 0, 0, -30, 130, 80, 10, "teal", 2); for (const x of [-60, 60]) { box(tilt, "yoke-side-plate", x, 0, -15, 10, 80, 34, "teal", 2); - cylinder(tilt, "a-trunnion-shaft", x, 0, 0, 10, 22, "steel", "x"); + cylinder(tilt, `${rotaryAxis}-trunnion-shaft`, x, 0, 0, 10, 22, "steel", "x"); } const rotaryBase = group(tilt, "c-rotary-base"); box(rotaryBase, "c-drive-housing", 0, 0, -24, 104, 100, 12, "dark", 3); @@ -178,15 +180,34 @@ export function buildXyzacMachine() { let toolLength = NaN; const update = (state: Pick) => { const [tableXJoint, saddleYJoint, spindleZJoint, tiltAJoint, rotateCJoint] = state.joints; + const xOffset = Number(state.offsets.x_offset ?? 0); + const yOffset = Number(state.offsets.y_offset ?? 0); + const zOffset = Number(state.offsets.z_offset ?? 0); tableX.position.x = -tableXJoint * MM; saddle.position.z = -saddleYJoint * MM; - zslide.position.y = (200 - spindleZJoint) * MM; - tilt.rotation.x = -tiltAJoint * DEG; + if (coordinates === "XYZBC") { + head.position.y = (150 + spindleZJoint) * MM; + // XYZBC is a table tilt: the source model rotates the B assembly + // around machine Y. The Web scene maps source Y to Three.js Z. + head.rotation.set(0, 0, 0); + zslide.position.y = 0; + tilt.rotation.set(0, 0, -tiltAJoint * DEG); + // xyzbc-trt-gui applies x-offset to both sides of the B table and + // applies z-offset with opposite signs to the inner/outer groups. + tilt.position.set(xOffset * MM, zOffset * MM, yOffset * MM); + brackets.position.copy(tilt.position); + rotaryBase.position.set(xOffset * MM, -zOffset * MM, 0); + } else { + zslide.position.y = (200 - spindleZJoint) * MM; + head.position.y = 2; + head.rotation.set(0, 0, 0); + tilt.rotation.x = -tiltAJoint * DEG; + tilt.position.set(0, zOffset * MM, yOffset * MM); + brackets.position.copy(tilt.position); + rotaryBase.position.copy(tilt.position).negate(); + } rotate.rotation.y = -rotateCJoint * DEG; tool.position.y = -state.offsets.tool_offset * MM; - tilt.position.set(0, state.offsets.z_offset * MM, state.offsets.y_offset * MM); - brackets.position.copy(tilt.position); - rotaryBase.position.copy(tilt.position).negate(); if (toolLength !== state.offsets.tool_offset) { toolLength = state.offsets.tool_offset; const reach = Math.max(.5, 10 + toolLength); diff --git a/web/react/src/xyzac-panel-contract.ts b/web/react/src/xyzac-panel-contract.ts index 0f27454..b03cb6b 100644 --- a/web/react/src/xyzac-panel-contract.ts +++ b/web/react/src/xyzac-panel-contract.ts @@ -1,7 +1,9 @@ import type { XyzacCapabilitySnapshot, XyzacKinematicsSnapshot } from "./protocol"; +import { DEFAULT_MACHINE, MACHINE_DEFINITIONS } from "../../workspace/machine-definitions.mjs"; -export const XYZAC_CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini" as const; -export const XYZAC_AXES = ["x", "y", "z", "a", "c"] as const; +export const MACHINE_CASES = MACHINE_DEFINITIONS; +export const XYZAC_CASE = DEFAULT_MACHINE.entrypoint; +export const XYZAC_AXES: readonly string[] = DEFAULT_MACHINE.axes; export const XYZAC_COMMANDS = { power: "POWER", selectKinematics: "KINEMATICS_SELECT", @@ -25,6 +27,7 @@ export type XyzacPanelView = { joints: number[]; gates: Record; recovery: Record | null; + machine: typeof DEFAULT_MACHINE; }; type XyzacPanelSource = { @@ -38,13 +41,18 @@ type XyzacPanelSource = { }; export function projectXyzacPanel(snapshot: XyzacPanelSource | null | undefined): XyzacPanelView { - const kinematics = snapshot?.kinematics?.coordinates === "XYZAC" && snapshot.kinematics.joint_count === 5 ? snapshot.kinematics : null; - const capability = snapshot?.xyzac?.coordinates === "XYZAC" && snapshot.xyzac.joint_count === 5 ? snapshot.xyzac : null; + const rawKinematics = snapshot?.kinematics; + const rawCapability = snapshot?.xyzac; + const machine = MACHINE_DEFINITIONS.find((entry) => entry.coordinates === rawKinematics?.coordinates) ?? DEFAULT_MACHINE; + const kinematics = rawKinematics && rawKinematics.coordinates === machine.coordinates && rawKinematics.joint_count === machine.joint_count ? rawKinematics : null; + const capability = rawCapability && rawCapability.coordinates === machine.coordinates && rawCapability.joint_count === machine.joint_count ? rawCapability : null; const visualPose = snapshot?.visual?.pose; - const pose = visualPose && [visualPose.x, visualPose.y, visualPose.z, visualPose.a, visualPose.c].every((value) => Number.isFinite(value)) + const poseAxes = [...machine.coordinates.toLowerCase()]; + const pose = visualPose && poseAxes.every((axis) => Number.isFinite(visualPose[axis])) ? { ...(kinematics?.pose ?? { x: 0, y: 0, z: 0, a: 0, b: 0, c: 0, u: 0, v: 0, w: 0 }), ...visualPose } : kinematics?.pose ?? null; - const joints = snapshot?.visual?.joints?.length === 5 ? snapshot.visual.joints.slice(0, 5) : kinematics?.joints ? [...kinematics.joints] : []; + const visualJoints = snapshot?.visual?.joints; + const joints = visualJoints?.length === machine.joint_count ? visualJoints.slice(0, machine.joint_count) : kinematics?.joints ? [...kinematics.joints] : []; return { status: kinematics && capability ? "ready" : "unavailable", tick: snapshot?.simulation_tick ?? 0, @@ -55,5 +63,6 @@ export function projectXyzacPanel(snapshot: XyzacPanelSource | null | undefined) joints, gates: snapshot?.gates ?? {}, recovery: (snapshot?.authoritative?.recovery as Record | undefined) ?? null, + machine, }; } diff --git a/web/react/src/xyzac-visual-adapter.ts b/web/react/src/xyzac-visual-adapter.ts index 1efc932..29d774c 100644 --- a/web/react/src/xyzac-visual-adapter.ts +++ b/web/react/src/xyzac-visual-adapter.ts @@ -35,8 +35,10 @@ export type XyzacVisualSource = { export function projectXyzacVisual(snapshot: XyzacVisualSource | null | undefined): XyzacVisualState { const kinematics = snapshot?.kinematics; + const coordinateSystem = String(kinematics?.coordinates ?? snapshot?.visual?.machine_model?.coordinate_system ?? "XYZAC"); + const poseAxes = [...coordinateSystem.toLowerCase()]; const visualPose = snapshot?.visual?.pose; - const pose = visualPose && [visualPose.x, visualPose.y, visualPose.z, visualPose.a, visualPose.c].every((value) => Number.isFinite(value)) + const pose = visualPose && poseAxes.every((axis) => Number.isFinite(visualPose[axis])) ? { ...ZERO_POSE, ...visualPose } : kinematics?.pose ?? ZERO_POSE; const visualJoints = snapshot?.visual?.joints; diff --git a/web/react/vite.config.ts b/web/react/vite.config.ts index f207a54..d42826b 100644 --- a/web/react/vite.config.ts +++ b/web/react/vite.config.ts @@ -40,6 +40,7 @@ function linuxcncAssets(): Plugin { ["linuxcnc/share/axis/images", "linuxcnc/share/axis/images"], ["linuxcnc/lib/hallib", "linuxcnc/lib/hallib"], ["linuxcnc/src/hal/user_comps/vismach/xyzac-trt-gui.py", "linuxcnc/src/hal/user_comps/vismach/xyzac-trt-gui.py"], + ["linuxcnc/src/hal/user_comps/vismach/xyzbc-trt-gui.py", "linuxcnc/src/hal/user_comps/vismach/xyzbc-trt-gui.py"], ["linuxcnc/configs/sim/qtvcp_screens/qtaxis_lathe/qtaxis/images", "linuxcnc/configs/sim/qtvcp_screens/qtaxis_lathe/qtaxis/images"], ]; for (const [from, to] of copies) cpSync(resolve(repoRoot, from), resolve(output, to), { recursive: true, dereference: false }); diff --git a/web/worker/sim-session.mjs b/web/worker/sim-session.mjs index 320e767..4b4d621 100644 --- a/web/worker/sim-session.mjs +++ b/web/worker/sim-session.mjs @@ -31,6 +31,7 @@ import { WasmXyzacIo } from "./wasm-xyzac-io.mjs"; import { WasmXyzacDeviceIo } from "./wasm-xyzac-device-io.mjs"; import { WasmProgramRuntime } from "./wasm-program-runtime.mjs"; import { axisGate, xyzacGate, xyzacGateMatrix } from "../app/axis-contract.mjs"; +import { machineDefinition } from "../workspace/machine-definitions.mjs"; const PROTOCOL_SCHEMA = 1; const CONTEXT_ID = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1"; @@ -135,6 +136,7 @@ export class SimSession { this.xyzacIo = null; this.xyzacDeviceIo = null; this.xyzacProgram = null; + this.machineDefinition = null; this.homingHistory = []; this.xyzacProgramKinematicsMode = XYZAC_MODES.identity; this.xyzacRapidVelocity = 35; @@ -293,7 +295,7 @@ export class SimSession { source: wasmMotion.state.source ?? "wasm_motion_queue", tick: wasmMotion.status === "verified" ? wasmMotion.state.tick : this.tick, axes: { x: position.x, y: position.y, z: position.z }, - joints: kinematicsJoints ?? (home.status === "verified" && Array.isArray(home.state.joints) ? [...home.state.joints] : [position.x, position.y, position.z, position.a ?? 0, position.c ?? 0]), + joints: kinematicsJoints ?? (home.status === "verified" && Array.isArray(home.state.joints) ? [...home.state.joints] : [position.x, position.y, position.z, position[this.machineDefinition?.coordinates === "XYZBC" ? "b" : "a"] ?? 0, position.c ?? 0]), velocity: wasmMotion.status === "verified" ? wasmMotion.state.velocity : 0, velocity_mm_min: Number(wasmMotion.state?.velocity_mm_min ?? 0), feed_command_mm_min: Number(sourceProgram?.feed ?? 0), @@ -310,7 +312,7 @@ export class SimSession { max_velocity: Number(wasmMotion.state?.velocity_limit ?? 0), homed: Boolean(home.state?.homed), homed_axes: Array.from({ length: 5 }, (_, index) => Boolean(Number(home.state?.homed_mask ?? 0) & (1 << index))), - limits: { x: Boolean(home.state?.limit_error), y: Boolean(home.state?.limit_error), z: Boolean(home.state?.limit_error), a: Boolean(home.state?.limit_error), c: Boolean(home.state?.limit_error) }, + limits: { x: Boolean(home.state?.limit_error), y: Boolean(home.state?.limit_error), z: Boolean(home.state?.limit_error), [this.machineDefinition?.coordinates === "XYZBC" ? "b" : "a"]: Boolean(home.state?.limit_error), c: Boolean(home.state?.limit_error) }, on_any_limit: Boolean(home.state?.limit_error), following_error: 0, motion_mode: this.xyzacProgram ? this.wasmHomeLimit.mode : (home.state?.homed ? "TELEOP" : "FREE"), @@ -356,7 +358,9 @@ export class SimSession { if (requirement.status !== "verified") return requirement; if (!requirement.required) return { status: "verified", count: 0 }; const basePath = String(programPath ?? "").split("/").slice(0, -1).join("/"); - const rows = [["remap_subs/xyzac_switchkins_sub.ngc", "xyzac_switchkins_sub"], ["remap_subs/helix_ac.ngc", "helix_ac"]]; + const coordinates = this.machineDefinition?.coordinates ?? "XYZAC"; + const stem = coordinates.toLowerCase(); + const rows = [[`remap_subs/${stem}_switchkins_sub.ngc`, `${stem}_switchkins_sub`], [`remap_subs/helix_${stem.slice(3)}.ngc`, `helix_${stem.slice(3)}`]]; for (const [suffix, name] of rows) { const path = `${basePath.replace(/\/demos$/, "")}/${suffix}`; const source = this.workspaceFiles[path]; @@ -402,7 +406,7 @@ export class SimSession { ? { schema: "wasm_authority_boundary.v1", source: "linuxcnc-wasm-worker", state_hash: fnv1a64({ motion: wasmMotion, kinematics: this.xyzacKinematics?.snapshot() ?? null, task }) } : this.authoritative.export(); const axis = axisState({ task, program: program ?? {}, motion, devices, io: this.xyzacIo ? this.xyzacIo.export() : {}, device_io: deviceIo }); - const xyzac = this.xyzacKinematics ? { coordinates: "XYZAC", joint_count: 5, hal_pin_vars: true, switchkins_net: true, finite_offsets: true, userk_capability: true, active_type: this.xyzacKinematics.snapshot().active_type } : null; + const xyzac = this.xyzacKinematics ? { coordinates: this.machineDefinition?.coordinates ?? "XYZAC", joint_count: 5, hal_pin_vars: true, switchkins_net: true, finite_offsets: true, userk_capability: true, active_type: this.xyzacKinematics.snapshot().active_type } : null; const gateInput = { task, program: program ?? {}, motion, devices, io: this.xyzacIo ? this.xyzacIo.export() : {}, device_io: deviceIo, xyzac, taskfile: Boolean(this.xyzacProgram ? this.xyzacProgram.snapshot().status !== 0 : this.program.loaded), kinematicsIdentity: this.motionAuthority === "wasm" }; const gateActions = ["load", "run", "runLine", "step", "pause", "resume", "abort", "restart", "reload", "edit", "mdi", "jogStart", "jogStop", "home", "estop", "recover", "override", "overrideLimits", "reset", "spindle", "coolant", "touchOff", "toolTouchOff"]; const ui = axisSourceUi({ task_state: axis.source_task_state, interp_state: axis.source_interp_state, @@ -459,7 +463,7 @@ export class SimSession { : { x: wasmPosition.x, y: wasmPosition.y, z: wasmPosition.z }; const inverseJoints = this.xyzacKinematics?.snapshot?.()?.joints; const joints = this.motionAuthority === "wasm" && wasmMotion.status === "verified" - ? (Array.isArray(wasmMotion.state.joint_commanded) && wasmMotion.state.joint_commanded.length === 5 ? [...wasmMotion.state.joint_commanded] : inverseJoints?.length === 5 ? [...inverseJoints] : [position.x, position.y, position.z, position.a ?? 0, position.c ?? 0]) + ? (Array.isArray(wasmMotion.state.joint_commanded) && wasmMotion.state.joint_commanded.length === 5 ? [...wasmMotion.state.joint_commanded] : inverseJoints?.length === 5 ? [...inverseJoints] : [position.x, position.y, position.z, position[this.machineDefinition?.coordinates === "XYZBC" ? "b" : "a"] ?? 0, position.c ?? 0]) : authoritative.kinematics.joints; const preview = this.previewState?.status === "verified" ? this.previewState : null; // Keep the Vismach model identity in the same authoritative visual @@ -467,16 +471,17 @@ export class SimSession { // hash comes from the exact resource resolved during WASM startup, so a // renderer can audit that its geometry matches the loaded LinuxCNC case. const modelResource = this.configurationState?.exact_resources?.find?.((row) => row.role === "vismach_source"); - const modelSourcePath = "linuxcnc/src/hal/user_comps/vismach/xyzac-trt-gui.py"; + const machineDefinition = this.machineDefinition; + const modelSourcePath = this.machineDefinition?.source_path ?? "linuxcnc/src/hal/user_comps/vismach/xyzac-trt-gui.py"; const modelSourceText = this.workspaceFiles?.[modelSourcePath]; const machineModel = this.xyzacProgram ? { schema: "linuxcnc-vismach-model.v1", - id: "xyzac-trt", + id: machineDefinition?.id ?? "xyzac-trt", source: "linuxcnc-wasm-worker", source_path: modelResource?.path ?? modelSourcePath, source_hash: modelResource?.data_hash ?? this.visualModelSourceHash ?? (typeof modelSourceText === "string" ? fnv1a64(modelSourceText) : null), - coordinate_system: "XYZAC", - joints: ["table-x", "saddle-y", "spindle-z", "tilt-a", "rotate-c"], + coordinate_system: machineDefinition?.coordinates ?? "XYZAC", + joints: machineDefinition?.coordinates ? ["table-x", "saddle-y", "spindle-z", `tilt-${machineDefinition.coordinates[3].toLowerCase()}`, "rotate-c"] : ["table-x", "saddle-y", "spindle-z", "tilt-a", "rotate-c"], components: ["base", "column", "head", "spindle", "tool", "table", "saddle", "zslide"], } : null; const visual = { @@ -498,10 +503,11 @@ export class SimSession { coordinate_offset: program?.coordinate_context?.offsets?.[(program?.coordinate_context?.system ?? 1) - 1]?.slice(0, 3) ?? null, ...(machineModel ? { machine_model: machineModel } : {}), }; - const axisCoordinates = [position.x, position.y, position.z, position.a ?? 0, position.c ?? 0]; + const coordinateAxes = machineDefinition?.axes ?? ["x", "y", "z", "a", "c"]; + const axisCoordinates = coordinateAxes.map((axis) => position[axis] ?? 0); const coordinateContext = program?.coordinate_context; const activeOffsets = coordinateContext?.offsets?.[coordinateContext.system - 1] ?? Array(9).fill(0); - const displayedOffsets = [0, 1, 2, 3, 5].map((index) => activeOffsets[index]); + const displayedOffsets = coordinateAxes.map((axis) => activeOffsets[["x", "y", "z", "a", "b", "c", "u", "v", "w"].indexOf(axis)] ?? 0); const unsigned = { schema: "axis-panel-snapshot.v1", context_id: this.contextId, @@ -532,7 +538,7 @@ export class SimSession { task_authority: this.task.authority(), ...(this.xyzacKinematics ? { kinematics: this.xyzacKinematics.snapshot() } : {}), ...(includeStableRuntime && this.xyzacIo ? { io: this.xyzacIo.export() } : {}), - ...(xyzac ? { xyzac } : {}), + ...(xyzac ? { xyzac: { ...xyzac, coordinates: machineDefinition?.coordinates ?? xyzac.coordinates } } : {}), }; return { ...unsigned, state_hash: fnv1a64(unsigned) }; } @@ -572,7 +578,7 @@ export class SimSession { const type = Number(input.payload?.type); const action = input.kind === "VISUAL_CLEAR" ? "vismachClear" : type === 0 ? "switchkinsIdentity" : type === 1 ? "switchkinsXyzac" : type === 2 ? "switchkinsUserk" : null; if (!action) return { action: null, enabled: false, reason: "xyzac_kinematics_type_invalid" }; - return xyzacGate(action, { task, program, motion, devices, xyzac: this.xyzacKinematics ? { coordinates: "XYZAC", joint_count: 5, hal_pin_vars: true, switchkins_net: true, finite_offsets: true, userk_capability: true } : null }); + return xyzacGate(action, { task, program, motion, devices, xyzac: this.xyzacKinematics ? { coordinates: this.machineDefinition?.coordinates ?? "XYZAC", joint_count: 5, hal_pin_vars: true, switchkins_net: true, finite_offsets: true, userk_capability: true } : null }); } const action = PANEL_GATE_ACTIONS[input.kind]; if (!action) return { action: null, enabled: true, reason: "axis_internal_command" }; @@ -675,7 +681,8 @@ export class SimSession { if (!this.xyzacProgram) return { status: "error", code: "xyzac_program_unavailable" }; const payload = input.payload ?? {}; const axis = String(payload.axis ?? "").trim().toUpperCase(); - if (!["X", "Y", "Z", "A", "C"].includes(axis)) return { status: "error", code: "touch_off_axis_invalid" }; + const coordinateAxes = this.machineDefinition?.axes ?? ["x", "y", "z", "a", "c"]; + if (!coordinateAxes.includes(axis.toLowerCase())) return { status: "error", code: "touch_off_axis_invalid" }; const value = Number(payload.value); if (!Number.isFinite(value)) return { status: "error", code: "touch_off_value_invalid" }; const selectedSystem = String(payload.system ?? "P1 G54").trim().toUpperCase(); @@ -1586,8 +1593,9 @@ export class SimSession { let startupLoaded = false; const configuration = input.payload?.configuration ?? null; if (configuration) { + this.machineDefinition = machineDefinition(configuration.entrypoint) ?? machineDefinition(configuration.kinematics?.type) ?? this.machineDefinition; this.workspaceFiles = { ...(configuration.files ?? configuration.workspace ?? configuration.workspace_files ?? {}) }; - const visualSourcePath = "linuxcnc/src/hal/user_comps/vismach/xyzac-trt-gui.py"; + const visualSourcePath = (this.machineDefinition ?? machineDefinition(configuration.entrypoint))?.source_path ?? "linuxcnc/src/hal/user_comps/vismach/xyzac-trt-gui.py"; this.visualModelSourceHash = typeof this.workspaceFiles[visualSourcePath] === "string" ? fnv1a64(this.workspaceFiles[visualSourcePath]) : null; const startupWorkspace = configuration.workspace ?? configuration.files ?? configuration.workspace_files; if (startupWorkspace) { @@ -1633,10 +1641,12 @@ export class SimSession { }; } } - this.motionAuthority = configuration?.entrypoint?.includes("3axis-tutorial") || configuration?.kinematics?.type === "xyzac-trt" ? "wasm" : "js"; - if (configuration?.kinematics?.type === "xyzac-trt") { + const configuredMachine = this.machineDefinition ?? machineDefinition(configuration?.kinematics?.type); + const isTrt = configuredMachine?.runtime === "trt" || configuration?.kinematics?.type === "xyzac-trt" || configuration?.kinematics?.type === "xyzbc-trt"; + this.motionAuthority = configuration?.entrypoint?.includes("3axis-tutorial") || isTrt ? "wasm" : "js"; + if (isTrt) { this.xyzacKinematics?.destroy(); - this.xyzacKinematics = new WasmXyzacKinematics(this.core, { ...configuration.kinematics.offsets, limits: configuration.home_limits }); + this.xyzacKinematics = new WasmXyzacKinematics(this.core, { ...configuration.kinematics.offsets, coordinates: configuredMachine?.coordinates ?? configuration.kinematics.coordinates ?? "XYZAC", limits: configuration.home_limits }); this.xyzacIo?.destroy(); this.xyzacIo = new WasmXyzacIo(this.core, { spindleCount: 2, toolCapacity: 1001 }); const initialPower = this.xyzacIo.command("POWER", { active: false }); @@ -1652,7 +1662,7 @@ export class SimSession { // The selected XYZAC TRT case always uses the upstream LinuxCNC TP; // a caller must not silently downgrade it to the compatibility // motion queue by omitting the planner option. - this.wasmMotion = configuration.kinematics.type === "xyzac-trt" + this.wasmMotion = isTrt ? new WasmLinuxcncTp(this.core, { cycleTime: this.xyzacMotionCycleTime, velocityLimit: this.xyzacRapidVelocity, accelerationLimit: Number(configuration.kinematics?.max_acceleration ?? 300), limits: configuration.home_limits ?? {} }) : new WasmMotionQueue(this.core, { cycleTime: this.xyzacMotionCycleTime, velocityLimit: 1e9 }); this.xyzacProgram = new WasmProgramRuntime(this.core); @@ -1662,9 +1672,10 @@ export class SimSession { this.xyzacRapidVelocity = Number(configuration.kinematics?.max_linear_velocity ?? 35); const linearLimit = Number(configuration.kinematics?.axis_linear_velocity_limit ?? 20); const angularLimit = Number(configuration.kinematics?.axis_angular_velocity_limit ?? 30); - this.xyzacAxisVelocityLimits = { x: linearLimit, y: linearLimit, z: linearLimit, a: angularLimit, c: angularLimit }; + const rotaryAxis = (configuredMachine?.coordinates ?? "XYZAC")[3].toLowerCase(); + this.xyzacAxisVelocityLimits = { x: linearLimit, y: linearLimit, z: linearLimit, [rotaryAxis]: angularLimit, c: angularLimit }; const accelerationLimit = Number(configuration.kinematics?.max_acceleration ?? 300); - this.xyzacAxisAccelerationLimits = { x: accelerationLimit, y: accelerationLimit, z: accelerationLimit, a: accelerationLimit, c: accelerationLimit }; + this.xyzacAxisAccelerationLimits = { x: accelerationLimit, y: accelerationLimit, z: accelerationLimit, [rotaryAxis]: accelerationLimit, c: accelerationLimit }; this.wasmMotion.setDynamics(Number(configuration.kinematics?.max_acceleration ?? 300), Number(configuration.kinematics?.max_jerk ?? 1000000000)); this.xyzacProgram.setRapidVelocity(this.xyzacRapidVelocity); } @@ -1696,7 +1707,7 @@ export class SimSession { }; } const configuredKinematics = input.payload?.kinematics ?? configuration?.kinematics?.type ?? null; - if (configuredKinematics && !(this.motionAuthority === "wasm" && configuredKinematics === "xyzac-trt")) { + if (configuredKinematics && !(this.motionAuthority === "wasm" && isTrt)) { const kinematics = this.authoritative.setKinematics(configuredKinematics); if (kinematics.status === "blocked") return finish(this.envelope("ERROR", input, { status: "blocked", code: kinematics.code, kinematics: this.authoritative.export() }), "failed"); } @@ -1858,9 +1869,10 @@ export class SimSession { const currentState = this.wasmMotion.read().state; const current = currentState.position; this.xyzacProgramKinematicsMode = { 428: XYZAC_MODES.xyzac, 429: XYZAC_MODES.identity, 430: XYZAC_MODES.userk }[pendingControl]; + const rotaryAxis = this.machineDefinition?.coordinates === "XYZBC" ? "b" : "a"; const joints = Array.isArray(currentState.joint_commanded) && currentState.joint_commanded.length === 5 ? currentState.joint_commanded - : [current.x, current.y, current.z, current.a ?? 0, current.c ?? 0]; + : [current.x, current.y, current.z, current[rotaryAxis] ?? 0, current.c ?? 0]; const selected = typeof this.xyzacKinematics.selectPhysical === "function" ? this.xyzacKinematics.selectPhysical(this.xyzacProgramKinematicsMode, joints) : this.xyzacKinematics.select(this.xyzacProgramKinematicsMode, joints); @@ -2602,7 +2614,8 @@ export class SimSession { } if (input.kind === "FOLLOWING_ERROR") return finish(this.error(input, "motion_following_error", "wasm_following_error_unavailable"), "failed"); const axis = String(input.payload?.axis ?? "").toLowerCase(); - if (!["x", "y", "z", "a", "c"].includes(axis)) return finish(this.error(input, "motion_axis", "motion_axis_invalid"), "failed"); + const availableAxes = this.machineDefinition?.axes ?? ["x", "y", "z", "a", "c"]; + if (!availableAxes.includes(axis)) return finish(this.error(input, "motion_axis", "motion_axis_invalid"), "failed"); const direction = input.payload?.direction === -1 ? -1 : input.payload?.direction === 1 ? 1 : null; if (direction === null) return finish(this.error(input, "motion_jog", "motion_jog_range"), "failed"); if (this.xyzacProgram) this.wasmHomeLimit.setMode("TELEOP"); @@ -2618,7 +2631,7 @@ export class SimSession { // sample as a normal queued line and retain the same JOG_STOP abort // path. This compatibility branch is never used by XYZAC TRT. const jog = typeof this.wasmMotion.jog === "function" - ? this.wasmMotion.jog({ x: 0, y: 1, z: 2, a: 3, c: 5 }[axis], direction, distance, input.seq) + ? this.wasmMotion.jog({ x: 0, y: 1, z: 2, a: 3, b: 4, c: 5 }[axis], direction, distance, input.seq) : (() => { const current = this.wasmMotion.read(); const start = current.state?.position ?? {}; diff --git a/web/worker/wasm-linuxcnc-tp.mjs b/web/worker/wasm-linuxcnc-tp.mjs index 9ffc2dd..7745cfd 100644 --- a/web/worker/wasm-linuxcnc-tp.mjs +++ b/web/worker/wasm-linuxcnc-tp.mjs @@ -152,7 +152,10 @@ export class WasmLinuxcncTp { } commandedJoints(position, activeTag) { - if (Number(activeTag) === 0) return [position.x, position.y, position.z, position.a ?? 0, position.c ?? 0]; + if (Number(activeTag) === 0) { + const tilt = this.kinematics?.coordinates === "XYZBC" ? position.b : position.a; + return [position.x, position.y, position.z, tilt ?? 0, position.c ?? 0]; + } const inverse = this.kinematics?.inverseMode?.(Number(activeTag), position) ?? this.kinematics?.inverse?.(position); if (inverse?.status === "verified" && Array.isArray(inverse.joints)) return inverse.joints.slice(0, 5); throw new Error(`linuxcnc_tp_joint_inverse_${inverse?.code ?? "unavailable"}`); @@ -204,7 +207,8 @@ export class WasmLinuxcncTp { const position = toPose(doubles); const joints = this.commandedJoints(position, this.activeTag); const velocityMmMin = Number(call(this.core, "sim_linuxcnc_tp_velocity_mm_min", "number", ["number"], [this.planner])); - const state = { position, velocity: doubles[9], velocity_mm_min: velocityMmMin, acceleration: this.accelerationLimit, progress: doubles[10], tick: ints[0], queue_depth: this.queueDepthOverride === null ? ints[1] : this.queueDepthOverride, active_queue: activeId ? 1 : 0, active_id: activeId, active_tag: this.activeTag, joint_commanded: joints, joint_actual: joints.slice(), motion_type: this.core.HEAP32[this.statePointer / 4 + 25], status: this.core.HEAP32[this.statePointer / 4 + 26], exec_state: this.executionState, velocity_limit: velocityLimit, velocity_scale: velocityScale, jog_velocity_linear: this.jogVelocity("x"), jog_velocity_angular: this.jogVelocity("a"), feed_override: feedOverride, jog_scale: jogScale, source: "linuxcnc-source-tp" }; + const rotaryAxis = this.kinematics?.coordinates === "XYZBC" ? "b" : "a"; + const state = { position, velocity: doubles[9], velocity_mm_min: velocityMmMin, acceleration: this.accelerationLimit, progress: doubles[10], tick: ints[0], queue_depth: this.queueDepthOverride === null ? ints[1] : this.queueDepthOverride, active_queue: activeId ? 1 : 0, active_id: activeId, active_tag: this.activeTag, joint_commanded: joints, joint_actual: joints.slice(), motion_type: this.core.HEAP32[this.statePointer / 4 + 25], status: this.core.HEAP32[this.statePointer / 4 + 26], exec_state: this.executionState, velocity_limit: velocityLimit, velocity_scale: velocityScale, jog_velocity_linear: this.jogVelocity("x"), jog_velocity_angular: this.jogVelocity(rotaryAxis), feed_override: feedOverride, jog_scale: jogScale, source: "linuxcnc-source-tp" }; this.lastState = state; return { status: "verified", state }; } diff --git a/web/worker/wasm-xyzac-kinematics.mjs b/web/worker/wasm-xyzac-kinematics.mjs index 8d2abfa..8e36432 100644 --- a/web/worker/wasm-xyzac-kinematics.mjs +++ b/web/worker/wasm-xyzac-kinematics.mjs @@ -13,11 +13,10 @@ const CONFIG = Object.freeze({ limit_enabled: 1448, joint_min: 1464, joint_max: 1536, }); const SWITCH = Object.freeze({ switching: 16, active_mode: 20, pending_mode: 24, generation: 28, switch_count: 32, abort_count: 36 }); -const TYPE = Object.freeze({ identity: 1, xyzac: 2, userk: 1 }); +const TYPE = Object.freeze({ identity: 1, xyzac: 2, xyzbc: 3, userk: 1 }); const MODE = Object.freeze({ identity: 0, xyzac: 1, userk: 2 }); const AXES = Object.freeze(["x", "y", "z", "a", "b", "c", "u", "v", "w"]); -const COORDINATES = Object.freeze(["x", "y", "z", "a", "c"]); -const DEFAULT_LIMITS = Object.freeze({ x: [-200, 200], y: [-100, 100], z: [-120, 120], a: [-100, 50], c: [-36000, 36000] }); +const DEFAULT_LIMITS = Object.freeze({ x: [-200, 200], y: [-100, 100], z: [-120, 120], a: [-100, 50], b: [-100, 50], c: [-36000, 36000] }); function call(core, name, returnType, argTypes, args) { const direct = core?.[`_${name}`] ?? core?.[name]; @@ -31,11 +30,11 @@ function pose(core, pointer) { return Object.fromEntries(AXES.map((axis, index) => [axis, doubles[index]])); } -function setCoordinates(core, pointer) { - for (const [index, value] of [..."XYZAC"].entries()) core.HEAPU8[pointer + CONFIG.coordinates + index] = value.charCodeAt(0); +function setCoordinates(core, pointer, coordinates) { + for (const [index, value] of [...coordinates].entries()) core.HEAPU8[pointer + CONFIG.coordinates + index] = value.charCodeAt(0); } -function configFor(core, pointer, type, options) { +function configFor(core, pointer, type, options, coordinates) { core.HEAPU8.fill(0, pointer, pointer + CONFIG_SIZE); const view = new DataView(core.HEAPU8.buffer, pointer, CONFIG_SIZE); view.setInt32(CONFIG.type, type, true); @@ -43,14 +42,22 @@ function configFor(core, pointer, type, options) { view.setUint32(CONFIG.joint_count, 5, true); view.setUint32(CONFIG.kinematics_joint_count, 5, true); view.setUint32(CONFIG.extra_joint_count, 0, true); - setCoordinates(core, pointer); - for (const [index, axis] of COORDINATES.entries()) { + setCoordinates(core, pointer, coordinates); + for (const [index, axis] of coordinates.toLowerCase().split("").entries()) { const limit = options.limits?.[axis] ?? DEFAULT_LIMITS[axis]; core.HEAPU8[pointer + CONFIG.limit_enabled + index] = 1; view.setFloat64(CONFIG.joint_min + index * 8, Number(limit[0]), true); view.setFloat64(CONFIG.joint_max + index * 8, Number(limit[1]), true); } - for (const [key, offset] of Object.entries({ x_rot_point: CONFIG.x_rot_point, y_offset: CONFIG.y_offset, z_offset: CONFIG.z_offset, tool_offset: CONFIG.tool_offset })) { + for (const [key, offset] of Object.entries({ + x_rot_point: CONFIG.x_rot_point, + y_rot_point: CONFIG.y_rot_point, + z_rot_point: CONFIG.z_rot_point, + x_offset: CONFIG.x_offset, + y_offset: CONFIG.y_offset, + z_offset: CONFIG.z_offset, + tool_offset: CONFIG.tool_offset, + })) { view.setFloat64(offset, Number(options[key] ?? 0), true); } } @@ -62,17 +69,21 @@ function resultCode(operation, result) { export class WasmXyzacKinematics { constructor(core, options = {}) { this.core = core; - this.options = { x_rot_point: 0, y_offset: 20, z_offset: 10, tool_offset: 0, limits: DEFAULT_LIMITS, ...options }; + this.coordinates = String(options.coordinates ?? "XYZAC").toUpperCase(); + if (!["XYZAC", "XYZBC"].includes(this.coordinates)) throw new Error("wasm_trt_coordinates_invalid"); + this.kinematicsType = this.coordinates === "XYZBC" ? "xyzbc-trt-kins" : "xyzac-trt-kins"; + this.options = { x_rot_point: 0, y_rot_point: 0, z_rot_point: 0, x_offset: 0, y_offset: 20, z_offset: 10, tool_offset: 0, limits: DEFAULT_LIMITS, ...options }; + if (this.coordinates === "XYZBC" && options.limits === undefined) this.options.limits = { x: [-200, 200], y: [-100, 100], z: [-120, 120], b: [-100, 50], c: [-36000, 36000] }; this.available = ["sim_kinematics_init", "sim_kinematics_switch_init", "sim_kinematics_switch_register", "sim_kinematics_switch_select", "sim_kinematics_switch_select_physical", "sim_kinematics_switch_forward", "sim_kinematics_switch_inverse", "sim_kinematics_switch_inverse_mode", "sim_kinematics_switch_active", "sim_kinematics_switch_state_hash"].every((name) => typeof core?.[`_${name}`] === "function" || typeof core?.[name] === "function"); this.lastCommand = null; this.modules = new Map(); this.pointers = []; if (!this.available || typeof core?._malloc !== "function") return; - for (const [mode, type] of [[MODE.identity, TYPE.identity], [MODE.xyzac, TYPE.xyzac], [MODE.userk, TYPE.userk]]) { + for (const [mode, type] of [[MODE.identity, TYPE.identity], [MODE.xyzac, this.coordinates === "XYZBC" ? TYPE.xyzbc : TYPE.xyzac], [MODE.userk, TYPE.userk]]) { const config = core._malloc(CONFIG_SIZE); const kinematics = core._malloc(KINEMATICS_SIZE); if (!config || !kinematics) throw new Error("wasm_xyzac_allocation_failed"); - configFor(core, config, type, this.options); + configFor(core, config, type, this.options, this.coordinates); const result = call(core, "sim_kinematics_init", "number", ["number", "number"], [kinematics, config]); if (result !== 0) throw new Error(`wasm_xyzac_init_${result}`); this.modules.set(mode, { config, kinematics }); @@ -185,10 +196,10 @@ export class WasmXyzacKinematics { snapshot(currentPose = pose(this.core, this.posePointer), joints = [...this.core.HEAPF64.subarray(this.jointPointer / 8, this.jointPointer / 8 + 5)]) { const activeMode = this.activeMode; - const nativeType = activeMode === MODE.xyzac ? "xyzac-trt-kins" : activeMode === MODE.userk ? "userk-identity-template" : "identity"; + const nativeType = activeMode === MODE.xyzac ? this.kinematicsType : activeMode === MODE.userk ? "userk-identity-template" : "identity"; const view = new DataView(this.core.HEAPU8.buffer, this.switchPointer, SWITCH_SIZE); - const limits = Object.fromEntries(COORDINATES.map((axis) => [axis, [...(this.options.limits?.[axis] ?? DEFAULT_LIMITS[axis])]])); - const unsigned = { schema: "wasm-xyzac-kinematics.v1", active_type: activeMode, native_type: nativeType, coordinates: "XYZAC", joint_count: 5, linear_units: "mm", angular_units: "deg", pose: currentPose, joints: joints.slice(0, 5), offsets: { x_rot_point: this.options.x_rot_point, y_offset: this.options.y_offset, z_offset: this.options.z_offset, tool_offset: this.options.tool_offset }, limits, generation: view.getUint32(SWITCH.generation, true), switch_count: view.getUint32(SWITCH.switch_count, true), abort_count: view.getUint32(SWITCH.abort_count, true), last_command: this.lastCommand, capability: { userk: "linked_identity_template", dynamic_modules: "denied" }, source: "linuxcnc-wasm-sim_kinematics_switch" }; + const limits = Object.fromEntries(this.coordinates.toLowerCase().split("").map((axis) => [axis, [...(this.options.limits?.[axis] ?? DEFAULT_LIMITS[axis])]])); + const unsigned = { schema: "wasm-xyzac-kinematics.v1", active_type: activeMode, native_type: nativeType, coordinates: this.coordinates, joint_count: 5, linear_units: "mm", angular_units: "deg", pose: currentPose, joints: joints.slice(0, 5), offsets: { x_rot_point: this.options.x_rot_point, y_rot_point: this.options.y_rot_point, z_rot_point: this.options.z_rot_point, x_offset: this.options.x_offset, y_offset: this.options.y_offset, z_offset: this.options.z_offset, tool_offset: this.options.tool_offset }, limits, generation: view.getUint32(SWITCH.generation, true), switch_count: view.getUint32(SWITCH.switch_count, true), abort_count: view.getUint32(SWITCH.abort_count, true), last_command: this.lastCommand, capability: { userk: "linked_identity_template", dynamic_modules: "denied" }, source: "linuxcnc-wasm-sim_kinematics_switch" }; const hash = call(this.core, "sim_kinematics_switch_state_hash", "number", ["number", "number", "number", "number"], [this.switchPointer, this.jointPointer, 5, this.posePointer]); return { status: "verified", ...unsigned, wasm_state_hash: `fnv1a64:${BigInt.asUintN(64, BigInt(hash >>> 0)).toString(16).padStart(16, "0")}`, state_hash: fnv1a64(unsigned) }; } diff --git a/web/workspace/machine-definitions.mjs b/web/workspace/machine-definitions.mjs new file mode 100644 index 0000000..5d4cf29 --- /dev/null +++ b/web/workspace/machine-definitions.mjs @@ -0,0 +1,28 @@ +const TRT_ROOT = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting"; + +function trt(id, coordinates, type, offsets, model) { + const stem = coordinates.toLowerCase(); + return Object.freeze({ + id, label: `${coordinates} TRT`, coordinates, axes: [...stem], joint_count: coordinates.length, + entrypoint: `${TRT_ROOT}/${id}.ini`, program: `${TRT_ROOT}/demos/${stem}_switchkins.ngc`, + kinematics_type: type, runtime: "trt", model, + source_path: `linuxcnc/src/hal/user_comps/vismach/${id}-gui.py`, + subroutines: [`${stem}_switchkins_sub`, `helix_${stem.slice(3)}`], + offsets, + joint_limits: { x: [-200, 200], y: [-100, 100], z: [-120, 120], [stem[3]]: [-100, 50], c: [-36000, 36000] }, + axis_limits: { x: [-200, 200], y: [-100, 100], z: [-120, 120], [stem[3]]: coordinates === "XYZAC" ? [-100, 50] : [-36000, 36000], c: [-36000, 36000] }, + }); +} + +export const MACHINE_DEFINITIONS = Object.freeze([ + trt("xyzac-trt", "XYZAC", 2, { x_rot_point: 0, y_offset: 20, z_offset: 10, tool_offset: 0 }, "trt-moving-knee"), + trt("xyzbc-trt", "XYZBC", 3, { x_rot_point: 0, y_rot_point: 0, z_rot_point: 0, x_offset: -20, y_offset: 0, z_offset: -15, tool_offset: 0 }, "trt-moving-head"), +]); +export const DEFAULT_MACHINE = MACHINE_DEFINITIONS[0]; +export function machineDefinition(value) { + return MACHINE_DEFINITIONS.find((entry) => [entry.id, entry.entrypoint, entry.coordinates].includes(value)) ?? null; +} +export function machineAxes(snapshot) { + const coordinates = snapshot?.kinematics?.coordinates ?? snapshot?.visual?.machine_model?.coordinate_system; + return machineDefinition(coordinates)?.axes ?? DEFAULT_MACHINE.axes; +} diff --git a/web/workspace/sim-case-loader.mjs b/web/workspace/sim-case-loader.mjs index 1a5ac23..7ded913 100644 --- a/web/workspace/sim-case-loader.mjs +++ b/web/workspace/sim-case-loader.mjs @@ -2,6 +2,7 @@ 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"; export const SIMULATION_CASE_SCHEMA = "simulation-case-bundle.v1"; const RESOURCE_KEYS = new Set([ @@ -17,6 +18,7 @@ const CASE_KINEMATICS = Object.freeze({ "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 } }), }); const RUNTIME_FIXTURES = Object.freeze({ "linuxcnc/configs/sim/axis/minimal_xyz.ini": Object.freeze({ @@ -49,6 +51,12 @@ const RUNTIME_FIXTURES = Object.freeze({ with: "HALFILE = xyzac-trt-headless_cmds.hal", reason: "LinuxCNC basic_sim.tcl generated XYZAC HAL command fixture", }), + "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini": Object.freeze({ + generated_hal: "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt_cmds.hal", + replace: "HALFILE = LIB:basic_sim.tcl", + with: "HALFILE = xyzbc-trt_cmds.hal", + reason: "LinuxCNC basic_sim.tcl generated XYZBC HAL command fixture", + }), }); function clone(value) { return structuredClone(value); } @@ -117,7 +125,7 @@ function generatedResource(path, key) { return generated ? { ...generated, data_hash: fnv1a64(generated.content) } : null; } function inManifest(path) { - return MANIFEST.has(path) || path === "linuxcnc/configs/nc_files/3dtest.ngc"; + 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(/^\//, "/"); } @@ -218,8 +226,9 @@ export async function loadSimulationCase({ path, fetchImpl = globalThis.fetch, b // 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. - if (catalog.path === "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini") { - const visualSource = "linuxcnc/src/hal/user_comps/vismach/xyzac-trt-gui.py"; + const machine = machineDefinition(catalog.path); + if (machine) { + const visualSource = machine.source_path; if (!(visualSource in files)) { const content = await fetchText(fetchImpl, baseUrl, visualSource); if (content === null) missing.push(visualSource); @@ -269,7 +278,7 @@ export function toWorkerConfiguration(bundle) { 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] } } : {}), + ...(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]) } : {}), }; }